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_SYSTEM_LOCALE: 'get-system-locale',
|
||||||
GET_USER_DATA_PATH: 'get-user-data-path',
|
GET_USER_DATA_PATH: 'get-user-data-path',
|
||||||
GET_USER_DATA_PATH_SYNC: 'get-user-data-path-sync',
|
GET_USER_DATA_PATH_SYNC: 'get-user-data-path-sync',
|
||||||
|
GET_PICTURES_PATH: 'get-pictures-path',
|
||||||
SHOW_OPEN_DIALOG: 'show-open-dialog',
|
SHOW_OPEN_DIALOG: 'show-open-dialog',
|
||||||
SHOW_SAVE_DIALOG: 'show-save-dialog',
|
SHOW_SAVE_DIALOG: 'show-save-dialog',
|
||||||
STOP_POWER_SAVE_BLOCKER: 'stop-power-save-blocker',
|
STOP_POWER_SAVE_BLOCKER: 'stop-power-save-blocker',
|
||||||
|
|
|
@ -405,11 +405,23 @@ function runApp() {
|
||||||
event.returnValue = app.getPath('userData')
|
event.returnValue = app.getPath('userData')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.GET_PICTURES_PATH, () => {
|
||||||
|
return app.getPath('pictures')
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.SHOW_OPEN_DIALOG, async (_, options) => {
|
ipcMain.handle(IpcChannels.SHOW_OPEN_DIALOG, async (_, options) => {
|
||||||
return await dialog.showOpenDialog(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)
|
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 === '') {
|
if (response.canceled || response.filePath === '') {
|
||||||
// User canceled the save dialog
|
// User canceled the save dialog
|
||||||
return
|
return
|
||||||
|
@ -766,7 +766,7 @@ export default Vue.extend({
|
||||||
return object
|
return object
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await this.showSaveDialog(options)
|
const response = await this.showSaveDialog({ options })
|
||||||
if (response.canceled || response.filePath === '') {
|
if (response.canceled || response.filePath === '') {
|
||||||
// User canceled the save dialog
|
// User canceled the save dialog
|
||||||
return
|
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 === '') {
|
if (response.canceled || response.filePath === '') {
|
||||||
// User canceled the save dialog
|
// User canceled the save dialog
|
||||||
return
|
return
|
||||||
|
@ -864,7 +864,7 @@ export default Vue.extend({
|
||||||
exportText += `${channel.id},${channelUrl},${channelName}\n`
|
exportText += `${channel.id},${channelUrl},${channelName}\n`
|
||||||
})
|
})
|
||||||
exportText += '\n'
|
exportText += '\n'
|
||||||
const response = await this.showSaveDialog(options)
|
const response = await this.showSaveDialog({ options })
|
||||||
if (response.canceled || response.filePath === '') {
|
if (response.canceled || response.filePath === '') {
|
||||||
// User canceled the save dialog
|
// User canceled the save dialog
|
||||||
return
|
return
|
||||||
|
@ -917,7 +917,7 @@ export default Vue.extend({
|
||||||
newPipeObject.subscriptions.push(subscription)
|
newPipeObject.subscriptions.push(subscription)
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await this.showSaveDialog(options)
|
const response = await this.showSaveDialog({ options })
|
||||||
if (response.canceled || response.filePath === '') {
|
if (response.canceled || response.filePath === '') {
|
||||||
// User canceled the save dialog
|
// User canceled the save dialog
|
||||||
return
|
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 === '') {
|
if (response.canceled || response.filePath === '') {
|
||||||
// User canceled the save dialog
|
// User canceled the save dialog
|
||||||
return
|
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 === '') {
|
if (response.canceled || response.filePath === '') {
|
||||||
// User canceled the save dialog
|
// User canceled the save dialog
|
||||||
return
|
return
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pure-material-slider > input:disabled + span {
|
.pure-material-slider > input:disabled + span {
|
||||||
color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
opacity: 0.38;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Webkit | Track */
|
/* Webkit | Track */
|
||||||
|
|
|
@ -26,6 +26,10 @@ export default Vue.extend({
|
||||||
valueExtension: {
|
valueExtension: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data: function () {
|
data: function () {
|
||||||
|
|
|
@ -5,11 +5,12 @@
|
||||||
<input
|
<input
|
||||||
:id="id"
|
:id="id"
|
||||||
v-model.number="currentValue"
|
v-model.number="currentValue"
|
||||||
|
:disabled="disabled"
|
||||||
type="range"
|
type="range"
|
||||||
:min="minValue"
|
:min="minValue"
|
||||||
:max="maxValue"
|
:max="maxValue"
|
||||||
:step="step"
|
:step="step"
|
||||||
@change="$emit('change', $event.target.value)"
|
@change="$emit('change', currentValue)"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{{ label }}:
|
{{ label }}:
|
||||||
|
|
|
@ -6,6 +6,7 @@ import $ from 'jquery'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import qualitySelector from '@silvermine/videojs-quality-selector'
|
import qualitySelector from '@silvermine/videojs-quality-selector'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
import 'videojs-overlay/dist/videojs-overlay'
|
import 'videojs-overlay/dist/videojs-overlay'
|
||||||
import 'videojs-overlay/dist/videojs-overlay.css'
|
import 'videojs-overlay/dist/videojs-overlay.css'
|
||||||
import 'videojs-vtt-thumbnails-freetube'
|
import 'videojs-vtt-thumbnails-freetube'
|
||||||
|
@ -115,6 +116,7 @@ export default Vue.extend({
|
||||||
'seekToLive',
|
'seekToLive',
|
||||||
'remainingTimeDisplay',
|
'remainingTimeDisplay',
|
||||||
'customControlSpacer',
|
'customControlSpacer',
|
||||||
|
'screenshotButton',
|
||||||
'playbackRateMenuButton',
|
'playbackRateMenuButton',
|
||||||
'loopButton',
|
'loopButton',
|
||||||
'chaptersButton',
|
'chaptersButton',
|
||||||
|
@ -263,11 +265,35 @@ export default Vue.extend({
|
||||||
}
|
}
|
||||||
|
|
||||||
return playbackRates
|
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: {
|
watch: {
|
||||||
showStatsModal: function() {
|
showStatsModal: function() {
|
||||||
this.player.trigger(this.statsModalEventName)
|
this.player.trigger(this.statsModalEventName)
|
||||||
|
},
|
||||||
|
|
||||||
|
enableScreenshot: function() {
|
||||||
|
this.toggleScreenshotButton()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted: function () {
|
mounted: function () {
|
||||||
|
@ -284,6 +310,7 @@ export default Vue.extend({
|
||||||
this.createFullWindowButton()
|
this.createFullWindowButton()
|
||||||
this.createLoopButton()
|
this.createLoopButton()
|
||||||
this.createToggleTheatreModeButton()
|
this.createToggleTheatreModeButton()
|
||||||
|
this.createScreenshotButton()
|
||||||
this.determineFormatType()
|
this.determineFormatType()
|
||||||
this.determineMaxFramerate()
|
this.determineMaxFramerate()
|
||||||
|
|
||||||
|
@ -437,6 +464,7 @@ export default Vue.extend({
|
||||||
if (this.captionHybridList.length !== 0) {
|
if (this.captionHybridList.length !== 0) {
|
||||||
this.transformAndInsertCaptions()
|
this.transformAndInsertCaptions()
|
||||||
}
|
}
|
||||||
|
this.toggleScreenshotButton()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.player.on('ended', () => {
|
this.player.on('ended', () => {
|
||||||
|
@ -1224,6 +1252,168 @@ export default Vue.extend({
|
||||||
this.$parent.toggleTheatreMode()
|
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) {
|
createDashQualitySelector: function (levels) {
|
||||||
if (levels.levels_.length === 0) {
|
if (levels.levels_.length === 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -1751,6 +1941,11 @@ export default Vue.extend({
|
||||||
// Toggle Theatre Mode
|
// Toggle Theatre Mode
|
||||||
this.toggleTheatreMode()
|
this.toggleTheatreMode()
|
||||||
break
|
break
|
||||||
|
case 85:
|
||||||
|
// U Key
|
||||||
|
// Take screenshot
|
||||||
|
this.takeScreenshot()
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1759,7 +1954,11 @@ export default Vue.extend({
|
||||||
'calculateColorLuminance',
|
'calculateColorLuminance',
|
||||||
'updateDefaultCaptionSettings',
|
'updateDefaultCaptionSettings',
|
||||||
'showToast',
|
'showToast',
|
||||||
'sponsorBlockSkipSegments'
|
'sponsorBlockSkipSegments',
|
||||||
|
'parseScreenshotCustomFileName',
|
||||||
|
'updateScreenshotFolderPath',
|
||||||
|
'getPicturesPath',
|
||||||
|
'showSaveDialog'
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
controls
|
controls
|
||||||
preload="auto"
|
preload="auto"
|
||||||
:data-setup="JSON.stringify(dataSetup)"
|
:data-setup="JSON.stringify(dataSetup)"
|
||||||
|
crossorigin="anonymous"
|
||||||
@touchstart="handleTouchStart"
|
@touchstart="handleTouchStart"
|
||||||
@touchend="handleTouchEnd"
|
@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 FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
|
||||||
import FtSlider from '../ft-slider/ft-slider.vue'
|
import FtSlider from '../ft-slider/ft-slider.vue'
|
||||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.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({
|
export default Vue.extend({
|
||||||
name: 'PlayerSettings',
|
name: 'PlayerSettings',
|
||||||
|
@ -13,7 +19,10 @@ export default Vue.extend({
|
||||||
'ft-select': FtSelect,
|
'ft-select': FtSelect,
|
||||||
'ft-toggle-switch': FtToggleSwitch,
|
'ft-toggle-switch': FtToggleSwitch,
|
||||||
'ft-slider': FtSlider,
|
'ft-slider': FtSlider,
|
||||||
'ft-flex-box': FtFlexBox
|
'ft-flex-box': FtFlexBox,
|
||||||
|
'ft-button': FtButton,
|
||||||
|
'ft-input': FtInput,
|
||||||
|
'ft-tooltip': FtTooltip
|
||||||
},
|
},
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
|
@ -36,7 +45,18 @@ export default Vue.extend({
|
||||||
0.25,
|
0.25,
|
||||||
0.5,
|
0.5,
|
||||||
1
|
1
|
||||||
]
|
],
|
||||||
|
screenshotFormatNames: [
|
||||||
|
'PNG',
|
||||||
|
'JPEG'
|
||||||
|
],
|
||||||
|
screenshotFormatValues: [
|
||||||
|
'png',
|
||||||
|
'jpg'
|
||||||
|
],
|
||||||
|
screenshotFolderPlaceholder: '',
|
||||||
|
screenshotFilenameExample: '',
|
||||||
|
screenshotDefaultPattern: '%Y%M%D-%H%N%S'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -138,9 +158,101 @@ export default Vue.extend({
|
||||||
this.$t('Settings.Player Settings.Default Quality.720p'),
|
this.$t('Settings.Player Settings.Default Quality.720p'),
|
||||||
this.$t('Settings.Player Settings.Default Quality.1080p')
|
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: {
|
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([
|
...mapActions([
|
||||||
'updateAutoplayVideos',
|
'updateAutoplayVideos',
|
||||||
'updateAutoplayPlaylists',
|
'updateAutoplayPlaylists',
|
||||||
|
@ -159,7 +271,15 @@ export default Vue.extend({
|
||||||
'updateVideoPlaybackRateMouseScroll',
|
'updateVideoPlaybackRateMouseScroll',
|
||||||
'updateDisplayVideoPlayButton',
|
'updateDisplayVideoPlayButton',
|
||||||
'updateMaxVideoPlaybackRate',
|
'updateMaxVideoPlaybackRate',
|
||||||
'updateVideoPlaybackRateInterval'
|
'updateVideoPlaybackRateInterval',
|
||||||
|
'updateEnableScreenshot',
|
||||||
|
'updateScreenshotFormat',
|
||||||
|
'updateScreenshotQuality',
|
||||||
|
'updateScreenshotAskPath',
|
||||||
|
'updateScreenshotFolderPath',
|
||||||
|
'updateScreenshotFilenamePattern',
|
||||||
|
'parseScreenshotCustomFileName',
|
||||||
|
'getPicturesPath'
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1 +1,14 @@
|
||||||
@use "../../sass-partials/settings"
|
@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"
|
@change="updateDefaultQuality"
|
||||||
/>
|
/>
|
||||||
</ft-flex-box>
|
</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>
|
</details>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -254,7 +254,13 @@ const state = {
|
||||||
videoVolumeMouseScroll: false,
|
videoVolumeMouseScroll: false,
|
||||||
videoPlaybackRateMouseScroll: false,
|
videoPlaybackRateMouseScroll: false,
|
||||||
videoPlaybackRateInterval: 0.25,
|
videoPlaybackRateInterval: 0.25,
|
||||||
downloadFolderPath: ''
|
downloadFolderPath: '',
|
||||||
|
enableScreenshot: false,
|
||||||
|
screenshotFormat: 'png',
|
||||||
|
screenshotQuality: 95,
|
||||||
|
screenshotAskPath: false,
|
||||||
|
screenshotFolderPath: '',
|
||||||
|
screenshotFilenamePattern: '%Y%M%D-%H%N%S'
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateWithSideEffects = {
|
const stateWithSideEffects = {
|
||||||
|
|
|
@ -200,11 +200,12 @@ const actions = {
|
||||||
defaultPath: fileName,
|
defaultPath: fileName,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
|
name: extension.toUpperCase(),
|
||||||
extensions: [extension]
|
extensions: [extension]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
const response = await dispatch('showSaveDialog', options)
|
const response = await dispatch('showSaveDialog', { options })
|
||||||
|
|
||||||
if (response.canceled || response.filePath === '') {
|
if (response.canceled || response.filePath === '') {
|
||||||
// User canceled the save dialog
|
// User canceled the save dialog
|
||||||
|
@ -283,10 +284,10 @@ const actions = {
|
||||||
return await invokeIRC(context, IpcChannels.SHOW_OPEN_DIALOG, webCbk, options)
|
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
|
// TODO: implement showSaveDialog web compatible callback
|
||||||
const webCbk = () => null
|
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) {
|
async getUserDataPath (context) {
|
||||||
|
@ -295,6 +296,66 @@ const actions = {
|
||||||
return await invokeIRC(context, IpcChannels.GET_USER_DATA_PATH, webCbk)
|
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) {
|
updateShowProgressBar ({ commit }, value) {
|
||||||
commit('setShowProgressBar', value)
|
commit('setShowProgressBar', value)
|
||||||
},
|
},
|
||||||
|
|
|
@ -490,6 +490,16 @@ body.vjs-full-window {
|
||||||
content: url(assets/img/close_theatre.svg)
|
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) {
|
@media only screen and (max-width: 1350px) {
|
||||||
.videoPlayer .vjs-button-theatre {
|
.videoPlayer .vjs-button-theatre {
|
||||||
display: none
|
display: none
|
||||||
|
|
|
@ -232,6 +232,20 @@ Settings:
|
||||||
1440p: 1440p
|
1440p: 1440p
|
||||||
4k: 4k
|
4k: 4k
|
||||||
8k: 8k
|
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 Settings
|
External Player Settings: External Player Settings
|
||||||
External Player: External Player
|
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'
|
Downloading has completed: '"$" has finished downloading'
|
||||||
Starting download: 'Starting download of "$"'
|
Starting download: 'Starting download of "$"'
|
||||||
Downloading failed: 'There was an issue downloading "$"'
|
Downloading failed: 'There was an issue downloading "$"'
|
||||||
|
Screenshot Success: Saved screenshot as "$"
|
||||||
|
Screenshot Error: Screenshot failed. $
|
||||||
|
|
||||||
Yes: Yes
|
Yes: Yes
|
||||||
No: No
|
No: No
|
||||||
|
|
Loading…
Reference in New Issue