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:
parent
80435960bf
commit
ddce28e586
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
@ -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 |
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -26,6 +26,10 @@ export default Vue.extend({
|
|||
valueExtension: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
|
|
|
@ -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 }}:
|
||||
|
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
controls
|
||||
preload="auto"
|
||||
:data-setup="JSON.stringify(dataSetup)"
|
||||
crossorigin="anonymous"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
|
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue