diff --git a/.stylelintrc.json b/.stylelintrc.json index e32dc52314fb9..8b2065bb53ec6 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -26,7 +26,7 @@ "selector-pseudo-class-no-unknown": [ true, { - "ignorePseudoClasses": ["deep"] + "ignorePseudoClasses": ["deep", "global"] } ], "a11y/no-outline-none": true, diff --git a/_scripts/_domParser.js b/_scripts/_domParser.js deleted file mode 100644 index 36d81182b9282..0000000000000 --- a/_scripts/_domParser.js +++ /dev/null @@ -1,5 +0,0 @@ -const DOMParser = window.DOMParser - -export { - DOMParser -} diff --git a/_scripts/dev-runner.js b/_scripts/dev-runner.js index 9983371dc10f1..6b083cb6cc9ed 100644 --- a/_scripts/dev-runner.js +++ b/_scripts/dev-runner.js @@ -19,10 +19,14 @@ const web = process.argv.indexOf('--web') !== -1 let mainConfig let rendererConfig let webConfig +let SHAKA_LOCALES_TO_BE_BUNDLED if (!web) { mainConfig = require('./webpack.main.config') rendererConfig = require('./webpack.renderer.config') + + SHAKA_LOCALES_TO_BE_BUNDLED = rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED + delete rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED } else { webConfig = require('./webpack.web.config') } @@ -128,17 +132,27 @@ function startRenderer(callback) { }) const server = new WebpackDevServer({ - static: { - directory: path.resolve(__dirname, '..', 'static'), - watch: { - ignored: [ - /(dashFiles|storyboards)\/*/, - '/**/.DS_Store', - '**/static/locales/*' - ] + static: [ + { + directory: path.resolve(__dirname, '..', 'static'), + watch: { + ignored: [ + /(dashFiles|storyboards)\/*/, + '/**/.DS_Store', + '**/static/locales/*' + ] + }, + publicPath: '/static' }, - publicPath: '/static' - }, + { + directory: path.resolve(__dirname, '..', 'node_modules', 'shaka-player', 'ui', 'locales'), + publicPath: '/static/shaka-player-locales', + watch: { + // Ignore everything that isn't one of the locales that we would bundle in production mode + ignored: `**/!(${SHAKA_LOCALES_TO_BE_BUNDLED.join('|')}).json` + } + } + ], port }, compiler) diff --git a/_scripts/getShakaLocales.js b/_scripts/getShakaLocales.js new file mode 100644 index 0000000000000..5e495bcd3c930 --- /dev/null +++ b/_scripts/getShakaLocales.js @@ -0,0 +1,114 @@ +const { readFileSync, readdirSync } = require('fs') + +function getPreloadedLocales() { + const localesFile = readFileSync(`${__dirname}/../node_modules/shaka-player/dist/locales.js`, 'utf-8') + + const localesLine = localesFile.match(/^\/\/ LOCALES: ([\w, -]+)$/m) + + if (!localesLine) { + throw new Error("Failed to parse shaka-player's preloaded locales") + } + + return localesLine[1].split(',').map(locale => locale.trim()) +} + +function getAllLocales() { + const filenames = readdirSync(`${__dirname}/../node_modules/shaka-player/ui/locales`) + + return new Set(filenames + .filter(filename => filename !== 'source.json' && filename.endsWith('.json')) + .map(filename => filename.replace('.json', ''))) +} + +/** + * Maps the shaka locales to FreeTube's active ones + * This allows us to know which locale files are actually needed + * and which shaka locale needs to be activated for a given FreeTube one. + * @param {Set} shakaLocales + * @param {string[]} freeTubeLocales + */ +function getMappings(shakaLocales, freeTubeLocales) { + /** + * @type {[string, string][]} + * Using this structure as it gets passed to `new Map()` in the player component + * The first element is the FreeTube locale, the second one is the shaka-player one + **/ + const mappings = [] + + for (const locale of freeTubeLocales) { + if (shakaLocales.has(locale)) { + mappings.push([ + locale, + locale + ]) + } else if (shakaLocales.has(locale.replace('_', '-'))) { + mappings.push([ + locale, + locale.replace('_', '-') + ]) + } else if (shakaLocales.has(locale.split(/[-_]/)[0])) { + mappings.push([ + locale, + locale.split(/[-_]/)[0] + ]) + } + } + + // special cases + + mappings.push( + // according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + // "no" is the macro language for "nb" and "nn" + [ + 'nb_NO', + 'no' + ], + [ + 'nn', + 'no' + ], + + // according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + // "iw" is the old/original code for Hebrew, these days it's "he" + [ + 'he', + 'iw' + ], + + // not sure why we have pt, pt-PT and pt-BR in the FreeTube locales + // as pt and pt-PT are the same thing, but we should handle it here anyway + [ + 'pt', + 'pt-PT' + ] + ) + + return mappings +} + +function getShakaLocales() { + const shakaLocales = getAllLocales() + + /** @type {string[]} */ + const freeTubeLocales = JSON.parse(readFileSync(`${__dirname}/../static/locales/activeLocales.json`, 'utf-8')) + + const mappings = getMappings(shakaLocales, freeTubeLocales) + + const preloaded = getPreloadedLocales() + + const shakaMappings = mappings.map(mapping => mapping[1]) + + // use a set to deduplicate the list + // we don't need to bundle any locale files that are already embedded in shaka-player/preloaded + + /** @type {string[]} */ + const toBeBundled = [...new Set(shakaMappings.filter(locale => !preloaded.includes(locale)))] + + return { + SHAKA_LOCALE_MAPPINGS: mappings, + SHAKA_LOCALES_PREBUNDLED: preloaded, + SHAKA_LOCALES_TO_BE_BUNDLED: toBeBundled + } +} + +module.exports = getShakaLocales() diff --git a/_scripts/patchShaka.mjs b/_scripts/patchShaka.mjs new file mode 100644 index 0000000000000..b497a0e89cf5c --- /dev/null +++ b/_scripts/patchShaka.mjs @@ -0,0 +1,135 @@ +// This script fixes shaka not exporting its type definitions and referencing remote google fonts in its CSS +// by adding an export line to the type definitions and downloading the fonts and updating the CSS to point to the local files +// this script only makes changes if they are needed, so running it multiple times doesn't cause any problems + +import { appendFileSync, closeSync, ftruncateSync, openSync, readFileSync, writeFileSync, writeSync } from 'fs' +import { resolve } from 'path' + +const SHAKA_DIST_DIR = resolve(import.meta.dirname, '../node_modules/shaka-player/dist') + +function fixTypes() { + let fixedTypes = false + + let fileHandleNormal + try { + fileHandleNormal = openSync(`${SHAKA_DIST_DIR}/shaka-player.ui.d.ts`, 'a+') + + const contents = readFileSync(fileHandleNormal, 'utf-8') + + // This script is run after every `yarn install`, even if shaka-player wasn't updated + // So we want to check first, if we actually need to make any changes + // or if the ones from the previous run are still intact + if (!contents.includes('export default shaka')) { + appendFileSync(fileHandleNormal, 'export default shaka;\n') + + fixedTypes = true + } + } finally { + if (typeof fileHandleNormal !== 'undefined') { + closeSync(fileHandleNormal) + } + } + + let fileHandleDebug + try { + fileHandleDebug = openSync(`${SHAKA_DIST_DIR}/shaka-player.ui.debug.d.ts`, 'a+') + + const contents = readFileSync(fileHandleDebug, 'utf-8') + + // This script is run after every `yarn install`, even if shaka-player wasn't updated + // So we want to check first, if we actually need to make any changes + // or if the ones from the previous run are still intact + if (!contents.includes('export default shaka')) { + appendFileSync(fileHandleDebug, 'export default shaka;\n') + + fixedTypes = true + } + } finally { + if (typeof fileHandleDebug !== 'undefined') { + closeSync(fileHandleDebug) + } + } + + if (fixedTypes) { + console.log('Fixed shaka-player types') + } +} + +async function removeRobotoFont() { + let cssFileHandle + try { + cssFileHandle = openSync(`${SHAKA_DIST_DIR}/controls.css`, 'r+') + + let cssContents = readFileSync(cssFileHandle, 'utf-8') + + const beforeReplacement = cssContents.length + cssContents = cssContents.replace(/@font-face\{font-family:Roboto;[^}]+\}/, '') + + if (cssContents.length !== beforeReplacement) { + ftruncateSync(cssFileHandle) + writeSync(cssFileHandle, cssContents, 0, 'utf-8') + + console.log('Removed shaka-player Roboto font, so it uses ours') + } + } finally { + if (typeof cssFileHandle !== 'undefined') { + closeSync(cssFileHandle) + } + } +} + +async function replaceAndDownloadMaterialIconsFont() { + let cssFileHandle + try { + cssFileHandle = openSync(`${SHAKA_DIST_DIR}/controls.css`, 'r+') + + let cssContents = readFileSync(cssFileHandle, 'utf-8') + + const fontFaceRegex = /@font-face{font-family:'Material Icons Round'[^}]+format\('opentype'\)}/ + + if (fontFaceRegex.test(cssContents)) { + const cssResponse = await fetch('https://fonts.googleapis.com/icon?family=Material+Icons+Round', { + headers: { + // Without the user-agent it returns the otf file instead of the woff2 one + 'user-agent': 'Firefox/125.0' + } + }) + + const text = await cssResponse.text() + + let newFontCSS = text.match(/(@font-face\s*{[^}]+})/)[1].replaceAll('\n', '') + + + const urlMatch = newFontCSS.match(/https:\/\/fonts\.gstatic\.com\/s\/materialiconsround\/(?[^\/]+)\/[^.]+\.(?[\w]+)/) + + const url = urlMatch[0] + const { version, extension } = urlMatch.groups + + const fontResponse = await fetch(url) + const fontContent = new Uint8Array(await fontResponse.arrayBuffer()) + + const filename = `shaka-materialiconsround-${version}.${extension}` + writeFileSync(`${SHAKA_DIST_DIR}/${filename}`, fontContent) + + newFontCSS = newFontCSS.replace(url, `./${filename}`) + + cssContents = cssContents.replace(fontFaceRegex, newFontCSS) + + ftruncateSync(cssFileHandle) + writeSync(cssFileHandle, cssContents, 0, 'utf-8') + + console.log('Changed shaka-player Material Icons Rounded font to use the smaller woff2 format instead of otf') + console.log('Downloaded shaka-player Material Icons Rounded font') + } + } catch (e) { + console.error(e) + } finally { + if (typeof cssFileHandle !== 'undefined') { + closeSync(cssFileHandle) + } + } +} + +fixTypes() +await removeRobotoFont() +await replaceAndDownloadMaterialIconsFont() diff --git a/_scripts/webpack.renderer.config.js b/_scripts/webpack.renderer.config.js index 6d5c90c66d47e..c1b813816b867 100644 --- a/_scripts/webpack.renderer.config.js +++ b/_scripts/webpack.renderer.config.js @@ -7,6 +7,11 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') const CopyWebpackPlugin = require('copy-webpack-plugin') +const { + SHAKA_LOCALE_MAPPINGS, + SHAKA_LOCALES_PREBUNDLED, + SHAKA_LOCALES_TO_BE_BUNDLED +} = require('./getShakaLocales') const isDevMode = process.env.NODE_ENV === 'development' @@ -122,7 +127,9 @@ const config = { 'process.env.SUPPORTS_LOCAL_API': true, 'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames), 'process.env.GEOLOCATION_NAMES': JSON.stringify(readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))), - 'process.env.SWIPER_VERSION': `'${swiperVersion}'` + 'process.env.SWIPER_VERSION': `'${swiperVersion}'`, + 'process.env.SHAKA_LOCALE_MAPPINGS': JSON.stringify(SHAKA_LOCALE_MAPPINGS), + 'process.env.SHAKA_LOCALES_PREBUNDLED': JSON.stringify(SHAKA_LOCALES_PREBUNDLED) }), new HtmlWebpackPlugin({ excludeChunks: ['processTaskWorker'], @@ -143,7 +150,21 @@ const config = { transformAll: (assets) => { return Buffer.concat(assets.map(asset => asset.data)) } - } + }, + // Don't need to copy them in dev mode, + // as we configure WebpackDevServer to serve them + ...(isDevMode ? [] : [ + { + from: path.join(__dirname, '../node_modules/shaka-player/ui/locales', `{${SHAKA_LOCALES_TO_BE_BUNDLED.join(',')}}.json`).replaceAll('\\', '/'), + to: path.join(__dirname, '../dist/static/shaka-player-locales'), + context: path.join(__dirname, '../node_modules/shaka-player/ui/locales'), + transform: { + transformer: (input) => { + return JSON.stringify(JSON.parse(input.toString('utf-8'))) + } + } + } + ]) ] }) ], @@ -156,14 +177,18 @@ const config = { 'youtubei.js$': 'youtubei.js/web', - // video.js's mpd-parser uses @xmldom/xmldom so that it can support both node and web browsers - // as FreeTube only runs in electron and web browsers we can use the native DOMParser class, instead of the "polyfill" - // https://caniuse.com/mdn-api_domparser - '@xmldom/xmldom$': path.resolve(__dirname, '_domParser.js') + // change to "shaka-player.ui.debug.js" to get debug logs (update jsconfig to get updated types) + 'shaka-player$': 'shaka-player/dist/shaka-player.ui.js', }, extensions: ['.js', '.vue'] }, target: 'electron-renderer', } +if (isDevMode) { + // hack to pass it through to the dev-runner.js script + // gets removed there before the config object is passed to webpack + config.SHAKA_LOCALES_TO_BE_BUNDLED = SHAKA_LOCALES_TO_BE_BUNDLED +} + module.exports = config diff --git a/_scripts/webpack.web.config.js b/_scripts/webpack.web.config.js index abbd517891e87..75f2ccd846c8b 100644 --- a/_scripts/webpack.web.config.js +++ b/_scripts/webpack.web.config.js @@ -8,6 +8,11 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin') const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') +const { + SHAKA_LOCALE_MAPPINGS, + SHAKA_LOCALES_PREBUNDLED, + SHAKA_LOCALES_TO_BE_BUNDLED +} = require('./getShakaLocales') const isDevMode = process.env.NODE_ENV === 'development' @@ -116,19 +121,7 @@ const config = { 'process.env.IS_ELECTRON': false, 'process.env.IS_ELECTRON_MAIN': false, 'process.env.SUPPORTS_LOCAL_API': false, - 'process.env.SWIPER_VERSION': `'${swiperVersion}'`, - - // video.js' vhs-utils supports both atob() in web browsers and Buffer in node - // As the FreeTube web build only runs in web browsers, we can override their check for atob() here: https://github.com/videojs/vhs-utils/blob/main/src/decode-b64-to-uint8-array.js#L3 - // overriding that check means we don't need to include a Buffer polyfill - // https://caniuse.com/atob-btoa - - // NOTE FOR THE FUTURE: this override won't work with vite as their define does a find and replace in the code for production builds, - // but uses globals in development builds to save build time, so this would replace the actual atob() function with true if used with vite - // this works in webpack as webpack does a find and replace in the source code for both development and production builds - // https://vitejs.dev/config/shared-options.html#define - // https://webpack.js.org/plugins/define-plugin/ - 'window.atob': true + 'process.env.SWIPER_VERSION': `'${swiperVersion}'` }), new webpack.ProvidePlugin({ process: 'process/browser' @@ -163,10 +156,8 @@ const config = { DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$: path.resolve(__dirname, '../src/datastores/handlers/web.js'), - // video.js's mpd-parser uses @xmldom/xmldom so that it can support both node and web browsers - // As FreeTube only runs in electron and web browsers, we can use the native DOMParser class, instead of the "polyfill" - // https://caniuse.com/mdn-api_domparser - '@xmldom/xmldom$': path.resolve(__dirname, '_domParser.js') + // change to "shaka-player.ui.debug.js" to get debug logs (update jsconfig to get updated types) + 'shaka-player$': 'shaka-player/dist/shaka-player.ui.js', }, fallback: { 'fs/promises': path.resolve(__dirname, '_empty.js'), @@ -188,7 +179,9 @@ config.plugins.push( processLocalesPlugin, new webpack.DefinePlugin({ 'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames), - 'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))) + 'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))), + 'process.env.SHAKA_LOCALE_MAPPINGS': JSON.stringify(SHAKA_LOCALE_MAPPINGS), + 'process.env.SHAKA_LOCALES_PREBUNDLED': JSON.stringify(SHAKA_LOCALES_PREBUNDLED) }), new CopyWebpackPlugin({ patterns: [ @@ -203,7 +196,12 @@ config.plugins.push( dot: true, ignore: ['**/.*', '**/locales/**', '**/pwabuilder-sw.js', '**/dashFiles/**', '**/storyboards/**'], }, - }, + }, + { + from: path.join(__dirname, '../node_modules/shaka-player/ui/locales', `{${SHAKA_LOCALES_TO_BE_BUNDLED.join(',')}}.json`).replaceAll('\\', '/'), + to: path.join(__dirname, '../dist/web/static/shaka-player-locales'), + context: path.join(__dirname, '../node_modules/shaka-player/ui/locales') + } ] }) ) diff --git a/jsconfig.json b/jsconfig.json index 3dd46670d49e2..dad8a794996bf 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -9,6 +9,9 @@ "DB_HANDLERS_ELECTRON_RENDERER_OR_WEB": [ "src/datastores/handlers/electron", "src/datastores/handlers/web" + ], + "shaka-player": [ + "./node_modules/shaka-player/dist/shaka-player.ui" ] } } diff --git a/package.json b/package.json index 287b99d2d7030..cf93e6ce8a487 100644 --- a/package.json +++ b/package.json @@ -19,19 +19,20 @@ "url": "https://github.com/FreeTubeApp/FreeTube/issues" }, "scripts": { - "build": "run-s rebuild:electron pack build-release", - "build:arm64": "run-s rebuild:electron pack build-release:arm64", - "build:arm32": "run-s rebuild:electron pack build-release:arm32", + "build": "run-s rebuild:electron patch-shaka pack build-release", + "build:arm64": "run-s rebuild:electron patch-shaka pack build-release:arm64", + "build:arm32": "run-s rebuild:electron patch-shaka pack build-release:arm32", "build-release": "node _scripts/build.js", "build-release:arm64": "node _scripts/build.js arm64", "build-release:arm32": "node _scripts/build.js arm32", "clean": "rimraf build/ dist/", - "debug": "run-s rebuild:electron debug-runner", + "debug": "run-s rebuild:electron patch-shaka debug-runner", "debug-runner": "node _scripts/dev-runner.js --remote-debug", - "dev": "run-s rebuild:electron dev-runner", + "dev": "run-s rebuild:electron patch-shaka dev-runner", "dev:web": "node _scripts/dev-runner.js --web", "dev-runner": "node _scripts/dev-runner.js", "get-instances": "node _scripts/getInstances.js", + "patch-shaka": "node _scripts/patchShaka.mjs", "get-regions": "node _scripts/getRegions.mjs", "lint-all": "run-p lint lint-json", "lint": "run-p eslint-lint lint-style", @@ -46,7 +47,7 @@ "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", - "postinstall": "yarn run --silent rebuild:electron", + "postinstall": "run-s --silent rebuild:electron patch-shaka", "prettier": "prettier --write \"{src,_scripts}/**/*.{js,vue}\"", "rebuild:electron": "electron-builder install-app-deps", "release": "run-s test build", @@ -59,40 +60,34 @@ "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/vue-fontawesome": "^2.0.10", "@seald-io/nedb": "^4.0.4", - "@silvermine/videojs-quality-selector": "^1.3.1", "autolinker": "^4.0.0", - "electron-context-menu": "^4.0.1", + "electron-context-menu": "^4.0.4", "lodash.debounce": "^4.0.8", - "marked": "^14.0.0", + "marked": "^14.1.0", "path-browserify": "^1.0.1", "portal-vue": "^2.1.7", "process": "^0.11.10", - "swiper": "^11.1.9", - "video.js": "7.21.5", - "videojs-contrib-quality-levels": "^3.0.0", - "videojs-http-source-selector": "^1.1.6", - "videojs-mobile-ui": "^0.8.0", - "videojs-overlay": "^3.1.0", - "videojs-vtt-thumbnails-freetube": "0.0.15", + "shaka-player": "^4.10.12", + "swiper": "^11.1.12", "vue": "^2.7.16", "vue-i18n": "^8.28.2", "vue-observe-visibility": "^1.0.0", "vue-router": "^3.6.5", "vuex": "^3.6.2", - "youtubei.js": "^10.3.0" + "youtubei.js": "^10.4.0" }, "devDependencies": { "@babel/core": "^7.25.2", "@babel/eslint-parser": "^7.25.1", - "@babel/plugin-transform-class-properties": "^7.24.7", - "@babel/preset-env": "^7.25.3", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/preset-env": "^7.25.4", "@double-great/stylelint-a11y": "^3.0.2", "@intlify/eslint-plugin-vue-i18n": "^3.0.0", "babel-loader": "^9.1.3", "copy-webpack-plugin": "^12.0.2", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", - "electron": "^31.4.0", + "electron": "^32.0.1", "electron-builder": "^24.13.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -109,16 +104,16 @@ "html-webpack-plugin": "^5.6.0", "js-yaml": "^4.1.0", "json-minimizer-webpack-plugin": "^5.0.0", - "lefthook": "^1.7.14", - "mini-css-extract-plugin": "^2.9.0", + "lefthook": "^1.7.15", + "mini-css-extract-plugin": "^2.9.1", "npm-run-all2": "^6.2.2", - "postcss": "^8.4.41", + "postcss": "^8.4.44", "postcss-scss": "^4.0.9", "prettier": "^2.8.8", "rimraf": "^6.0.1", "sass": "^1.77.8", - "sass-loader": "^16.0.0", - "stylelint": "^16.8.2", + "sass-loader": "^16.0.1", + "stylelint": "^16.9.0", "stylelint-config-sass-guidelines": "^12.0.0", "stylelint-config-standard": "^36.0.1", "stylelint-high-performance-animation": "^1.10.0", @@ -127,7 +122,7 @@ "vue-devtools": "^5.1.4", "vue-eslint-parser": "^9.4.3", "vue-loader": "^15.10.0", - "webpack": "^5.93.0", + "webpack": "^5.94.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4", "yaml-eslint-parser": "^1.2.3" diff --git a/src/constants.js b/src/constants.js index 56b8a44d0915e..847b86cdc0059 100644 --- a/src/constants.js +++ b/src/constants.js @@ -36,10 +36,10 @@ const IpcChannels = { GET_REPLACE_HTTP_CACHE: 'get-replace-http-cache', TOGGLE_REPLACE_HTTP_CACHE: 'toggle-replace-http-cache', - SHOW_VIDEO_STATISTICS: 'show-video-statistics', - PLAYER_CACHE_GET: 'player-cache-get', - PLAYER_CACHE_SET: 'player-cache-set' + PLAYER_CACHE_SET: 'player-cache-set', + + SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization' } const DBActions = { diff --git a/src/main/index.js b/src/main/index.js index 00724b07ba04a..af5134b7b4be3 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -57,13 +57,6 @@ function runApp() { showSelectAll: false, showCopyLink: false, prepend: (defaultActions, parameters, browserWindow) => [ - { - label: 'Show / Hide Video Statistics', - visible: parameters.mediaType === 'video', - click: () => { - browserWindow.webContents.send(IpcChannels.SHOW_VIDEO_STATISTICS) - } - }, { label: 'Open in a New Window', // Only show the option for in-app URLs and not external ones @@ -403,15 +396,19 @@ function runApp() { sameSite: 'no_restriction', }) - // make InnerTube requests work with the fetch function - // InnerTube rejects requests if the referer isn't YouTube or empty - const innertubeAndMediaRequestFilter = { urls: ['https://www.youtube.com/youtubei/*', 'https://*.googlevideo.com/videoplayback?*'] } - - session.defaultSession.webRequest.onBeforeSendHeaders(innertubeAndMediaRequestFilter, ({ requestHeaders, url, resourceType }, callback) => { - requestHeaders.Referer = 'https://www.youtube.com/' - requestHeaders.Origin = 'https://www.youtube.com' + const onBeforeSendHeadersRequestFilter = { + urls: ['https://*/*', 'http://*/*'], + types: ['xhr', 'media', 'image'] + } + session.defaultSession.webRequest.onBeforeSendHeaders(onBeforeSendHeadersRequestFilter, ({ requestHeaders, url, webContents }, callback) => { + const urlObj = new URL(url) if (url.startsWith('https://www.youtube.com/youtubei/')) { + // make InnerTube requests work with the fetch function + // InnerTube rejects requests if the referer isn't YouTube or empty + 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 @@ -430,43 +427,19 @@ function runApp() { requestHeaders['Sec-Fetch-Mode'] = 'same-origin' requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false' } - } else { + } else if (urlObj.origin.endsWith('.googlevideo.com') && urlObj.pathname === '/videoplayback') { + requestHeaders.Referer = 'https://www.youtube.com/' + requestHeaders.Origin = 'https://www.youtube.com' + // YouTube doesn't send the Content-Type header for the media requests, so we shouldn't either delete requestHeaders['Content-Type'] - } - - // YouTube throttles the adaptive formats if you request a chunk larger than 10MiB. - // For the DASH formats we are fine as video.js doesn't seem to ever request chunks that big. - // The legacy formats don't have any chunk size limits. - // For the audio formats we need to handle it ourselves, as the browser requests the entire audio file, - // which means that for most videos that are longer than 10 mins, we get throttled, as the audio track file sizes surpass that 10MiB limit. + } else if (webContents) { + const invidiousAuthorization = invidiousAuthorizations.get(webContents.id) - // This code checks if the file is larger than the limit, by checking the `clen` query param, - // which YouTube helpfully populates with the content length for us. - // If it does surpass that limit, it then checks if the requested range is larger than the limit - // (seeking right at the end of the video, would result in a small enough range to be under the chunk limit) - // if that surpasses the limit too, it then limits the requested range to 10MiB, by setting the range to `start-${start + 10MiB}`. - if (resourceType === 'media' && url.includes('&mime=audio') && requestHeaders.Range) { - const TEN_MIB = 10 * 1024 * 1024 - - const contentLength = parseInt(new URL(url).searchParams.get('clen')) - - if (contentLength > TEN_MIB) { - const [startStr, endStr] = requestHeaders.Range.split('=')[1].split('-') - - const start = parseInt(startStr) - - // handle open ended ranges like `0-` and `1234-` - const end = endStr.length === 0 ? contentLength : parseInt(endStr) - - if (end - start > TEN_MIB) { - const newEnd = start + TEN_MIB - - requestHeaders.Range = `bytes=${start}-${newEnd}` - } + if (invidiousAuthorization && url.startsWith(invidiousAuthorization.url)) { + requestHeaders.Authorization = invidiousAuthorization.authorization } } - // eslint-disable-next-line n/no-callback-literal callback({ requestHeaders }) }) @@ -488,8 +461,10 @@ function runApp() { const imageCache = new ImageCache() protocol.handle('imagecache', (request) => { + const [requestUrl, rawWebContentsId] = request.url.split('#') + return new Promise((resolve, reject) => { - const url = decodeURIComponent(request.url.substring(13)) + const url = decodeURIComponent(requestUrl.substring(13)) if (imageCache.has(url)) { const cached = imageCache.get(url) @@ -499,9 +474,22 @@ function runApp() { return } + let headers + + if (rawWebContentsId) { + const invidiousAuthorization = invidiousAuthorizations.get(parseInt(rawWebContentsId)) + + if (invidiousAuthorization && url.startsWith(invidiousAuthorization.url)) { + headers = { + Authorization: invidiousAuthorization.authorization + } + } + } + const newRequest = net.request({ method: request.method, - url + url, + headers }) // Electron doesn't allow certain headers to be set: @@ -548,19 +536,20 @@ function runApp() { }) }) - const imageRequestFilter = { urls: ['https://*/*', 'http://*/*'] } + const imageRequestFilter = { urls: ['https://*/*', 'http://*/*'], types: ['image'] } session.defaultSession.webRequest.onBeforeRequest(imageRequestFilter, (details, callback) => { // the requests made by the imagecache:// handler to fetch the image, // are allowed through, as their resourceType is 'other' - if (details.resourceType === 'image') { - // eslint-disable-next-line n/no-callback-literal - callback({ - redirectURL: `imagecache://${encodeURIComponent(details.url)}` - }) - } else { - // eslint-disable-next-line n/no-callback-literal - callback({}) + + let redirectURL = `imagecache://${encodeURIComponent(details.url)}` + + if (details.webContents) { + redirectURL += `#${details.webContents.id}` } + + callback({ + redirectURL + }) }) // --- end of `if experimentsDisableDiskCache` --- @@ -594,8 +583,8 @@ function runApp() { return 'text/javascript' case 'ttf': return 'font/ttf' - case 'woff': - return 'font/woff' + case 'woff2': + return 'font/woff2' case 'svg': return 'image/svg+xml' case 'png': @@ -1011,6 +1000,21 @@ function runApp() { await asyncFs.writeFile(filePath, new Uint8Array(value)) }) + /** @type {Map} */ + const invidiousAuthorizations = new Map() + + ipcMain.on(IpcChannels.SET_INVIDIOUS_AUTHORIZATION, (event, authorization, url) => { + if (!isFreeTubeUrl(event.senderFrame.url)) { + return + } + + if (!authorization) { + invidiousAuthorizations.delete(event.sender.id) + } else if (typeof authorization === 'string' && typeof url === 'string') { + invidiousAuthorizations.set(event.sender.id, { authorization, url }) + } + }) + // ************************************************* // // DB related IPC calls // *********** // @@ -1459,6 +1463,12 @@ function runApp() { } }) + app.on('web-contents-created', (_, webContents) => { + webContents.once('destroyed', () => { + invidiousAuthorizations.delete(webContents.id) + }) + }) + /* * Check if an argument was passed and send it over to the GUI (Linux / Windows). * Remove freetube:// protocol if present @@ -1628,7 +1638,7 @@ function runApp() { click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } - browserWindow.webContents.goBack() + browserWindow.webContents.navigationHistory.goBack() }, type: 'normal', }, @@ -1638,7 +1648,7 @@ function runApp() { click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } - browserWindow.webContents.goForward() + browserWindow.webContents.navigationHistory.goForward() }, type: 'normal', }, diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 759c91240926e..93d14723725e7 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -110,5 +110,4 @@