From 248581d6b7cff1fec31bb349457c8e9164d87bea Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Fri, 27 Dec 2024 20:49:44 +0100 Subject: [PATCH 1/4] Switch back to the WEB client for the local API with potokens (#6436) --- _scripts/dev-runner.js | 15 ++- _scripts/injectAllowedPaths.mjs | 2 + _scripts/webpack.botGuardScript.config.js | 23 ++++ package.json | 4 +- src/botGuardScript.js | 37 ++++++ src/constants.js | 4 +- src/main/index.js | 26 ++-- src/main/poTokenGenerator.js | 140 ++++++++++++++++++++++ src/renderer/helpers/api/local.js | 112 +++++++---------- src/renderer/views/Watch/Watch.js | 27 ++--- yarn.lock | 5 + 11 files changed, 283 insertions(+), 112 deletions(-) create mode 100644 _scripts/webpack.botGuardScript.config.js create mode 100644 src/botGuardScript.js create mode 100644 src/main/poTokenGenerator.js diff --git a/_scripts/dev-runner.js b/_scripts/dev-runner.js index fa5d16db56c0d..15d9b356b842e 100644 --- a/_scripts/dev-runner.js +++ b/_scripts/dev-runner.js @@ -18,12 +18,14 @@ const web = process.argv.indexOf('--web') !== -1 let mainConfig let rendererConfig +let botGuardScriptConfig let webConfig let SHAKA_LOCALES_TO_BE_BUNDLED if (!web) { mainConfig = require('./webpack.main.config') rendererConfig = require('./webpack.renderer.config') + botGuardScriptConfig = require('./webpack.botGuardScript.config') SHAKA_LOCALES_TO_BE_BUNDLED = rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED delete rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED @@ -98,6 +100,14 @@ function setupNotifyLocaleUpdate(compiler, devServer) { }) } +function startBotGuardScript() { + webpack(botGuardScriptConfig, (err) => { + if (err) console.error(err) + + console.log(`\nCompiled ${botGuardScriptConfig.name} script!`) + }) +} + function startMain() { const compiler = webpack(mainConfig) const { name } = compiler @@ -196,7 +206,10 @@ function startWeb () { }) } if (!web) { - startRenderer(startMain) + startRenderer(() => { + startBotGuardScript() + startMain() + }) } else { startWeb() } diff --git a/_scripts/injectAllowedPaths.mjs b/_scripts/injectAllowedPaths.mjs index 2310bf49839b1..e5c1ce2293232 100644 --- a/_scripts/injectAllowedPaths.mjs +++ b/_scripts/injectAllowedPaths.mjs @@ -21,6 +21,8 @@ const paths = readdirSync(distDirectory, { // disallow the renderer process/browser windows to read the main.js file dirent.name !== 'main.js' && dirent.name !== 'main.js.LICENSE.txt' && + // disallow the renderer process/browser windows to read the botGuardScript.js file + dirent.name !== 'botGuardScript.js' && // filter out any web build files, in case the dist directory contains a web build !dirent.parentPath.startsWith(webDirectory) }) diff --git a/_scripts/webpack.botGuardScript.config.js b/_scripts/webpack.botGuardScript.config.js new file mode 100644 index 0000000000000..9b66ffc370f21 --- /dev/null +++ b/_scripts/webpack.botGuardScript.config.js @@ -0,0 +1,23 @@ +const path = require('path') + +/** @type {import('webpack').Configuration} */ +module.exports = { + name: 'botGuardScript', + // Always use production mode, as we use the output as a function body and the debug output doesn't work for that + mode: 'production', + devtool: false, + target: 'web', + entry: { + botGuardScript: path.join(__dirname, '../src/botGuardScript.js'), + }, + output: { + filename: '[name].js', + path: path.join(__dirname, '../dist'), + library: { + type: 'modern-module' + } + }, + experiments: { + outputModule: true + } +} diff --git a/package.json b/package.json index 1b5ba57d8176e..6b9313784d29d 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,11 @@ "lint-style": "stylelint \"**/*.{css,scss}\"", "lint-style-fix": "stylelint --fix \"**/*.{css,scss}\"", "lint-yml": "eslint --config eslint.config.mjs \"**/*.yml\" \"**/*.yaml\"", - "pack": "run-p pack:main pack:renderer && node _scripts/injectAllowedPaths.mjs", + "pack": "run-p pack:main pack:renderer pack:botGuardScript && node _scripts/injectAllowedPaths.mjs", "pack:main": "webpack --mode=production --node-env=production --config _scripts/webpack.main.config.js", "pack:renderer": "webpack --mode=production --node-env=production --config _scripts/webpack.renderer.config.js", "pack:web": "webpack --mode=production --node-env=production --config _scripts/webpack.web.config.js", + "pack:botGuardScript": "webpack --config _scripts/webpack.botGuardScript.config.js", "postinstall": "run-s --silent rebuild:electron patch-shaka", "prettier": "prettier --write \"{src,_scripts}/**/*.{js,vue}\"", "rebuild:electron": "electron-builder install-app-deps", @@ -61,6 +62,7 @@ "@fortawesome/vue-fontawesome": "^2.0.10", "@seald-io/nedb": "^4.0.4", "autolinker": "^4.0.1", + "bgutils-js": "^3.1.0", "electron-context-menu": "^4.0.4", "lodash.debounce": "^4.0.8", "marked": "^15.0.4", diff --git a/src/botGuardScript.js b/src/botGuardScript.js new file mode 100644 index 0000000000000..11da8855a6472 --- /dev/null +++ b/src/botGuardScript.js @@ -0,0 +1,37 @@ +import { BG } from 'bgutils-js' + +// This script has it's own webpack config, as it gets passed as a string to Electron's evaluateJavaScript function +// in src/main/poTokenGenerator.js +export default async function(visitorData) { + const requestKey = 'O43z0dpjhgX20SCx4KAo' + + const bgConfig = { + fetch: (input, init) => fetch(input, init), + requestKey, + globalObj: window, + identifier: visitorData + } + + const challenge = await BG.Challenge.create(bgConfig) + + if (!challenge) { + throw new Error('Could not get challenge') + } + + const interpreterJavascript = challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue + + if (interpreterJavascript) { + // eslint-disable-next-line no-new-func + new Function(interpreterJavascript)() + } else { + console.warn('Unable to load VM.') + } + + const poTokenResult = await BG.PoToken.generate({ + program: challenge.program, + globalName: challenge.globalName, + bgConfig + }) + + return poTokenResult.poToken +} diff --git a/src/constants.js b/src/constants.js index 97be9daf6fe9f..13294dddd90fa 100644 --- a/src/constants.js +++ b/src/constants.js @@ -40,7 +40,9 @@ const IpcChannels = { PLAYER_CACHE_GET: 'player-cache-get', PLAYER_CACHE_SET: 'player-cache-set', - SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization' + SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization', + + GENERATE_PO_TOKEN: 'generate-po-token', } const DBActions = { diff --git a/src/main/index.js b/src/main/index.js index 806a99f0feef8..739ff9ee78f47 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -22,6 +22,7 @@ import { brotliDecompress } from 'zlib' import contextMenu from 'electron-context-menu' import packageDetails from '../../package.json' +import { generatePoToken } from './poTokenGenerator' const brotliDecompressAsync = promisify(brotliDecompress) @@ -427,24 +428,9 @@ function runApp() { requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' - // Make iOS requests work and look more realistic - if (requestHeaders['x-youtube-client-name'] === '5') { - delete requestHeaders.Referer - delete requestHeaders.Origin - delete requestHeaders['Sec-Fetch-Site'] - delete requestHeaders['Sec-Fetch-Mode'] - delete requestHeaders['Sec-Fetch-Dest'] - delete requestHeaders['sec-ch-ua'] - delete requestHeaders['sec-ch-ua-mobile'] - delete requestHeaders['sec-ch-ua-platform'] - - requestHeaders['User-Agent'] = requestHeaders['x-user-agent'] - delete requestHeaders['x-user-agent'] - } else { - requestHeaders['Sec-Fetch-Site'] = 'same-origin' - requestHeaders['Sec-Fetch-Mode'] = 'same-origin' - requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' - } + requestHeaders['Sec-Fetch-Site'] = 'same-origin' + requestHeaders['Sec-Fetch-Mode'] = 'same-origin' + requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' } else if (urlObj.origin.endsWith('.googlevideo.com') && urlObj.pathname === '/videoplayback') { requestHeaders.Referer = 'https://www.youtube.com/' requestHeaders.Origin = 'https://www.youtube.com' @@ -884,6 +870,10 @@ function runApp() { }) }) + ipcMain.handle(IpcChannels.GENERATE_PO_TOKEN, (_, visitorData) => { + return generatePoToken(visitorData) + }) + ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => { session.defaultSession.setProxy({ proxyRules: url diff --git a/src/main/poTokenGenerator.js b/src/main/poTokenGenerator.js new file mode 100644 index 0000000000000..5871d7483975d --- /dev/null +++ b/src/main/poTokenGenerator.js @@ -0,0 +1,140 @@ +import { session, WebContentsView } from 'electron' +import { readFile } from 'fs/promises' +import { join } from 'path' + +/** + * Generates a poToken (proof of origin token) using `bgutils-js`. + * The script to generate it is `src/botGuardScript.js` + * + * This is intentionally split out into it's own thing, with it's own temporary in-memory session, + * as the BotGuard stuff accesses the global `document` and `window` objects and also requires making some requests. + * So we definitely don't want it running in the same places as the rest of the FreeTube code with the user data. + * @param {string} visitorData + * @returns {Promise} + */ +export async function generatePoToken(visitorData) { + const sessionUuid = crypto.randomUUID() + + const theSession = session.fromPartition(`potoken-${sessionUuid}`, { cache: false }) + + theSession.setPermissionCheckHandler(() => false) + // eslint-disable-next-line n/no-callback-literal + theSession.setPermissionRequestHandler((webContents, permission, callback) => callback(false)) + + theSession.setUserAgent( + theSession.getUserAgent() + .split(' ') + .filter(part => !part.includes('Electron')) + .join(' ') + ) + + const webContentsView = new WebContentsView({ + webPreferences: { + backgroundThrottling: false, + safeDialogs: true, + sandbox: true, + v8CacheOptions: 'none', + session: theSession, + offscreen: true + } + }) + + webContentsView.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + + webContentsView.webContents.setAudioMuted(true) + webContentsView.setBounds({ + x: 0, + y: 0, + width: 1920, + height: 1080 + }) + + webContentsView.webContents.debugger.attach() + + await webContentsView.webContents.loadURL('data:text/html,', { + baseURLForDataURL: 'https://www.youtube.com' + }) + + await webContentsView.webContents.debugger.sendCommand('Emulation.setUserAgentOverride', { + userAgent: theSession.getUserAgent(), + acceptLanguage: 'en-US', + platform: 'Win32', + userAgentMetadata: { + brands: [ + { + brand: 'Not/A)Brand', + version: '99' + }, + { + brand: 'Chromium', + version: process.versions.chrome.split('.')[0] + } + ], + fullVersionList: [ + { + brand: 'Not/A)Brand', + version: '99.0.0.0' + }, + { + brand: 'Chromium', + version: process.versions.chrome + } + ], + platform: 'Windows', + platformVersion: '10.0.0', + architecture: 'x86', + model: '', + mobile: false, + bitness: '64', + wow64: false + } + }) + + await webContentsView.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', { + width: 1920, + height: 1080, + deviceScaleFactor: 1, + mobile: false, + screenWidth: 1920, + screenHeight: 1080, + positionX: 0, + positionY: 0, + screenOrientation: { + type: 'landscapePrimary', + angle: 0 + } + }) + + const script = await getScript(visitorData) + + const response = await webContentsView.webContents.executeJavaScript(script) + + webContentsView.webContents.close({ waitForBeforeUnload: false }) + await theSession.closeAllConnections() + + return response +} + +let cachedScript + +/** + * @param {string} visitorData + */ +async function getScript(visitorData) { + if (!cachedScript) { + const pathToScript = process.env.NODE_ENV === 'development' + ? join(__dirname, '../../dist/botGuardScript.js') + /* eslint-disable-next-line n/no-path-concat */ + : `${__dirname}/botGuardScript.js` + + const content = await readFile(pathToScript, 'utf-8') + + const match = content.match(/export{(\w+) as default};/) + + const functionName = match[1] + + cachedScript = content.replace(match[0], `;${functionName}("FT_VISITOR_DATA")`) + } + + return cachedScript.replace('FT_VISITOR_DATA', visitorData) +} diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 6de470d3bd60e..eb952793e204f 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -1,6 +1,6 @@ -import { ClientType, Innertube, Misc, Parser, UniversalCache, Utils, YT, YTNodes } from 'youtubei.js' +import { Innertube, Misc, Mixins, Parser, UniversalCache, Utils, YT, YTNodes } from 'youtubei.js' import Autolinker from 'autolinker' -import { SEARCH_CHAR_LIMIT } from '../../../constants' +import { IpcChannels, SEARCH_CHAR_LIMIT } from '../../../constants' import { PlayerCache } from './PlayerCache' import { @@ -10,7 +10,6 @@ import { extractNumberFromString, getChannelPlaylistId, getRelativeTimeFromDate, - randomArrayItem, } from '../utils' const TRACKING_PARAM_NAMES = [ @@ -21,25 +20,6 @@ const TRACKING_PARAM_NAMES = [ 'utm_content', ] -const IOS_VERSIONS = [ - '17.5.1', - '17.5', - '17.4.1', - '17.4', - '17.3.1', - '17.3', -] - -const YOUTUBE_IOS_CLIENT_VERSIONS = [ - '19.29.1', - '19.28.1', - '19.26.5', - '19.25.4', - '19.25.3', - '19.24.3', - '19.24.2', -] - /** * Creates a lightweight Innertube instance, which is faster to create or * an instance that can decode the streaming URLs, which is slower to create @@ -77,32 +57,7 @@ async function createInnertube({ withPlayer = false, location = undefined, safet client_type: clientType, // use browser fetch - fetch: (input, init) => { - // Make iOS requests work and look more realistic - if (init?.headers instanceof Headers && init.headers.get('x-youtube-client-name') === '5') { - // Use a random iOS version and YouTube iOS client version to make the requests look less suspicious - const clientVersion = randomArrayItem(YOUTUBE_IOS_CLIENT_VERSIONS) - const iosVersion = randomArrayItem(IOS_VERSIONS) - - init.headers.set('x-youtube-client-version', clientVersion) - - // We can't set the user-agent here, but in the main process we take the x-user-agent and set it as the user-agent - init.headers.delete('user-agent') - init.headers.set('x-user-agent', `com.google.ios.youtube/${clientVersion} (iPhone16,2; CPU iOS ${iosVersion.replaceAll('.', '_')} like Mac OS X; en_US)`) - - const bodyJson = JSON.parse(init.body) - - const client = bodyJson.context.client - - client.clientVersion = clientVersion - client.deviceModel = 'iPhone16,2' // iPhone 15 Pro Max - client.osVersion = iosVersion - - init.body = JSON.stringify(bodyJson) - } - - return fetch(input, init) - }, + fetch: (input, init) => fetch(input, init), cache, generate_session_locally: !!generateSessionLocally }) @@ -242,46 +197,61 @@ export async function getLocalSearchContinuation(continuationData) { export async function getLocalVideoInfo(id) { const webInnertube = await createInnertube({ withPlayer: true, generateSessionLocally: false }) + let poToken + + if (process.env.IS_ELECTRON) { + const { ipcRenderer } = require('electron') + + try { + poToken = await ipcRenderer.invoke(IpcChannels.GENERATE_PO_TOKEN, webInnertube.session.context.client.visitorData) + + webInnertube.session.po_token = poToken + webInnertube.session.player.po_token = poToken + } catch (error) { + console.error('Local API, poToken generation failed', error) + throw error + } + } + const info = await webInnertube.getInfo(id) const hasTrailer = info.has_trailer const trailerIsAgeRestricted = info.getTrailerInfo() === null - if (hasTrailer) { - /** @type {import('youtubei.js').YTNodes.PlayerLegacyDesktopYpcTrailer} */ - const trailerScreen = info.playability_status.error_screen - id = trailerScreen.video_id - } - if ((info.playability_status.status === 'UNPLAYABLE' && (!hasTrailer || trailerIsAgeRestricted)) || info.playability_status.status === 'LOGIN_REQUIRED') { return info } - const iosInnertube = await createInnertube({ clientType: ClientType.IOS }) - - const iosInfo = await iosInnertube.getBasicInfo(id, 'iOS') - if (hasTrailer) { - info.playability_status = iosInfo.playability_status - info.streaming_data = iosInfo.streaming_data - info.basic_info.start_timestamp = iosInfo.basic_info.start_timestamp - info.basic_info.duration = iosInfo.basic_info.duration - info.captions = iosInfo.captions - info.storyboards = iosInfo.storyboards - } else if (iosInfo.streaming_data) { - info.streaming_data.adaptive_formats = iosInfo.streaming_data.adaptive_formats - info.streaming_data.hls_manifest_url = iosInfo.streaming_data.hls_manifest_url + /** @type {import('youtubei.js').YTNodes.PlayerLegacyDesktopYpcTrailer} */ + const trailerScreen = info.playability_status.error_screen - // Use the legacy formats from the original web response as the iOS client doesn't have any legacy formats + const trailerInfo = new Mixins.MediaInfo([{ data: trailerScreen.trailer.player_response }]) - for (const format of info.streaming_data.adaptive_formats) { - format.freeTubeUrl = format.url - } + info.playability_status = trailerInfo.playability_status + info.streaming_data = trailerInfo.streaming_data + info.basic_info.start_timestamp = trailerInfo.basic_info.start_timestamp + info.basic_info.duration = trailerInfo.basic_info.duration + info.captions = trailerInfo.captions + info.storyboards = trailerInfo.storyboards } if (info.streaming_data) { decipherFormats(info.streaming_data.formats, webInnertube.actions.session.player) + decipherFormats(info.streaming_data.adaptive_formats, webInnertube.actions.session.player) + + if (info.streaming_data.dash_manifest_url) { + let url = info.streaming_data.dash_manifest_url + + if (url.includes('?')) { + url += `&pot=${encodeURIComponent(poToken)}&mpd_version=7` + } else { + url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(poToken)}/mpd_version/7` + } + + info.streaming_data.dash_manifest_url = url + } } return info diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js index 91e7017ce42b0..0b43332b71716 100644 --- a/src/renderer/views/Watch/Watch.js +++ b/src/renderer/views/Watch/Watch.js @@ -566,26 +566,13 @@ export default defineComponent({ } if (useRemoteManifest) { - // The live DASH manifest is currently unusable it is not available on the iOS client - // but the web ones returns 403s after 1 minute of playback so we have to use the HLS one for now. - // Leaving the code here commented out in case we can use it again in the future - - // if (result.streaming_data.dash_manifest_url) { - // let src = result.streaming_data.dash_manifest_url - - // if (src.includes('?')) { - // src += '&mpd_version=7' - // } else { - // src += `${src.endsWith('/') ? '' : '/'}mpd_version/7` - // } - - // this.manifestSrc = src - // this.manifestMimeType = MANIFEST_TYPE_DASH - // } else { - - this.manifestSrc = result.streaming_data.hls_manifest_url - this.manifestMimeType = MANIFEST_TYPE_HLS - // } + if (result.streaming_data.dash_manifest_url) { + this.manifestSrc = result.streaming_data.dash_manifest_url + this.manifestMimeType = MANIFEST_TYPE_DASH + } else { + this.manifestSrc = result.streaming_data.hls_manifest_url + this.manifestMimeType = MANIFEST_TYPE_HLS + } } this.streamingDataExpiryDate = result.streaming_data.expires diff --git a/yarn.lock b/yarn.lock index 138658a58c345..99788e3f56289 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2521,6 +2521,11 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY= +bgutils-js@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/bgutils-js/-/bgutils-js-3.1.0.tgz#b06484e40a653fb42bd75c261f31e499b18e865a" + integrity sha512-2S80c/B4OQFubJLD5ddRRp74utrvjA70x9U0RsIVK7gJaDnaPrbw+bnXWxnEnc0euLznmO9jxOtTTC7FxGmv6w== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" From 7b312f7680f1c9d12de96810cd5b0f8e7d918d3a Mon Sep 17 00:00:00 2001 From: Nik Nikovsky Date: Fri, 27 Dec 2024 18:00:50 +0000 Subject: [PATCH 2/4] Translated using Weblate (Polish) Currently translated at 100.0% (887 of 887 strings) Translation: FreeTube/Translations Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/pl/ --- static/locales/pl.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/locales/pl.yaml b/static/locales/pl.yaml index 0169938cc9843..60fa8f9881215 100644 --- a/static/locales/pl.yaml +++ b/static/locales/pl.yaml @@ -569,7 +569,7 @@ Settings: Hide Channels Invalid: Podane ID kanału jest niepoprawne Hide Channels Disabled Message: Niektóre z kanałów nie zostały przetworzone, ponieważ zostały zablokowane po swoim ID. Funkcja jest zablokowana, aż do momentu, w - którym zostaną one zaktualizowane. + którym zostaną one zaktualizowane Hide Channels Already Exists: To ID kanału już jest Hide Channels API Error: Nie udało się wyszukać użytkownika po podanym ID. Proszę sprawdzić, czy wpisane ID jest poprawne. @@ -578,6 +578,7 @@ Settings: Hide Videos and Playlists Containing Text Placeholder: Słowo, fragment słowa, lub wyrażenie Hide Channel Home: Schowaj kartę kanału „Główna” + Show Added Items: Pokaż dodane rzeczy The app needs to restart for changes to take effect. Restart and apply change?: Aplikacja musi zostać ponownie uruchomiona, aby zmiany zostały wprowadzone. Uruchomić ponownie i zastosować zmiany? From 6d4c2c57f6ebc0f2ca090b2ecf8bd7e8eb4be1b1 Mon Sep 17 00:00:00 2001 From: kizey <38506198+ikizey@users.noreply.github.com> Date: Sat, 28 Dec 2024 01:11:59 +0400 Subject: [PATCH 3/4] Feature: remember playback rate in watch session (#6449) * feat: use state in Watch for playback rate value instead defaultPlayback store value directly * refactor: update playback rate handling in ft-shaka-video-player and Watch components - Changed default playback rate from 1.0 to 1 in ft-shaka-video-player.js - Renamed event from 'current-playback-rate-update' to 'playback-rate-updated' in ft-shaka-video-player.js and Watch.vue - Updated Watch.js to initialize currentPlaybackRate to null and set it based on store value in mounted lifecycle hook * refactor: simplify playback rate initialization in ft-shaka-video-player and Watch components --- .../ft-shaka-video-player.js | 24 +++++++++++-------- src/renderer/views/Watch/Watch.js | 8 ++++++- src/renderer/views/Watch/Watch.vue | 2 ++ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js index 52f0a7555cfa5..0d83ea8ac926e 100644 --- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js +++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js @@ -123,13 +123,18 @@ export default defineComponent({ type: String, default: null }, + currentPlaybackRate: { + type: Number, + default: 1 + }, }, emits: [ 'error', 'loaded', 'ended', 'timeupdate', - 'toggle-theatre-mode' + 'toggle-theatre-mode', + 'playback-rate-updated' ], setup: function (props, { emit, expose }) { const { locale, t } = useI18n() @@ -235,11 +240,6 @@ export default defineComponent({ }) }) - /** @type {import('vue').ComputedRef} */ - const defaultPlayback = computed(() => { - return store.getters.getDefaultPlayback - }) - /** @type {import('vue').ComputedRef} */ const defaultSkipInterval = computed(() => { return store.getters.getDefaultSkipInterval @@ -901,8 +901,8 @@ export default defineComponent({ // stop shaka-player's click handler firing event.stopPropagation() - video.value.playbackRate = defaultPlayback.value - video.value.defaultPlaybackRate = defaultPlayback.value + video.value.playbackRate = props.currentPlaybackRate + video.value.defaultPlaybackRate = props.currentPlaybackRate } } @@ -2310,8 +2310,8 @@ export default defineComponent({ videoElement.muted = (muted === 'true') } - videoElement.playbackRate = defaultPlayback.value - videoElement.defaultPlaybackRate = defaultPlayback.value + videoElement.playbackRate = props.currentPlaybackRate + videoElement.defaultPlaybackRate = props.currentPlaybackRate const localPlayer = new shaka.Player() @@ -2413,6 +2413,10 @@ export default defineComponent({ container.value.classList.add('no-cursor') await performFirstLoad() + + player.addEventListener('ratechange', () => { + emit('playback-rate-updated', player.getPlaybackRate()) + }) }) async function performFirstLoad() { diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js index 0b43332b71716..96fa9b8cfd568 100644 --- a/src/renderer/views/Watch/Watch.js +++ b/src/renderer/views/Watch/Watch.js @@ -133,7 +133,8 @@ export default defineComponent({ customErrorIcon: null, videoGenreIsMusic: false, /** @type {Date|null} */ - streamingDataExpiryDate: null + streamingDataExpiryDate: null, + currentPlaybackRate: null, } }, computed: { @@ -304,6 +305,7 @@ export default defineComponent({ this.activeFormat = this.defaultVideoFormat this.checkIfTimestamp() + this.currentPlaybackRate = this.$store.getters.getDefaultPlayback }, mounted: function () { this.onMountedDependOnLocalStateLoading() @@ -1657,6 +1659,10 @@ export default defineComponent({ this.blockVideoAutoplay = false }, + updatePlaybackRate(newRate) { + this.currentPlaybackRate = newRate + }, + ...mapActions([ 'updateHistory', 'updateWatchProgress', diff --git a/src/renderer/views/Watch/Watch.vue b/src/renderer/views/Watch/Watch.vue index 42e2fd3d55bbf..d2c792227a8bb 100644 --- a/src/renderer/views/Watch/Watch.vue +++ b/src/renderer/views/Watch/Watch.vue @@ -34,12 +34,14 @@ :theatre-possible="theatrePossible" :use-theatre-mode="useTheatreMode" :vr-projection="vrProjection" + :current-playback-rate="currentPlaybackRate" class="videoPlayer" @error="handlePlayerError" @loaded="handleVideoLoaded" @timeupdate="updateCurrentChapter" @ended="handleVideoEnded" @toggle-theatre-mode="useTheatreMode = !useTheatreMode" + @playback-rate-updated="updatePlaybackRate" />
Date: Fri, 27 Dec 2024 21:43:47 +0000 Subject: [PATCH 4/4] Keyboard shortcut modal (#6306) * Add keyboard shortcut modal * Temp * Add keyboard shortcut modal shortcut entry, shift & enter labels + Mac icons, capitalize non-special shortcut letters * Use usei18n instead of i18n.t * Improve keyboard shortcut button formatting * Update prompt logic to focus ft-icon-buttons as well * Improve mobile styling * Adjust v-for key to reflect new template structure * Apply suggestions from code review Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * Implement review suggestions Change shortcut to be more accurate, remove duplicate action for closing current FreeTube window, update label for force reload * Change shortcut to YouTube's SHIFT+? * Use keyboard icon for button, and check for usingElectron in showing it * Change ctrl+R shortcuts to use new labelling * Exit FS and/or FW when keyboard shortcut modal is opened * Add code comment clarifying where parallel change to shortcut documentation should be * Implement changes from review --------- Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> --- src/constants.js | 29 ++- src/renderer/App.js | 13 +- src/renderer/App.vue | 3 + .../FtKeyboardShortcutPrompt.css | 100 ++++++++++ .../FtKeyboardShortcutPrompt.vue | 185 ++++++++++++++++++ .../components/ft-prompt/ft-prompt.js | 2 +- .../ft-shaka-video-player.js | 17 ++ src/renderer/helpers/utils.js | 16 +- src/renderer/main.js | 6 +- src/renderer/store/modules/utils.js | 17 ++ src/renderer/views/Settings/Settings.css | 8 +- src/renderer/views/Settings/Settings.js | 4 + src/renderer/views/Settings/Settings.vue | 6 + static/locales/en-US.yaml | 52 +++++ 14 files changed, 446 insertions(+), 12 deletions(-) create mode 100644 src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.css create mode 100644 src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue diff --git a/src/constants.js b/src/constants.js index 13294dddd90fa..c21ace4448a8c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -116,19 +116,37 @@ const SyncEvents = { }, } -// note: the multi-key shortcut values are currently just for display use in action titles +/* + DEV NOTE: Duplicate any and all changes made here to our [official documentation site here](https://github.com/FreeTubeApp/FreeTube-Docs/blob/master/usage/keyboard-shortcuts.md) + to have them reflect on the [keyboard shortcut reference webpage](https://docs.freetubeapp.io/usage/keyboard-shortcuts). + Please also update the [keyboard shortcut modal](src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue) +*/ const KeyboardShortcuts = { APP: { GENERAL: { + SHOW_SHORTCUTS: 'shift+?', HISTORY_BACKWARD: 'alt+arrowleft', HISTORY_FORWARD: 'alt+arrowright', - NEW_WINDOW: 'ctrl+N', + FULLSCREEN: 'f11', NAVIGATE_TO_SETTINGS: 'ctrl+,', NAVIGATE_TO_HISTORY: 'ctrl+H', NAVIGATE_TO_HISTORY_MAC: 'cmd+Y', + NEW_WINDOW: 'ctrl+N', + MINIMIZE_WINDOW: 'ctrl+M', + CLOSE_WINDOW: 'ctrl+W', + RESTART_WINDOW: 'ctrl+R', + FORCE_RESTART_WINDOW: 'ctrl+shift+R', + TOGGLE_DEVTOOLS: 'ctrl+shift+I', + FOCUS_SEARCH: 'alt+D', + SEARCH_IN_NEW_WINDOW: 'shift+enter', + RESET_ZOOM: 'ctrl+0', + ZOOM_IN: 'ctrl+plus', + ZOOM_OUT: 'ctrl+-' + }, SITUATIONAL: { - REFRESH: 'r' + REFRESH: 'r', + FOCUS_SECONDARY_SEARCH: 'ctrl+F' }, }, VIDEO_PLAYER: { @@ -152,10 +170,11 @@ const KeyboardShortcuts = { SMALL_FAST_FORWARD: 'arrowright', DECREASE_VIDEO_SPEED: 'o', INCREASE_VIDEO_SPEED: 'p', - LAST_FRAME: ',', - NEXT_FRAME: '.', + SKIP_N_TENTHS: '0..9', LAST_CHAPTER: 'ctrl+arrowleft', NEXT_CHAPTER: 'ctrl+arrowright', + LAST_FRAME: ',', + NEXT_FRAME: '.', } }, } diff --git a/src/renderer/App.js b/src/renderer/App.js index aac7cbd983015..e7ebffa07ca9b 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -10,6 +10,7 @@ import FtToast from './components/ft-toast/ft-toast.vue' import FtProgressBar from './components/FtProgressBar/FtProgressBar.vue' import FtPlaylistAddVideoPrompt from './components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.vue' import FtCreatePlaylistPrompt from './components/ft-create-playlist-prompt/ft-create-playlist-prompt.vue' +import FtKeyboardShortcutPrompt from './components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue' import FtSearchFilters from './components/FtSearchFilters/FtSearchFilters.vue' import { marked } from 'marked' import { IpcChannels } from '../constants' @@ -32,7 +33,8 @@ export default defineComponent({ FtProgressBar, FtPlaylistAddVideoPrompt, FtCreatePlaylistPrompt, - FtSearchFilters + FtSearchFilters, + FtKeyboardShortcutPrompt, }, data: function () { return { @@ -71,6 +73,9 @@ export default defineComponent({ checkForBlogPosts: function () { return this.$store.getters.getCheckForBlogPosts }, + isKeyboardShortcutPromptShown: function () { + return this.$store.getters.getIsKeyboardShortcutPromptShown + }, showAddToPlaylistPrompt: function () { return this.$store.getters.getShowAddToPlaylistPrompt }, @@ -347,6 +352,10 @@ export default defineComponent({ }, handleKeyboardShortcuts: function (event) { + if (event.shiftKey && event.key === '?') { + this.$store.commit('setIsKeyboardShortcutPromptShown', !this.isKeyboardShortcutPromptShown) + } + if (event.altKey) { switch (event.key) { case 'D': @@ -576,6 +585,8 @@ export default defineComponent({ 'fetchInvidiousInstancesFromFile', 'setRandomCurrentInvidiousInstance', 'setupListenersToSyncWindows', + 'hideKeyboardShortcutPrompt', + 'showKeyboardShortcutPrompt', 'updateBaseTheme', 'updateMainColor', 'updateSecColor', diff --git a/src/renderer/App.vue b/src/renderer/App.vue index a0235a5854898..f4ac948e85e41 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -50,6 +50,9 @@ + diff --git a/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.css b/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.css new file mode 100644 index 0000000000000..9f2f71216d56e --- /dev/null +++ b/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.css @@ -0,0 +1,100 @@ + + +.keyboardShortcutPrompt { + max-inline-size: 80%; +} + +.titleAndCloseButton { + display: flex; + justify-content: space-between; + margin-block: 20px; + margin-inline-start: 10px; +} + +h2 { + font-size: 25px; +} + +h3 { + font-size: 20px; +} + +h2, +h3 { + margin-block: 0.25em; + inline-size: fit-content; + font-weight: bold; +} + +.center { + text-align: center; +} + +.primarySection { + inline-size: 100%; +} + +.primarySections, +.primarySection { + display: flex; + flex-flow: row wrap; + gap: 30px; + justify-content: space-evenly; +} + +.secondarySection { + flex: 0 0 500px; +} + +.labelsAndShortcuts { + display: table; + table-layout: auto; + border-collapse: collapse; +} + +.labelAndShortcut { + display: table-row; + font-size: 16px; +} + +.labelAndShortcut + .labelAndShortcut { + border-block-start: 1px solid var(--primary-shadow-color); +} + +.label, +.shortcut { + padding-block: 0.25em; +} + +.label { + display: table-cell; + padding-inline-end: 2em; + inline-size: 300px; + min-inline-size: 300px; +} + +.shortcut { + font-family: monospace; + display: table-cell; + vertical-align: middle; + text-transform: capitalize; +} + +:deep(.promptCard) { + overflow-x: hidden; +} + +@media only screen and (width <= 600px) { + .label { + min-inline-size: 200px; + inline-size: 200px; + } + + .secondarySection { + flex: 0; + } + + .primarySection { + justify-content: flex-start; + } +} diff --git a/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue b/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue new file mode 100644 index 0000000000000..409f0d5ef9575 --- /dev/null +++ b/src/renderer/components/FtKeyboardShortcutPrompt/FtKeyboardShortcutPrompt.vue @@ -0,0 +1,185 @@ + + + + +