diff --git a/src/constants.js b/src/constants.js index 20aca01a..b5956cc9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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', diff --git a/src/main/index.js b/src/main/index.js index 7b0f638d..900ea269 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -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) }) diff --git a/src/renderer/assets/img/camera.svg b/src/renderer/assets/img/camera.svg new file mode 100644 index 00000000..13a3c85c --- /dev/null +++ b/src/renderer/assets/img/camera.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index 96c78d76..60531c5d 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -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 diff --git a/src/renderer/components/ft-slider/ft-slider.css b/src/renderer/components/ft-slider/ft-slider.css index 5405aa62..bc91c097 100644 --- a/src/renderer/components/ft-slider/ft-slider.css +++ b/src/renderer/components/ft-slider/ft-slider.css @@ -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 */ diff --git a/src/renderer/components/ft-slider/ft-slider.js b/src/renderer/components/ft-slider/ft-slider.js index b95e5cd3..fb0b56ee 100644 --- a/src/renderer/components/ft-slider/ft-slider.js +++ b/src/renderer/components/ft-slider/ft-slider.js @@ -26,6 +26,10 @@ export default Vue.extend({ valueExtension: { type: String, default: null + }, + disabled: { + type: Boolean, + default: false } }, data: function () { diff --git a/src/renderer/components/ft-slider/ft-slider.vue b/src/renderer/components/ft-slider/ft-slider.vue index 477e1299..c163f54b 100644 --- a/src/renderer/components/ft-slider/ft-slider.vue +++ b/src/renderer/components/ft-slider/ft-slider.vue @@ -5,11 +5,12 @@ {{ label }}: diff --git a/src/renderer/components/ft-video-player/ft-video-player.js b/src/renderer/components/ft-video-player/ft-video-player.js index 5dc556fe..fcdab490 100644 --- a/src/renderer/components/ft-video-player/ft-video-player.js +++ b/src/renderer/components/ft-video-player/ft-video-player.js @@ -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('
') + .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' ]) } }) diff --git a/src/renderer/components/ft-video-player/ft-video-player.vue b/src/renderer/components/ft-video-player/ft-video-player.vue index 60c63a88..ab13aad4 100644 --- a/src/renderer/components/ft-video-player/ft-video-player.vue +++ b/src/renderer/components/ft-video-player/ft-video-player.vue @@ -7,6 +7,7 @@ controls preload="auto" :data-setup="JSON.stringify(dataSetup)" + crossorigin="anonymous" @touchstart="handleTouchStart" @touchend="handleTouchEnd" > diff --git a/src/renderer/components/player-settings/player-settings.js b/src/renderer/components/player-settings/player-settings.js index 1f0666f8..e662961e 100644 --- a/src/renderer/components/player-settings/player-settings.js +++ b/src/renderer/components/player-settings/player-settings.js @@ -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' ]) } }) diff --git a/src/renderer/components/player-settings/player-settings.sass b/src/renderer/components/player-settings/player-settings.sass index 05cb0dfb..609b568d 100644 --- a/src/renderer/components/player-settings/player-settings.sass +++ b/src/renderer/components/player-settings/player-settings.sass @@ -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 diff --git a/src/renderer/components/player-settings/player-settings.vue b/src/renderer/components/player-settings/player-settings.vue index ab1577cc..aba0f416 100644 --- a/src/renderer/components/player-settings/player-settings.vue +++ b/src/renderer/components/player-settings/player-settings.vue @@ -149,6 +149,88 @@ @change="updateDefaultQuality" /> +
+ + + +
+ + + + + + + + +

+ {{ $t('Settings.Player Settings.Screenshot.Folder Label') }} +

+ + +
+ +

+ {{ $t('Settings.Player Settings.Screenshot.File Name Label') }} + +

+ + +
+
diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index 7a8db63e..693813d1 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -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 = { diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js index 40a21aa2..a0884a85 100644 --- a/src/renderer/store/modules/utils.js +++ b/src/renderer/store/modules/utils.js @@ -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) }, diff --git a/src/renderer/videoJS.css b/src/renderer/videoJS.css index 9b1f59ad..8a3f4831 100644 --- a/src/renderer/videoJS.css +++ b/src/renderer/videoJS.css @@ -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 diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index c9abfa6d..1e29973a 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -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