Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5daf9bb
Feature: Screenshot copy to clipboard
Shadorc Apr 20, 2026
0e73673
Use dropdown instead of toggles
Shadorc Apr 20, 2026
fa29f33
Merge branch 'FreeTubeApp:development' into feat/screenshot-clipboard
Shadorc Apr 26, 2026
e2a2943
use the standard web navigator.clipboard API
Shadorc Apr 26, 2026
4e95ca8
Apply changes requested for select option
Shadorc Apr 26, 2026
c935190
Hide format and quality options when clipboard is selected
Shadorc Apr 26, 2026
96a82eb
fix screenshot mode conditions in player
Shadorc Apr 26, 2026
94ab5cc
Fix screenshot mode dropdown being hidden when USING_ELECTRON was false
Shadorc Apr 27, 2026
6b46704
Rename 'Do Not Ask Path' to 'Save Screenshot To'
Shadorc May 1, 2026
c68f79d
Rename screenshot mode enum names
Shadorc May 2, 2026
5d9026f
Remove unused var in loca text
Shadorc May 2, 2026
4d43270
Improve copyToClipboard error message with blobs
Shadorc May 2, 2026
458dc79
Remove punctuation for single sentence
Shadorc May 2, 2026
c477bdb
Add migration script
Shadorc May 2, 2026
b0d8a55
Remove unused import
Shadorc May 2, 2026
5194ca6
Fix wrong remove key in migration script
Shadorc May 2, 2026
20b030c
Apply code style format request
Shadorc May 5, 2026
f34964a
Only create one blob now that only one screenshot mode can be selected
Shadorc May 5, 2026
e419792
Move 'filename' and 'filenameWithExtension' inside non-clipboard scre…
Shadorc May 5, 2026
6fb5ba1
Move the screenshot mode dropdown right below the Enable Screenshot t…
Shadorc May 9, 2026
61182be
Hide screenshot save options when clipboard mode is selected
Shadorc May 9, 2026
1e3d5a7
clean-up code
Shadorc May 9, 2026
cfff7d5
clean-up code
Shadorc May 9, 2026
6e272c9
Improve mode select options consistency
Shadorc May 12, 2026
41d0673
Fix screenshot mode text being too long
Shadorc May 13, 2026
327f230
Carry over existing Ask Path translations
absidue May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/datastores/handlers/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ class Settings {
await db.settings.removeAsync({ _id: 'saveWatchedProgress' })
}

// In FreeTube 0.24.0, the "Screenshot Mode" setting only had two options that were represented as a toggle named "Ask path"
// This is a one time migration to preserve users' Screenshot prompt preference through this change.
const screenshotAskPath = await db.settings.findOneAsync({ _id: 'screenshotAskPath' })

if (screenshotAskPath) {
await this.upsert('screenshotMode', screenshotAskPath.value ? 'prompt_folder' : 'default_folder')
await db.settings.removeAsync({ _id: 'screenshotAskPath' })
}

return db.settings.findAsync({ _id: { $ne: 'bounds' } })
}

Expand Down
51 changes: 33 additions & 18 deletions src/renderer/components/PlayerSettings/PlayerSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@
</FtFlexBox>
<div v-if="enableScreenshot">
<FtFlexBox>
<FtSelect
:placeholder="t('Settings.Player Settings.Screenshot.Mode')"
:value="screenshotMode"
:select-names="screenshotModeNames"
:select-values="screenshotModeValues"
:icon="['fas', 'expand']"
@change="handleUpdateScreenshotMode"
/>
</FtFlexBox>
<FtFlexBox v-if="screenshotMode !== 'clipboard'">
<FtSelect
:placeholder="t('Settings.Player Settings.Screenshot.Format Label')"
:value="screenshotFormat"
Expand All @@ -195,15 +205,8 @@
@change="updateScreenshotQuality"
/>
</FtFlexBox>
<FtFlexBox v-if="USING_ELECTRON">
<FtToggleSwitch
:label="t('Settings.Player Settings.Screenshot.Ask Path')"
:default-value="screenshotAskPath"
@change="updateScreenshotAskPath"
/>
</FtFlexBox>
<FtFlexBox
v-if="USING_ELECTRON && !screenshotAskPath"
v-if="USING_ELECTRON && screenshotMode === 'default_folder'"
class="screenshotFolderContainer"
>
<p class="screenshotFolderLabel">
Expand All @@ -223,6 +226,7 @@
/>
</FtFlexBox>
<FtFlexBox
v-if="screenshotMode !== 'clipboard'"
class="screenshotFolderContainer"
>
<p class="screenshotFilenamePatternTitle">
Expand Down Expand Up @@ -598,24 +602,35 @@ async function handleUpdateScreenshotFormat(format) {
getScreenshotFilenameExample(screenshotFilenamePattern.value)
}

/** @type {import('vue').ComputedRef<number>} */
const screenshotQuality = computed(() => store.getters.getScreenshotQuality)
const screenshotModeNames = computed(() => [
t('Settings.Player Settings.Screenshot.Modes.Ask Path'),
...process.env.IS_ELECTRON ? [t('Settings.Player Settings.Screenshot.Modes.Save To Folder')] : [],
t('Settings.Player Settings.Screenshot.Modes.Clipboard'),
])
const screenshotModeValues = computed(() => [
'prompt_folder',
...process.env.IS_ELECTRON ? ['default_folder'] : [],
'clipboard'
])

/** @type {import('vue').ComputedRef<'prompt_folder' | 'default_folder' | 'clipboard'>} */
const screenshotMode = computed(() => store.getters.getScreenshotMode)

/**
* @param {number} value
* @param {'prompt_folder' | 'default_folder' | 'clipboard'} mode
*/
function updateScreenshotQuality(value) {
store.dispatch('updateScreenshotQuality', value)
async function handleUpdateScreenshotMode(mode) {
await store.dispatch('updateScreenshotMode', mode)
}

/** @type {import('vue').ComputedRef<boolean>} */
const screenshotAskPath = computed(() => store.getters.getScreenshotAskPath)
/** @type {import('vue').ComputedRef<number>} */
const screenshotQuality = computed(() => store.getters.getScreenshotQuality)

/**
* @param {boolean} value
* @param {number} value
*/
function updateScreenshotAskPath(value) {
store.dispatch('updateScreenshotAskPath', value)
function updateScreenshotQuality(value) {
store.dispatch('updateScreenshotQuality', value)
}

/** @type {import('vue').ComputedRef<string>} */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
throttle,
debounce,
removeFromArrayIfExists,
copyToClipboard,
} from '../../helpers/utils'
import { MANIFEST_TYPE_SABR } from '../../helpers/player/SabrManifestParser'
import { setupSabrScheme } from '../../helpers/player/SabrSchemePlugin'
Expand Down Expand Up @@ -340,6 +341,11 @@ export default defineComponent({
return store.getters.getEnableScreenshot
})

/** @type {import('vue').ComputedRef<string>} */
const screenshotMode = computed(() => {
return store.getters.getScreenshotMode
})

/** @type {import('vue').ComputedRef<string>} */
const screenshotFormat = computed(() => {
return store.getters.getScreenshotFormat
Expand All @@ -350,11 +356,6 @@ export default defineComponent({
return store.getters.getScreenshotQuality
})

/** @type {import('vue').ComputedRef<boolean>} */
const screenshotAskPath = computed(() => {
return store.getters.getScreenshotAskPath
})

/** @type {import('vue').ComputedRef<boolean>} */
const videoVolumeMouseScroll = computed(() => {
return store.getters.getVideoVolumeMouseScroll
Expand Down Expand Up @@ -1703,55 +1704,60 @@ export default defineComponent({
canvas.height = height
canvas.getContext('2d').drawImage(video_, 0, 0)

const format = screenshotFormat.value
// Navigator Clipboard API only supports PNG
const format = screenshotMode.value === 'clipboard' ? 'png' : screenshotFormat.value
const mimeType = `image/${format === 'jpg' ? 'jpeg' : format}`
// imageQuality is ignored for pngs, so it is still okay to pass the quality value
const imageQuality = screenshotQuality.value / 100

let filename
try {
filename = await store.dispatch('parseScreenshotCustomFileName', {
date: new Date(),
playerTime: video_.currentTime,
videoId: props.videoId
})
} catch (err) {
console.error(`Parse failed: ${err.message}`)
showToast(t('Screenshot Error', { error: err.message }))
canvas.remove()
return
}

const filenameWithExtension = `${filename}.${format}`

const wasPlaying = !video_.paused
if ((!process.env.IS_ELECTRON || screenshotAskPath.value) && wasPlaying) {
if ((!process.env.IS_ELECTRON || screenshotMode.value === 'prompt_folder') && wasPlaying) {
video_.pause()
}

try {
/** @type {Blob} */
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mimeType, imageQuality))

if (!process.env.IS_ELECTRON || screenshotAskPath.value) {
const saved = await writeFileWithPicker(
filenameWithExtension,
blob,
format.toUpperCase(),
mimeType,
`.${format}`,
'player-screenshots',
'pictures'
)

if (saved) {
showToast(t('Screenshot Success'))
if (screenshotMode.value === 'clipboard') {
await copyToClipboard(blob, { messageOnSuccess: t('Screenshot Clipboard Success'), messageOnError: t('Screenshot Clipboard Error') })
} else if (screenshotMode.value === 'prompt_folder' || screenshotMode.value === 'default_folder') {
let filename
try {
filename = await store.dispatch('parseScreenshotCustomFileName', {
date: new Date(),
playerTime: video_.currentTime,
videoId: props.videoId
})
} catch (err) {
console.error(`Parse failed: ${err.message}`)
showToast(t('Screenshot Error', { error: err.message }))
canvas.remove()
return
}
} else {
const arrayBuffer = await blob.arrayBuffer()

if (await window.ftElectron.writeToDefaultFolder(filenameWithExtension, arrayBuffer)) {
showToast(t('Screenshot Success'))
const filenameWithExtension = `${filename}.${format}`

if (!process.env.IS_ELECTRON || screenshotMode.value === 'prompt_folder') {
const saved = await writeFileWithPicker(
filenameWithExtension,
blob,
format.toUpperCase(),
mimeType,
`.${format}`,
'player-screenshots',
'pictures'
)

if (saved) {
showToast(t('Screenshot Success'))
}
} else {
const arrayBuffer = await blob.arrayBuffer()

if (await window.ftElectron.writeToDefaultFolder(filenameWithExtension, arrayBuffer)) {
showToast(t('Screenshot Success'))
}
}
}
} catch (error) {
Expand All @@ -1760,7 +1766,7 @@ export default defineComponent({
} finally {
canvas.remove()

if ((!process.env.IS_ELECTRON || screenshotAskPath.value) && wasPlaying) {
if ((!process.env.IS_ELECTRON || screenshotMode.value === 'prompt_folder') && wasPlaying) {
video_.play()
}
}
Expand Down
19 changes: 16 additions & 3 deletions src/renderer/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,20 +190,33 @@ export function showToast(message, time = null, action = null, abortSignal = nul
* This writes to the clipboard. If an error occurs during the copy,
* a toast with the error is shown. If the copy is successful and
* there is a success message, a toast with that message is shown.
* @param {string} content the content to be copied to the clipboard
* @param {string|Blob} content the content to be copied to the clipboard (text or image Blob)
* @param {object} [options] - Optional settings for the copy operation.
* @param {null|string} options.messageOnSuccess the message to be displayed as a toast when the copy succeeds (optional)
* @param {null|string} options.messageOnError the message to be displayed as a toast when the copy fails (optional)
*/
export async function copyToClipboard(content, { messageOnSuccess = null, messageOnError = null } = {}) {
if (navigator.clipboard !== undefined && window.isSecureContext) {
try {
await navigator.clipboard.writeText(content)
if (content instanceof Blob) {
await navigator.clipboard.write([
new ClipboardItem({
[content.type]: content
})
])
} else {
await navigator.clipboard.writeText(content)
}

if (messageOnSuccess !== null) {
showToast(messageOnSuccess)
}
} catch (error) {
console.error(`Failed to copy ${content} to clipboard`, error)
if (content instanceof Blob) {
console.error(`Failed to data of type "${content.type}" to clipboard`, error)
} else {
console.error(`Failed to copy ${content} to clipboard`, error)
}
if (messageOnError !== null) {
showToast(`${messageOnError}: ${error}`, 5000)
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/store/modules/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,9 @@ const state = {
videoSkipMouseScroll: false,
videoPlaybackRateInterval: 0.25,
enableScreenshot: false,
screenshotMode: 'prompt_folder',
screenshotFormat: 'png',
screenshotQuality: 95,
screenshotAskPath: !process.env.IS_ELECTRON,
screenshotFolderPath: '',
screenshotFilenamePattern: '%Y%M%D-%H%N%S',
settingsSectionSortEnabled: false,
Expand Down
3 changes: 2 additions & 1 deletion static/locales/af.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ Settings:
Enable: 'Aktiveer skermkiekie'
Format Label: 'Skermkiekieformaat'
Quality Label: 'Skermkiekiekwaliteit'
Ask Path: 'Vra watter vouer om in te bewaar'
Modes:
Ask Path: 'Vra watter vouer om in te bewaar'
Folder Label: 'Skermkiekievouer'
Folder Button: 'Kies vouer'
File Name Label: 'Lêernaampatroon'
Expand Down
3 changes: 2 additions & 1 deletion static/locales/ar.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,8 @@ Settings:
Empty File Name: اسم المِلَفّ فارغ
Folder Button: اختر مجلد
File Name Label: نمط اسم المِلَفّ
Ask Path: اسأل عن موقع الحفظ
Modes:
Ask Path: اسأل عن موقع الحفظ
Folder Label: موقع لقطة الشاشة
Format Label: صيغة لقطة الشاشة
Quality Label: جودة لقطة الشاشة
Expand Down
3 changes: 2 additions & 1 deletion static/locales/awa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,8 @@ Settings:
Enable: ''
Format Label: ''
Quality Label: ''
Ask Path: ''
Modes:
Ask Path: ''
Folder Label: ''
Folder Button: ''
File Name Label: ''
Expand Down
3 changes: 2 additions & 1 deletion static/locales/be.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@ Settings:
Enable: 'Уключыць здымак экрана'
Format Label: 'Фармат здымка'
Quality Label: 'Якасць здымка'
Ask Path: 'Пытаць папку для захоўвання'
Modes:
Ask Path: 'Пытаць папку для захоўвання'
Folder Label: 'Папка для здымкаў'
Folder Button: 'Выбраць папку'
File Name Label: 'Шаблон назвы файла'
Expand Down
3 changes: 2 additions & 1 deletion static/locales/bg.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,8 @@ Settings:
Screenshot:
Enable: Включване на снимки на екрана
Quality Label: Качество
Ask Path: Питане за папка за запазване
Modes:
Ask Path: Питане за папка за запазване
Folder Label: Папка
File Name Label: Модел за име на файл
Error:
Expand Down
3 changes: 2 additions & 1 deletion static/locales/br.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,8 @@ Settings:
Enable: 'Gweredekaat Tapadennoù-skramm'
Format Label: 'Furmad an tapadennoù-skramm'
Quality Label: 'Kalite an tapadennoù-skramm'
Ask Path: 'Goulenn war-lerc''h an teuliad enrollañ'
Modes:
Ask Path: 'Goulenn war-lerc''h an teuliad enrollañ'
Folder Label: 'Teuliad an tapadennoù-skramm'
Folder Button: 'Diuzañ un teuliad'
File Name Label: 'Patrom anv restr'
Expand Down
3 changes: 2 additions & 1 deletion static/locales/ckb.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,8 @@ Settings:
Enable: ''
Format Label: ''
Quality Label: ''
Ask Path: 'بۆ بوخچەی پاشەکەوت بپرسە'
Modes:
Ask Path: 'بۆ بوخچەی پاشەکەوت بپرسە'
Folder Label: ''
Folder Button: 'دیاریکردنی بوخچە'
File Name Label: ''
Expand Down
3 changes: 2 additions & 1 deletion static/locales/cs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,8 @@ Settings:
Empty File Name: Prázdný název souboru
File Name Label: Vzor názvu souboru
File Name Tooltip: Můžete použít proměnné níže. %Y Rok 4 číslice. %M Měsíc 2 číslice. %D Den 2 číslice. %H Hodina 2 číslice. %N Minuta 2 číslice. %S Sekunda 2 číslice. %T Milisekunda 3 číslice. %s Sekunda videa. %t Milisekunda videa 3 číslice. %i ID videa.
Ask Path: Zeptat se na složku pro uložení
Modes:
Ask Path: Zeptat se na složku pro uložení
Folder Label: Složka snímků obrazovky
Enable: Povolit snímek obrazovky
Format Label: Formát snímku obrazovky
Expand Down
3 changes: 2 additions & 1 deletion static/locales/cy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,8 @@ Settings:
Enable: 'Galluogi Lluniau Sgrin'
Format Label: 'Fformat Lluniau Sgrin'
Quality Label: 'Ansawdd Llun Sgrin'
Ask Path: 'Gofyn am Ffolder Cadw'
Modes:
Ask Path: 'Gofyn am Ffolder Cadw'
Folder Label: 'Ffolder Llun Sgrin'
Folder Button: 'Dewis Ffolder'
File Name Label: 'Patrwm Enw Ffeil'
Expand Down
3 changes: 2 additions & 1 deletion static/locales/da.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,8 @@ Settings:
Forbidden Characters: Forbudte tegn
Empty File Name: Tomt filnavn
File Name Tooltip: Du kan bruge variablerne nedenfor. %Y – år (4 cifre). %M – måned (2 cifre). %D – dag (2 cifre). %H – time (2 cifre). %N – minut (2 cifre). %S – sekund (2 cifre). %T – millisekund (3 cifre). %s – video-sekund. %t – video-millisekund (3 cifre). %i – video-ID.
Ask Path: Bed om lagringsmappe
Modes:
Ask Path: Bed om lagringsmappe
Folder Label: Skærmbilledmappe
Next Video Interval: Nedtællingstimer til auto-afspilning
Max Video Playback Rate: Maks. videoafspilningshastighed
Expand Down
3 changes: 2 additions & 1 deletion static/locales/de-DE.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,8 @@ Settings:
Enable: Screenshots aktivieren
Format Label: Format von Screenshots
Quality Label: Qualität von Screenshots
Ask Path: Nach dem Ordner zum Speichern fragen
Modes:
Ask Path: Nach dem Ordner zum Speichern fragen
Folder Label: Ordner für Screenshots
Folder Button: Ordner auswählen
File Name Label: Dateinamen-Muster
Expand Down
Loading
Loading