Feature: Screenshot of video (#2221)

* screenshot

* player settings, jpg & default "pictures" folder

* filename pattern

* folder placeholder update

* remove duplicate action

* update: won't save invalid pattern

* Ask for folder, toggle screenshot, modal showSaveDialog & button unfocus

* useModal
This commit is contained in:
bob1520 2022-05-30 22:24:34 +09:00 committed by GitHub
parent 80435960bf
commit ddce28e586
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 557 additions and 18 deletions

View File

@ -6,6 +6,7 @@ const IpcChannels = {
GET_SYSTEM_LOCALE: 'get-system-locale',
GET_USER_DATA_PATH: 'get-user-data-path',
GET_USER_DATA_PATH_SYNC: 'get-user-data-path-sync',
GET_PICTURES_PATH: 'get-pictures-path',
SHOW_OPEN_DIALOG: 'show-open-dialog',
SHOW_SAVE_DIALOG: 'show-save-dialog',
STOP_POWER_SAVE_BLOCKER: 'stop-power-save-blocker',

View File

@ -405,11 +405,23 @@ function runApp() {
event.returnValue = app.getPath('userData')
})
ipcMain.handle(IpcChannels.GET_PICTURES_PATH, () => {
return app.getPath('pictures')
})
ipcMain.handle(IpcChannels.SHOW_OPEN_DIALOG, async (_, options) => {
return await dialog.showOpenDialog(options)
})
ipcMain.handle(IpcChannels.SHOW_SAVE_DIALOG, async (_, options) => {
ipcMain.handle(IpcChannels.SHOW_SAVE_DIALOG, async (event, { options, useModal }) => {
if (useModal) {
const senderWindow = BrowserWindow.getAllWindows().find((window) => {
return window.webContents.id === event.sender.id
})
if (senderWindow) {
return await dialog.showSaveDialog(senderWindow, options)
}
}
return await dialog.showSaveDialog(options)
})

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="26" height="26" viewBox="0 0 24 24"
stroke-width="1.5"
stroke="#ffffff"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
<circle cx="12" cy="13" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -683,7 +683,7 @@ export default Vue.extend({
]
}
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return
@ -766,7 +766,7 @@ export default Vue.extend({
return object
})
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return
@ -818,7 +818,7 @@ export default Vue.extend({
}
})
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return
@ -864,7 +864,7 @@ export default Vue.extend({
exportText += `${channel.id},${channelUrl},${channelName}\n`
})
exportText += '\n'
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return
@ -917,7 +917,7 @@ export default Vue.extend({
newPipeObject.subscriptions.push(subscription)
})
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return
@ -1048,7 +1048,7 @@ export default Vue.extend({
]
}
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return
@ -1220,7 +1220,7 @@ export default Vue.extend({
]
}
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return

View File

@ -50,7 +50,7 @@
}
.pure-material-slider > input:disabled + span {
color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
opacity: 0.38;
}
/* Webkit | Track */

View File

@ -26,6 +26,10 @@ export default Vue.extend({
valueExtension: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
}
},
data: function () {

View File

@ -5,11 +5,12 @@
<input
:id="id"
v-model.number="currentValue"
:disabled="disabled"
type="range"
:min="minValue"
:max="maxValue"
:step="step"
@change="$emit('change', $event.target.value)"
@change="$emit('change', currentValue)"
>
<span>
{{ label }}:

View File

@ -6,6 +6,7 @@ import $ from 'jquery'
import videojs from 'video.js'
import qualitySelector from '@silvermine/videojs-quality-selector'
import fs from 'fs'
import path from 'path'
import 'videojs-overlay/dist/videojs-overlay'
import 'videojs-overlay/dist/videojs-overlay.css'
import 'videojs-vtt-thumbnails-freetube'
@ -115,6 +116,7 @@ export default Vue.extend({
'seekToLive',
'remainingTimeDisplay',
'customControlSpacer',
'screenshotButton',
'playbackRateMenuButton',
'loopButton',
'chaptersButton',
@ -263,11 +265,35 @@ export default Vue.extend({
}
return playbackRates
},
enableScreenshot: function() {
return this.$store.getters.getEnableScreenshot
},
screenshotFormat: function() {
return this.$store.getters.getScreenshotFormat
},
screenshotQuality: function() {
return this.$store.getters.getScreenshotQuality
},
screenshotAskPath: function() {
return this.$store.getters.getScreenshotAskPath
},
screenshotFolder: function() {
return this.$store.getters.getScreenshotFolderPath
}
},
watch: {
showStatsModal: function() {
this.player.trigger(this.statsModalEventName)
},
enableScreenshot: function() {
this.toggleScreenshotButton()
}
},
mounted: function () {
@ -284,6 +310,7 @@ export default Vue.extend({
this.createFullWindowButton()
this.createLoopButton()
this.createToggleTheatreModeButton()
this.createScreenshotButton()
this.determineFormatType()
this.determineMaxFramerate()
@ -437,6 +464,7 @@ export default Vue.extend({
if (this.captionHybridList.length !== 0) {
this.transformAndInsertCaptions()
}
this.toggleScreenshotButton()
})
this.player.on('ended', () => {
@ -1224,6 +1252,168 @@ export default Vue.extend({
this.$parent.toggleTheatreMode()
},
createScreenshotButton: function() {
const VjsButton = videojs.getComponent('Button')
const screenshotButton = videojs.extend(VjsButton, {
constructor: function(player, options) {
VjsButton.call(this, player, options)
},
handleClick: () => {
this.takeScreenshot()
const video = document.getElementsByTagName('video')[0]
video.focus()
video.blur()
},
createControlTextEl: function (button) {
return $(button)
.html('<div id="screenshotButton" class="vjs-icon-screenshot vjs-button vjs-hidden"></div>')
.attr('title', 'Screenshot')
}
})
videojs.registerComponent('screenshotButton', screenshotButton)
},
toggleScreenshotButton: function() {
const button = document.getElementById('screenshotButton')
if (this.enableScreenshot && this.format !== 'audio') {
button.classList.remove('vjs-hidden')
} else {
button.classList.add('vjs-hidden')
}
},
takeScreenshot: async function() {
if (!this.enableScreenshot || this.format === 'audio') {
return
}
const width = this.player.videoWidth()
const height = this.player.videoHeight()
if (width <= 0) {
return
}
// Need to set crossorigin="anonymous" for LegacyFormat on Invidious
// https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
const video = document.querySelector('video')
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(video, 0, 0)
const format = this.screenshotFormat
const mimeType = `image/${format === 'jpg' ? 'jpeg' : format}`
const imageQuality = format === 'jpg' ? this.screenshotQuality / 100 : 1
let filename
try {
filename = await this.parseScreenshotCustomFileName({
date: new Date(Date.now()),
playerTime: this.player.currentTime(),
videoId: this.videoId
})
} catch (err) {
console.error(`Parse failed: ${err.message}`)
this.showToast({
message: this.$t('Screenshot Error').replace('$', err.message)
})
canvas.remove()
return
}
const dirChar = process.platform === 'win32' ? '\\' : '/'
let subDir = ''
if (filename.indexOf(dirChar) !== -1) {
const lastIndex = filename.lastIndexOf(dirChar)
subDir = filename.substring(0, lastIndex)
filename = filename.substring(lastIndex + 1)
}
const filenameWithExtension = `${filename}.${format}`
let dirPath
let filePath
if (this.screenshotAskPath) {
const wasPlaying = !this.player.paused()
if (wasPlaying) {
this.player.pause()
}
if (this.screenshotFolder === '' || !fs.existsSync(this.screenshotFolder)) {
dirPath = await this.getPicturesPath()
} else {
dirPath = this.screenshotFolder
}
const options = {
defaultPath: path.join(dirPath, filenameWithExtension),
filters: [
{
name: format.toUpperCase(),
extensions: [format]
}
]
}
const response = await this.showSaveDialog({ options, useModal: true })
if (wasPlaying) {
this.player.play()
}
if (response.canceled || response.filePath === '') {
canvas.remove()
return
}
filePath = response.filePath
if (!filePath.endsWith(`.${format}`)) {
filePath = `${filePath}.${format}`
}
dirPath = path.dirname(filePath)
this.updateScreenshotFolderPath(dirPath)
} else {
if (this.screenshotFolder === '') {
dirPath = path.join(await this.getPicturesPath(), 'Freetube', subDir)
} else {
dirPath = path.join(this.screenshotFolder, subDir)
}
if (!fs.existsSync(dirPath)) {
try {
fs.mkdirSync(dirPath, { recursive: true })
} catch (err) {
console.error(err)
this.showToast({
message: this.$t('Screenshot Error').replace('$', err)
})
canvas.remove()
return
}
}
filePath = path.join(dirPath, filenameWithExtension)
}
canvas.toBlob((result) => {
result.arrayBuffer().then(ab => {
const arr = new Uint8Array(ab)
fs.writeFile(filePath, arr, (err) => {
if (err) {
console.error(err)
this.showToast({
message: this.$t('Screenshot Error').replace('$', err)
})
} else {
this.showToast({
message: this.$t('Screenshot Success').replace('$', filePath)
})
}
})
})
}, mimeType, imageQuality)
canvas.remove()
},
createDashQualitySelector: function (levels) {
if (levels.levels_.length === 0) {
setTimeout(() => {
@ -1751,6 +1941,11 @@ export default Vue.extend({
// Toggle Theatre Mode
this.toggleTheatreMode()
break
case 85:
// U Key
// Take screenshot
this.takeScreenshot()
break
}
}
},
@ -1759,7 +1954,11 @@ export default Vue.extend({
'calculateColorLuminance',
'updateDefaultCaptionSettings',
'showToast',
'sponsorBlockSkipSegments'
'sponsorBlockSkipSegments',
'parseScreenshotCustomFileName',
'updateScreenshotFolderPath',
'getPicturesPath',
'showSaveDialog'
])
}
})

View File

@ -7,6 +7,7 @@
controls
preload="auto"
:data-setup="JSON.stringify(dataSetup)"
crossorigin="anonymous"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
>

View File

@ -5,6 +5,12 @@ import FtSelect from '../ft-select/ft-select.vue'
import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
import FtSlider from '../ft-slider/ft-slider.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtButton from '../ft-button/ft-button.vue'
import FtInput from '../ft-input/ft-input.vue'
import FtTooltip from '../ft-tooltip/ft-tooltip.vue'
import { ipcRenderer } from 'electron'
import { IpcChannels } from '../../../constants'
import path from 'path'
export default Vue.extend({
name: 'PlayerSettings',
@ -13,7 +19,10 @@ export default Vue.extend({
'ft-select': FtSelect,
'ft-toggle-switch': FtToggleSwitch,
'ft-slider': FtSlider,
'ft-flex-box': FtFlexBox
'ft-flex-box': FtFlexBox,
'ft-button': FtButton,
'ft-input': FtInput,
'ft-tooltip': FtTooltip
},
data: function () {
return {
@ -36,7 +45,18 @@ export default Vue.extend({
0.25,
0.5,
1
]
],
screenshotFormatNames: [
'PNG',
'JPEG'
],
screenshotFormatValues: [
'png',
'jpg'
],
screenshotFolderPlaceholder: '',
screenshotFilenameExample: '',
screenshotDefaultPattern: '%Y%M%D-%H%N%S'
}
},
computed: {
@ -138,9 +158,101 @@ export default Vue.extend({
this.$t('Settings.Player Settings.Default Quality.720p'),
this.$t('Settings.Player Settings.Default Quality.1080p')
]
},
enableScreenshot: function() {
return this.$store.getters.getEnableScreenshot
},
screenshotFormat: function() {
return this.$store.getters.getScreenshotFormat
},
screenshotQuality: function() {
return this.$store.getters.getScreenshotQuality
},
screenshotAskPath: function() {
return this.$store.getters.getScreenshotAskPath
},
screenshotFolder: function() {
return this.$store.getters.getScreenshotFolderPath
},
screenshotFilenamePattern: function() {
return this.$store.getters.getScreenshotFilenamePattern
}
},
watch: {
screenshotFolder: function() {
this.getScreenshotFolderPlaceholder()
}
},
mounted: function() {
this.getScreenshotFolderPlaceholder()
this.getScreenshotFilenameExample(this.screenshotFilenamePattern)
},
methods: {
handleUpdateScreenshotFormat: async function(format) {
await this.updateScreenshotFormat(format)
this.getScreenshotFilenameExample(this.screenshotFilenamePattern)
},
getScreenshotEmptyFolderPlaceholder: async function() {
return path.join(await this.getPicturesPath(), 'Freetube')
},
getScreenshotFolderPlaceholder: function() {
if (this.screenshotFolder !== '') {
this.screenshotFolderPlaceholder = this.screenshotFolder
return
}
this.getScreenshotEmptyFolderPlaceholder().then((res) => {
this.screenshotFolderPlaceholder = res
})
},
chooseScreenshotFolder: async function() {
// only use with electron
const folder = await ipcRenderer.invoke(
IpcChannels.SHOW_OPEN_DIALOG,
{ properties: ['openDirectory'] }
)
if (!folder.canceled) {
await this.updateScreenshotFolderPath(folder.filePaths[0])
this.getScreenshotFolderPlaceholder()
}
},
handleScreenshotFilenamePatternChanged: async function(input) {
const pattern = input.trim()
if (!await this.getScreenshotFilenameExample(pattern)) {
return
}
if (pattern) {
this.updateScreenshotFilenamePattern(pattern)
} else {
this.updateScreenshotFilenamePattern(this.screenshotDefaultPattern)
}
},
getScreenshotFilenameExample: function(pattern) {
return this.parseScreenshotCustomFileName({
pattern: pattern || this.screenshotDefaultPattern,
date: new Date(Date.now()),
playerTime: 123.456,
videoId: 'dQw4w9WgXcQ'
}).then(res => {
this.screenshotFilenameExample = `${res}.${this.screenshotFormat}`
return true
}).catch(err => {
this.screenshotFilenameExample = `${this.$t(`Settings.Player Settings.Screenshot.Error.${err.message}`)}`
return false
})
},
...mapActions([
'updateAutoplayVideos',
'updateAutoplayPlaylists',
@ -159,7 +271,15 @@ export default Vue.extend({
'updateVideoPlaybackRateMouseScroll',
'updateDisplayVideoPlayButton',
'updateMaxVideoPlaybackRate',
'updateVideoPlaybackRateInterval'
'updateVideoPlaybackRateInterval',
'updateEnableScreenshot',
'updateScreenshotFormat',
'updateScreenshotQuality',
'updateScreenshotAskPath',
'updateScreenshotFolderPath',
'updateScreenshotFilenamePattern',
'parseScreenshotCustomFileName',
'getPicturesPath'
])
}
})

View File

@ -1 +1,14 @@
@use "../../sass-partials/settings"
.screenshotFolderContainer
width: 95%
margin: 0 auto
align-items: center
column-gap: 1rem
.screenshotFolderLabel, .screenshotFolderButton, .screenshotFilenamePatternTitle
flex-grow: 0
.screenshotFolderPath, .screenshotFilenamePatternInput, .screenshotFilenamePatternExample
flex-grow: 1
margin-top: 10px

View File

@ -149,6 +149,88 @@
@change="updateDefaultQuality"
/>
</ft-flex-box>
<br>
<ft-flex-box>
<ft-toggle-switch
:label="$t('Settings.Player Settings.Screenshot.Enable')"
:default-value="enableScreenshot"
@change="updateEnableScreenshot"
/>
</ft-flex-box>
<div v-if="enableScreenshot">
<ft-flex-box>
<ft-select
:placeholder="$t('Settings.Player Settings.Screenshot.Format Label')"
:value="screenshotFormat"
:select-names="screenshotFormatNames"
:select-values="screenshotFormatValues"
@change="handleUpdateScreenshotFormat"
/>
<ft-slider
:label="$t('Settings.Player Settings.Screenshot.Quality Label')"
:default-value="screenshotQuality"
:min-value="0"
:max-value="100"
:step="1"
value-extension="%"
:disabled="screenshotFormat !== 'jpg'"
@change="updateScreenshotQuality"
/>
</ft-flex-box>
<ft-flex-box>
<ft-toggle-switch
:label="$t('Settings.Player Settings.Screenshot.Ask Path')"
:default-value="screenshotAskPath"
@change="updateScreenshotAskPath"
/>
</ft-flex-box>
<ft-flex-box
v-if="!screenshotAskPath"
class="screenshotFolderContainer"
>
<p class="screenshotFolderLabel">
{{ $t('Settings.Player Settings.Screenshot.Folder Label') }}
</p>
<ft-input
class="screenshotFolderPath"
:placeholder="screenshotFolderPlaceholder"
:show-action-button="false"
:show-label="false"
:disabled="true"
/>
<ft-button
:label="$t('Settings.Player Settings.Screenshot.Folder Button')"
class="screenshotFolderButton"
@click="chooseScreenshotFolder"
/>
</ft-flex-box>
<ft-flex-box class="screenshotFolderContainer">
<p class="screenshotFilenamePatternTitle">
{{ $t('Settings.Player Settings.Screenshot.File Name Label') }}
<ft-tooltip
class="selectTooltip"
position="bottom"
:tooltip="$t('Settings.Player Settings.Screenshot.File Name Tooltip')"
/>
</p>
<ft-input
class="screenshotFilenamePatternInput"
placeholder=""
:value="screenshotFilenamePattern"
:spellcheck="false"
:show-action-button="false"
:show-label="false"
@input="handleScreenshotFilenamePatternChanged"
/>
<ft-input
class="screenshotFilenamePatternExample"
:placeholder="`${screenshotFilenameExample}`"
:show-action-button="false"
:show-label="false"
:disabled="true"
/>
</ft-flex-box>
</div>
</details>
</template>

View File

@ -254,7 +254,13 @@ const state = {
videoVolumeMouseScroll: false,
videoPlaybackRateMouseScroll: false,
videoPlaybackRateInterval: 0.25,
downloadFolderPath: ''
downloadFolderPath: '',
enableScreenshot: false,
screenshotFormat: 'png',
screenshotQuality: 95,
screenshotAskPath: false,
screenshotFolderPath: '',
screenshotFilenamePattern: '%Y%M%D-%H%N%S'
}
const stateWithSideEffects = {

View File

@ -200,11 +200,12 @@ const actions = {
defaultPath: fileName,
filters: [
{
name: extension.toUpperCase(),
extensions: [extension]
}
]
}
const response = await dispatch('showSaveDialog', options)
const response = await dispatch('showSaveDialog', { options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
@ -283,10 +284,10 @@ const actions = {
return await invokeIRC(context, IpcChannels.SHOW_OPEN_DIALOG, webCbk, options)
},
async showSaveDialog (context, options) {
async showSaveDialog (context, { options, useModal = false }) {
// TODO: implement showSaveDialog web compatible callback
const webCbk = () => null
return await invokeIRC(context, IpcChannels.SHOW_SAVE_DIALOG, webCbk, options)
return await invokeIRC(context, IpcChannels.SHOW_SAVE_DIALOG, webCbk, { options, useModal })
},
async getUserDataPath (context) {
@ -295,6 +296,66 @@ const actions = {
return await invokeIRC(context, IpcChannels.GET_USER_DATA_PATH, webCbk)
},
async getPicturesPath (context) {
const webCbk = () => null
return await invokeIRC(context, IpcChannels.GET_PICTURES_PATH, webCbk)
},
parseScreenshotCustomFileName: function({ rootState }, payload) {
return new Promise((resolve, reject) => {
const { pattern = rootState.settings.screenshotFilenamePattern, date, playerTime, videoId } = payload
const keywords = [
['%Y', date.getFullYear()], // year 4 digits
['%M', (date.getMonth() + 1).toString().padStart(2, '0')], // month 2 digits
['%D', date.getDate().toString().padStart(2, '0')], // day 2 digits
['%H', date.getHours().toString().padStart(2, '0')], // hour 2 digits
['%N', date.getMinutes().toString().padStart(2, '0')], // minute 2 digits
['%S', date.getSeconds().toString().padStart(2, '0')], // second 2 digits
['%T', date.getMilliseconds().toString().padStart(3, '0')], // millisecond 3 digits
['%s', parseInt(playerTime)], // video position second n digits
['%t', (playerTime % 1).toString().slice(2, 5) || '000'], // video position millisecond 3 digits
['%i', videoId] // video id
]
let parsedString = pattern
for (const [key, value] of keywords) {
parsedString = parsedString.replaceAll(key, value)
}
const platform = process.platform
if (platform === 'win32') {
// https://www.boost.org/doc/libs/1_78_0/libs/filesystem/doc/portability_guide.htm
// https://stackoverflow.com/questions/1976007/
const noForbiddenChars = ['<', '>', ':', '"', '/', '|'].every(char => {
return parsedString.indexOf(char) === -1
})
if (!noForbiddenChars) {
reject(new Error('Forbidden Characters')) // use message as translation key
}
} else if (platform === 'darwin') {
// https://superuser.com/questions/204287/
if (parsedString.indexOf(':') !== -1) {
reject(new Error('Forbidden Characters'))
}
}
const dirChar = platform === 'win32' ? '\\' : '/'
let filename
if (parsedString.indexOf(dirChar) !== -1) {
const lastIndex = parsedString.lastIndexOf(dirChar)
filename = parsedString.substring(lastIndex + 1)
} else {
filename = parsedString
}
if (!filename) {
reject(new Error('Empty File Name'))
}
resolve(parsedString)
})
},
updateShowProgressBar ({ commit }, value) {
commit('setShowProgressBar', value)
},

View File

@ -490,6 +490,16 @@ body.vjs-full-window {
content: url(assets/img/close_theatre.svg)
}
.vjs-icon-screenshot {
margin-top: 3px;
padding-top: 3px;
cursor: pointer;
}
.vjs-icon-screenshot::before {
content: url(assets/img/camera.svg)
}
@media only screen and (max-width: 1350px) {
.videoPlayer .vjs-button-theatre {
display: none

View File

@ -232,6 +232,20 @@ Settings:
1440p: 1440p
4k: 4k
8k: 8k
Screenshot:
Enable: Enable Screenshot
Format Label: Screenshot Format
Quality Label: Screenshot Quality
Ask Path: Ask for Save Folder
Folder Label: Screenshot Folder
Folder Button: Select Folder
File Name Label: Filename Pattern
File Name Tooltip: You can use variables below. %Y Year 4 digits. %M Month 2 digits.
%D Day 2 digits. %H Hour 2 digits. %N Minute 2 digits. %S Second 2 digits. %T Millisecond 3 digits.
%s Video Second. %t Video Millisecond 3 digits. %i Video ID. You can also use "\" or "/" to create subfolders.
Error:
Forbidden Characters: Forbidden Characters
Empty File Name: Empty File Name
External Player Settings:
External Player Settings: External Player Settings
External Player: External Player
@ -740,6 +754,8 @@ External link opening has been disabled in the general settings: 'External link
Downloading has completed: '"$" has finished downloading'
Starting download: 'Starting download of "$"'
Downloading failed: 'There was an issue downloading "$"'
Screenshot Success: Saved screenshot as "$"
Screenshot Error: Screenshot failed. $
Yes: Yes
No: No