diff --git a/.babelrc b/.babelrc index bca0e3c2c303e..c13cff889347b 100644 --- a/.babelrc +++ b/.babelrc @@ -11,6 +11,6 @@ ] ], "plugins": [ - "@babel/proposal-class-properties" + "@babel/plugin-transform-class-properties" ] } diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 0cc5b81014008..0000000000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -_scripts -dist diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index a0c8c79b28a3a..0000000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,127 +0,0 @@ -const path = require('path') -const { readFileSync } = require('fs') - -const activeLocales = JSON.parse(readFileSync(path.join(__dirname, './static/locales/activeLocales.json'))) - -module.exports = { - // https://eslint.org/docs/user-guide/configuring#using-configuration-files-1 - root: true, - - // https://eslint.org/docs/user-guide/configuring#specifying-environments - env: { - browser: true, - node: true - }, - - // https://eslint.org/docs/user-guide/configuring#specifying-parser - parser: 'vue-eslint-parser', - - // https://eslint.vuejs.org/user-guide/#faq - parserOptions: { - parser: '@babel/eslint-parser', - ecmaVersion: 2022, - sourceType: 'module' - }, - - overrides: [ - { - files: ['*.json'], - parser: 'jsonc-eslint-parser', - extends: ['plugin:jsonc/base'], - rules: { - 'no-tabs': 'off', - 'comma-spacing': 'off' - } - }, - { - files: ['*.yaml', '*.yml'], - parser: 'yaml-eslint-parser', - extends: ['plugin:yml/recommended'], - rules: { - 'yml/no-irregular-whitespace': 'off' - } - } - ], - - // https://eslint.org/docs/user-guide/configuring#extending-configuration-files - // order matters: from least important to most important in terms of overriding - // Prettier + Vue: https://medium.com/@gogl.alex/how-to-properly-set-up-eslint-with-prettier-for-vue-or-nuxt-in-vscode-e42532099a9c - extends: [ - 'prettier', - 'eslint:recommended', - 'plugin:vue/recommended', - 'standard', - 'plugin:jsonc/recommended-with-json', - 'plugin:vuejs-accessibility/recommended', - 'plugin:@intlify/vue-i18n/recommended' - ], - - // https://eslint.org/docs/user-guide/configuring#configuring-plugins - plugins: ['vue', 'vuejs-accessibility', 'n', 'unicorn', '@intlify/vue-i18n'], - - rules: { - 'space-before-function-paren': 'off', - 'comma-dangle': ['error', 'only-multiline'], - 'vue/no-v-html': 'off', - 'no-console': ['error', { allow: ['warn', 'error'] }], - 'no-unused-vars': 'warn', - 'no-undef': 'warn', - 'object-shorthand': 'off', - 'vue/no-template-key': 'warn', - 'vue/no-useless-template-attributes': 'off', - 'vue/multi-word-component-names': 'off', - 'vuejs-accessibility/no-onchange': 'off', - 'vuejs-accessibility/label-has-for': ['error', { - required: { - some: ['nesting', 'id'] - } - }], - 'vuejs-accessibility/no-static-element-interactions': 'off', - 'n/no-callback-literal': 'warn', - 'n/no-path-concat': 'warn', - 'unicorn/better-regex': 'error', - 'unicorn/no-array-push-push': 'error', - 'unicorn/prefer-keyboard-event-key': 'error', - 'unicorn/prefer-regexp-test': 'error', - 'unicorn/prefer-string-replace-all': 'error', - '@intlify/vue-i18n/no-dynamic-keys': 'error', - // TODO: enable at a later date. currently disabled to prevent massive conflicts for initial PR - // '@intlify/vue-i18n/no-unused-keys': [ - // 'error', - // { - // extensions: ['.js', '.vue', 'yaml'] - // } - // ], - '@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error', - '@intlify/vue-i18n/no-raw-text': [ - 'error', - { - attributes: { - '/.+/': [ - 'title', - 'aria-label', - 'aria-placeholder', - 'aria-roledescription', - 'aria-valuetext', - 'tooltip', - 'message' - ], - input: ['placeholder', 'value'], - img: ['alt'] - }, - ignoreText: ['-', '•', '/', 'YouTube', 'Invidious', 'FreeTube'] - } - ], - // Only applicable when we upgrade to Vue 3 and vue-i18n 9+ - '@intlify/vue-i18n/no-deprecated-tc': 'off', - - 'vue/require-explicit-emits': 'error', - 'vue/no-unused-emit-declarations': 'error', - }, - settings: { - 'vue-i18n': { - localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, - messageSyntaxVersion: '^8.0.0' - } - } -} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cb49b32397ef2..34f5ce3ee5439 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,6 +17,7 @@ updates: patterns: - "eslint" - "eslint-*" + - "@eslint/*" - "yaml-eslint-parser" - "vue-eslint-parser" stylelint: diff --git a/.github/workflows/calibreapp-image-actions.yml b/.github/workflows/calibreapp-image-actions.yml index d336cad2328ab..1315f1fe5915b 100644 --- a/.github/workflows/calibreapp-image-actions.yml +++ b/.github/workflows/calibreapp-image-actions.yml @@ -20,7 +20,7 @@ jobs: compressOnly: true - name: Create New Pull Request If Needed if: steps.calibre.outputs.markdown != '' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v7 with: title: Compressed Images Nightly branch-suffix: timestamp diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index dcb395c41cd27..abee92197ac42 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -77,22 +77,22 @@ jobs: date +"%Y-%m-%d" >> $GITHUB_ENV echo 'EOF' >> $GITHUB_ENV - name: Update x64 File Location in yml File - uses: mikefarah/yq@v4.44.2 + uses: mikefarah/yq@v4.44.3 with: # The Command which should be run cmd: yq -i '.modules[0].sources[0].url = "https://github.com/FreeTubeApp/FreeTube/releases/download/v${{ steps.sub.outputs.result }}-beta/freetube-${{ steps.sub.outputs.result }}-linux-portable-x64.zip"' io.freetubeapp.FreeTube.yml - name: Update x64 Hash in yml File - uses: mikefarah/yq@v4.44.2 + uses: mikefarah/yq@v4.44.3 with: # The Command which should be run cmd: yq -i '.modules[0].sources[0].sha256 = "${{ env.HASH_X64 }}"' io.freetubeapp.FreeTube.yml - name: Update ARM File Location in yml File - uses: mikefarah/yq@v4.44.2 + uses: mikefarah/yq@v4.44.3 with: # The Command which should be run cmd: yq -i '.modules[0].sources[1].url = "https://github.com/FreeTubeApp/FreeTube/releases/download/v${{ steps.sub.outputs.result }}-beta/freetube-${{ steps.sub.outputs.result }}-linux-portable-arm64.zip"' io.freetubeapp.FreeTube.yml - name: Update ARM Hash in yml File - uses: mikefarah/yq@v4.44.2 + uses: mikefarah/yq@v4.44.3 with: # The Command which should be run cmd: yq -i '.modules[0].sources[1].sha256 = "${{ env.HASH_ARM64 }}"' io.freetubeapp.FreeTube.yml diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml deleted file mode 100644 index 834da028dd957..0000000000000 --- a/.github/workflows/report.yml +++ /dev/null @@ -1,70 +0,0 @@ -# This is a basic workflow to help you get started with Actions - -name: Project Board Automation - -on: - issues: - types: [closed, deleted, reopened, opened] - -jobs: - assign-issues-to-projects: - runs-on: ubuntu-latest - steps: - - # For bug reports - - name: New bug issue - uses: alex-page/github-project-automation-plus@v0.9.0 - if: contains(github.event.issue.labels.*.name, 'bug') && github.event.action == 'opened' - with: - project: Bug Reports - column: To assign - repo-token: ${{ secrets.PUSH_TOKEN }} - action: update - - - name: Bug issue closed - uses: alex-page/github-project-automation-plus@v0.9.0 - if: github.event.action == 'closed' || github.event.action == 'deleted' - with: - action: delete - project: Bug Reports - column: To assign - repo-token: ${{ secrets.PUSH_TOKEN }} - - - name: Bug issue reopened - uses: alex-page/github-project-automation-plus@v0.9.0 - if: contains(github.event.issue.labels.*.name, 'bug') && github.event.action == 'reopened' - with: - project: Bug Reports - column: To assign - repo-token: ${{ secrets.PUSH_TOKEN }} - action: update - - # For feature requests - - name: New feature issue - uses: alex-page/github-project-automation-plus@v0.9.0 - if: contains(github.event.issue.labels.*.name, 'enhancement') && github.event.action == 'opened' - with: - project: Feature Requests - column: To assign - repo-token: ${{ secrets.PUSH_TOKEN }} - action: update - - - name: Feature request issue closed - uses: alex-page/github-project-automation-plus@v0.9.0 - if: github.event.action == 'closed' || github.event.action == 'deleted' - with: - action: delete - project: Feature Requests - column: To assign - repo-token: ${{ secrets.PUSH_TOKEN }} - - - name: Feature request issue reopened - uses: alex-page/github-project-automation-plus@v0.9.0 - if: contains(github.event.issue.labels.*.name, 'enhancement') && github.event.action == 'reopened' - with: - project: Feature Requests - column: To assign - repo-token: ${{ secrets.PUSH_TOKEN }} - action: update - - 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/README.md b/README.md index 5a42b6c49d877..200784b2517f4 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,6 @@ longer track you using cookies or JavaScript. Your subscriptions and history are * Option to show only family friendly content * Show/hide functionality or elements within the app using the distraction free settings * View channel community posts -* View most age restricted videos ### Browser Extension FreeTube is supported by the [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect) and [LibRedirect](https://github.com/libredirect/libredirect) extensions, which will allow you to open YouTube links into FreeTube. diff --git a/_scripts/ProcessLocalesPlugin.js b/_scripts/ProcessLocalesPlugin.js index e3b2dd7a88cf9..2b6abeae8df09 100644 --- a/_scripts/ProcessLocalesPlugin.js +++ b/_scripts/ProcessLocalesPlugin.js @@ -40,17 +40,17 @@ class ProcessLocalesPlugin { /** @type {(updatedLocales: [string, string][]) => void|null} */ this.notifyLocaleChange = null - if (this.hotReload) { - this.hotReloadScript = readFileSync(`${__dirname}/_hotReloadLocalesScript.js`, 'utf-8') - } - this.loadLocales() } /** @param {import('webpack').Compiler} compiler */ apply(compiler) { const { CachedSource, RawSource } = compiler.webpack.sources; - const { Compilation } = compiler.webpack + const { Compilation, DefinePlugin } = compiler.webpack + + new DefinePlugin({ + 'process.env.HOT_RELOAD_LOCALES': this.hotReload + }).apply(compiler) compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { const IS_DEV_SERVER = !!compiler.watching @@ -72,6 +72,7 @@ class ProcessLocalesPlugin { } for (let [locale, data] of this.locales) { + // eslint-disable-next-line no-async-promise-executor promises.push(new Promise(async (resolve) => { if (IS_DEV_SERVER && compiler.fileTimestamps) { const filePath = join(this.inputDir, `${locale}.yaml`) @@ -131,24 +132,12 @@ class ProcessLocalesPlugin { }) compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => { + // eslint-disable-next-line no-extra-boolean-cast if (!!compiler.watching) { // watch locale files for changes compilation.fileDependencies.addAll(this.filePaths) } }) - - compiler.hooks.emit.tap(PLUGIN_NAME, (compilation) => { - if (this.hotReload) { - // Find generated JavaScript output file (e.g. renderer.js or web.js) - // and inject the code snippet that listens for locale updates and replaces vue-i18n's locales - - /** @type {string} */ - const filename = [...[...compilation.chunks][0].files] - .find(file => file.endsWith('.js')) - - compilation.assets[filename]._source._children.push(`\n${this.hotReloadScript}`) - } - }) } loadLocales() { 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/_hotReloadLocalesScript.js b/_scripts/_hotReloadLocalesScript.js deleted file mode 100644 index 8cbfb6ef8812d..0000000000000 --- a/_scripts/_hotReloadLocalesScript.js +++ /dev/null @@ -1,18 +0,0 @@ -const websocket = new WebSocket('ws://localhost:9080/ws') - -websocket.onmessage = (event) => { - const message = JSON.parse(event.data) - - if (message.type === 'freetube-locale-update') { - const i18n = document.getElementById('app').__vue__.$i18n - - for (const [locale, data] of message.data) { - // Only update locale data if it was already loaded - if (i18n.availableLocales.includes(locale)) { - const localeData = JSON.parse(data) - - i18n.setLocaleMessage(locale, localeData) - } - } - } -} 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/getRegions.mjs b/_scripts/getRegions.mjs index 90adce4d0dfd0..842bd7cb16efa 100644 --- a/_scripts/getRegions.mjs +++ b/_scripts/getRegions.mjs @@ -28,7 +28,7 @@ const initialResponse = await scrapeLanguage('en') // Scrape language menu in en-US /** @type {string[]} */ -const youTubeLanguages = initialResponse.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items +const youTubeLanguages = initialResponse.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items[2].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items .map(({ compactLinkRenderer }) => { return compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].selectLanguageCommand.hl }) @@ -46,13 +46,6 @@ for (const language of youTubeLanguages) { youTube: language, freeTube: language }) - } else if (activeLanguages.includes(language.replace('-', '_'))) { - const withUnderScore = language.replace('-', '_') - foundLanguageNames.push(withUnderScore) - languagesToScrape.push({ - youTube: language, - freeTube: withUnderScore - }) } // special cases else if (language === 'de') { @@ -70,20 +63,44 @@ for (const language of youTubeLanguages) { } else if (language === 'no') { // according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes // "no" is the macro language for "nb" and "nn" - foundLanguageNames.push('nb_NO', 'nn') + foundLanguageNames.push('nb-NO', 'nn') languagesToScrape.push({ youTube: 'no', - freeTube: 'nb_NO' + freeTube: 'nb-NO' }) languagesToScrape.push({ youTube: 'no', freeTube: 'nn' }) + } else if (language === 'iw') { + // 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" + foundLanguageNames.push('he') + languagesToScrape.push({ + youTube: 'iw', + freeTube: 'he' + }) + } else if (language === 'es-419') { + foundLanguageNames.push('es-AR', 'es-MX') + languagesToScrape.push({ + youTube: 'es-419', + freeTube: 'es-AR' + }) + languagesToScrape.push({ + youTube: 'es-419', + freeTube: 'es-MX' + }) } else if (language !== 'en') { unusedYouTubeLanguageNames.push(language) } } +foundLanguageNames.push('pt-BR') +languagesToScrape.push({ + youTube: 'pt', + freeTube: 'pt-BR' +}) + console.log("Active FreeTube languages that aren't available on YouTube:") console.log(activeLanguages.filter(lang => !foundLanguageNames.includes(lang)).sort()) @@ -116,7 +133,7 @@ async function scrapeLanguage(youTubeLanguageCode) { } function processGeolocations(freeTubeLanguage, youTubeLanguage, response) { - const geolocations = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[1].multiPageMenuSectionRenderer.items[3].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items + const geolocations = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items[4].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items .map(({ compactLinkRenderer }) => { return { name: new Misc.Text(compactLinkRenderer.title).toString().trim(), diff --git a/_scripts/getShakaLocales.js b/_scripts/getShakaLocales.js new file mode 100644 index 0000000000000..76ed42b2633c1 --- /dev/null +++ b/_scripts/getShakaLocales.js @@ -0,0 +1,109 @@ +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.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..a064ce9b59758 --- /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/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000000..0105059384b8a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,234 @@ +import eslintPluginVue from 'eslint-plugin-vue' +import vuejsAccessibility from 'eslint-plugin-vuejs-accessibility' +import eslintPluginUnicorn from 'eslint-plugin-unicorn' +import eslintConfigPrettier from 'eslint-config-prettier' +import intlifyVueI18N from '@intlify/eslint-plugin-vue-i18n' +import globals from 'globals' +import vueEslintParser from 'vue-eslint-parser' +import js from '@eslint/js' +import { FlatCompat } from '@eslint/eslintrc' +import { fixupConfigRules } from '@eslint/compat' +import jsoncEslintParser from 'jsonc-eslint-parser' +import eslintPluginJsonc from 'eslint-plugin-jsonc' +import eslintPluginYml from 'eslint-plugin-yml' +import yamlEslintParser from 'yaml-eslint-parser' + +import activeLocales from './static/locales/activeLocales.json' with { type: 'json' } + +const compat = new FlatCompat({ + baseDirectory: import.meta.dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}) + +export default [ + { + ignores: [ + 'dist/', + 'eslint.config.mjs' + ] + }, + ...fixupConfigRules( + compat.config({ + extends: ['standard'] + }) + ), + js.configs.recommended, + eslintConfigPrettier, + ...eslintPluginVue.configs['flat/vue2-recommended'], + ...vuejsAccessibility.configs["flat/recommended"], + ...intlifyVueI18N.configs['flat/recommended'], + { + files: [ + '**/*.{js,vue}', + ], + ignores: [ + '_scripts/', + ], + plugins: { + unicorn: eslintPluginUnicorn, + }, + + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + + parser: vueEslintParser, + ecmaVersion: 2022, + sourceType: 'module', + }, + + settings: { + 'vue-i18n': { + localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, + messageSyntaxVersion: '^8.0.0', + }, + }, + + rules: { + 'space-before-function-paren': 'off', + 'comma-dangle': ['error', 'only-multiline'], + 'vue/no-v-html': 'off', + + 'no-console': ['error', { + allow: ['warn', 'error'], + }], + + 'no-unused-vars': 'warn', + 'no-undef': 'warn', + 'object-shorthand': 'off', + 'vue/no-template-key': 'warn', + 'vue/multi-word-component-names': 'off', + 'vuejs-accessibility/no-onchange': 'off', + + 'vuejs-accessibility/label-has-for': ['error', { + required: { + some: ['nesting', 'id'], + }, + }], + + 'vuejs-accessibility/no-static-element-interactions': 'off', + 'n/no-callback-literal': 'warn', + 'n/no-path-concat': 'warn', + 'unicorn/better-regex': 'error', + 'unicorn/no-array-push-push': 'error', + 'unicorn/prefer-keyboard-event-key': 'error', + 'unicorn/prefer-regexp-test': 'error', + 'unicorn/prefer-string-replace-all': 'error', + '@intlify/vue-i18n/no-dynamic-keys': 'error', + '@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error', + + '@intlify/vue-i18n/no-raw-text': ['error', { + attributes: { + '/.+/': [ + 'title', + 'aria-label', + 'aria-placeholder', + 'aria-roledescription', + 'aria-valuetext', + 'tooltip', + 'message', + ], + + input: ['placeholder', 'value'], + img: ['alt'], + }, + + ignoreText: ['-', '•', '/', 'YouTube', 'Invidious', 'FreeTube'], + }], + + '@intlify/vue-i18n/no-deprecated-tc': 'off', + 'vue/require-explicit-emits': 'error', + 'vue/no-unused-emit-declarations': 'error', + }, + }, + + ...eslintPluginJsonc.configs['flat/base'], + { + files: ['**/*.json'], + ignores: [ + '_scripts/', + ], + + languageOptions: { + parser: jsoncEslintParser, + }, + + rules: { + 'no-tabs': 'off', + 'comma-spacing': 'off', + 'no-irregular-whitespace': 'off', + }, + + settings: { + 'vue-i18n': { + localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, + messageSyntaxVersion: '^8.0.0', + }, + }, + }, + + ...eslintPluginYml.configs['flat/recommended'], + { + files: ['**/*.{yml,yaml}'], + ignores: [ + '.github/', + '_scripts/' + ], + + languageOptions: { + parser: yamlEslintParser, + }, + + rules: { + 'yml/no-irregular-whitespace': 'off', + }, + + settings: { + 'vue-i18n': { + localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, + messageSyntaxVersion: '^8.0.0', + }, + }, + }, + { + files: ['.github/**/*.{yml,yaml}'], + + languageOptions: { + parser: yamlEslintParser, + }, + + rules: { + 'yml/no-empty-mapping-value': 'off', + 'yml/no-irregular-whitespace': 'off', + }, + + settings: { + 'vue-i18n': { + localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`, + messageSyntaxVersion: '^8.0.0', + }, + }, + }, + { + files: ['_scripts/*.js'], + languageOptions: { + globals: { + ...globals.node + }, + ecmaVersion: 'latest', + }, + + plugins: { + unicorn: eslintPluginUnicorn, + }, + + rules: { + 'no-console': 'off', + 'n/no-path-concat': 'off', + 'unicorn/better-regex': 'error', + } + }, + { + files: ['_scripts/*.mjs'], + languageOptions: { + globals: { + ...globals.node, + }, + ecmaVersion: 'latest', + sourceType: 'module', + }, + + plugins: { + unicorn: eslintPluginUnicorn, + }, + + rules: { + 'no-console': 'off', + 'n/no-path-concat': 'off', + 'unicorn/better-regex': 'error', + } + } +] 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 66bbed4be5c99..64956715efd8f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "freetube", "productName": "FreeTube", "description": "A private YouTube client", - "version": "0.21.3", + "version": "0.22.0", "license": "AGPL-3.0-or-later", "main": "./dist/main.js", "private": true, @@ -19,107 +19,106 @@ "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", "lint-fix": "run-p eslint-lint-fix lint-style-fix", - "eslint-lint": "eslint --ext .js,.vue ./", - "eslint-lint-fix": "eslint --fix --ext .js,.vue ./", - "lint-json": "eslint --ext .json ./", + "eslint-lint": "eslint --config eslint.config.mjs \"./src/**/*.js\" \"./src/**/*.vue\" \"./static/**/*.js\" \"./_scripts/*.js\" \"./_scripts/*.mjs\"", + "eslint-lint-fix": "eslint --config eslint.config.mjs --fix \"./src/**/*.js\" \"./src/**/*.vue\" \"./static/**/*.js\" \"./_scripts/*.js\" \"./_scripts/*.mjs\"", + "lint-json": "eslint --config eslint.config.mjs \"./static/**/*.json\"", "lint-style": "stylelint \"**/*.{css,scss}\"", "lint-style-fix": "stylelint --fix \"**/*.{css,scss}\"", - "lint-yml": "eslint --ext .yml,.yaml ./", + "lint-yml": "eslint --config eslint.config.mjs \"./**/*.yml\" \"./**/*.yaml\"", "pack": "run-p pack:main pack:renderer && 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", - "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", "ci": "yarn install --silent --frozen-lockfile" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.5.2", - "@fortawesome/free-brands-svg-icons": "^6.5.2", - "@fortawesome/free-regular-svg-icons": "^6.5.2", - "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@fortawesome/free-brands-svg-icons": "^6.6.0", + "@fortawesome/free-regular-svg-icons": "^6.6.0", + "@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": "^13.0.2", + "marked": "^14.1.3", "path-browserify": "^1.0.1", "portal-vue": "^2.1.7", "process": "^0.11.10", - "swiper": "^11.1.4", - "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.11.10", + "swiper": "^11.1.14", "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.5.0" }, "devDependencies": { - "@babel/core": "^7.24.7", - "@babel/eslint-parser": "^7.24.7", - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/preset-env": "^7.24.7", + "@babel/core": "^7.25.8", + "@babel/eslint-parser": "^7.25.8", + "@babel/plugin-transform-class-properties": "^7.25.7", + "@babel/preset-env": "^7.25.8", "@double-great/stylelint-a11y": "^3.0.2", + "@eslint/compat": "^1.2.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.13.0", "@intlify/eslint-plugin-vue-i18n": "^3.0.0", - "babel-loader": "^9.1.3", + "babel-loader": "^9.2.1", "copy-webpack-plugin": "^12.0.2", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", - "electron": "^31.1.0", - "electron-builder": "^24.13.3", - "eslint": "^8.57.0", + "electron": "^32.2.2", + "electron-builder": "^25.1.8", + "eslint": "^9.11.1", "eslint-config-prettier": "^9.1.0", "eslint-config-standard": "^17.1.0", - "eslint-plugin-import": "^2.29.1", + "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsonc": "^2.16.0", - "eslint-plugin-n": "^17.9.0", - "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-promise": "^6.4.0", - "eslint-plugin-unicorn": "^54.0.0", - "eslint-plugin-vue": "^9.27.0", - "eslint-plugin-vuejs-accessibility": "^2.3.1", + "eslint-plugin-n": "^17.11.1", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-promise": "^7.1.0", + "eslint-plugin-unicorn": "^56.0.0", + "eslint-plugin-vue": "^9.29.1", + "eslint-plugin-vuejs-accessibility": "^2.4.1", "eslint-plugin-yml": "^1.14.0", - "html-webpack-plugin": "^5.6.0", + "globals": "^15.11.0", + "html-webpack-plugin": "^5.6.2", "js-yaml": "^4.1.0", "json-minimizer-webpack-plugin": "^5.0.0", - "lefthook": "^1.7.1", - "mini-css-extract-plugin": "^2.9.0", - "npm-run-all2": "^6.2.2", - "postcss": "^8.4.39", + "lefthook": "^1.7.22", + "mini-css-extract-plugin": "^2.9.1", + "npm-run-all2": "^6.2.4", + "postcss": "^8.4.47", "postcss-scss": "^4.0.9", "prettier": "^2.8.8", - "rimraf": "^5.0.8", - "sass": "^1.77.6", - "sass-loader": "^14.2.1", - "stylelint": "^16.6.1", - "stylelint-config-sass-guidelines": "^11.1.0", + "rimraf": "^6.0.1", + "sass": "^1.80.3", + "sass-loader": "^16.0.2", + "stylelint": "^16.10.0", + "stylelint-config-sass-guidelines": "^12.1.0", "stylelint-config-standard": "^36.0.1", "stylelint-high-performance-animation": "^1.10.0", "stylelint-use-logical-spec": "^5.0.1", @@ -127,9 +126,9 @@ "vue-devtools": "^5.1.4", "vue-eslint-parser": "^9.4.3", "vue-loader": "^15.10.0", - "webpack": "^5.92.1", + "webpack": "^5.95.0", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^5.0.4", + "webpack-dev-server": "^5.1.0", "yaml-eslint-parser": "^1.2.3" } } diff --git a/src/constants.js b/src/constants.js index 7fc27d8e342ff..f45e30fb8211d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -25,19 +25,21 @@ const IpcChannels = { DB_HISTORY: 'db-history', DB_PROFILES: 'db-profiles', DB_PLAYLISTS: 'db-playlists', + DB_SUBSCRIPTION_CACHE: 'db-subscription-cache', SYNC_SETTINGS: 'sync-settings', SYNC_HISTORY: 'sync-history', SYNC_PROFILES: 'sync-profiles', SYNC_PLAYLISTS: 'sync-playlists', + SYNC_SUBSCRIPTION_CACHE: 'sync-subscription-cache', 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 = { @@ -51,6 +53,7 @@ const DBActions = { }, HISTORY: { + OVERWRITE: 'db-action-history-overwrite', UPDATE_WATCH_PROGRESS: 'db-action-history-update-watch-progress', UPDATE_PLAYLIST: 'db-action-history-update-playlist', }, @@ -66,7 +69,15 @@ const DBActions = { DELETE_VIDEO_ID: 'db-action-playlists-delete-video-by-playlist-name', DELETE_VIDEO_IDS: 'db-action-playlists-delete-video-ids', DELETE_ALL_VIDEOS: 'db-action-playlists-delete-all-videos', - } + }, + + SUBSCRIPTION_CACHE: { + UPDATE_VIDEOS_BY_CHANNEL: 'db-action-subscriptions-update-videos-by-channel', + UPDATE_LIVE_STREAMS_BY_CHANNEL: 'db-action-subscriptions-update-live-streams-by-channel', + UPDATE_SHORTS_BY_CHANNEL: 'db-action-subscriptions-update-shorts-by-channel', + UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL: 'db-action-subscriptions-update-shorts-with-channel-page-shorts-by-channel', + UPDATE_COMMUNITY_POSTS_BY_CHANNEL: 'db-action-subscriptions-update-community-posts-by-channel', + }, } const SyncEvents = { @@ -78,6 +89,7 @@ const SyncEvents = { }, HISTORY: { + OVERWRITE: 'sync-history-overwrite', UPDATE_WATCH_PROGRESS: 'sync-history-update-watch-progress', UPDATE_PLAYLIST: 'sync-history-update-playlist', }, @@ -90,7 +102,15 @@ const SyncEvents = { PLAYLISTS: { UPSERT_VIDEO: 'sync-playlists-upsert-video', DELETE_VIDEO: 'sync-playlists-delete-video', - } + }, + + SUBSCRIPTION_CACHE: { + UPDATE_VIDEOS_BY_CHANNEL: 'sync-subscriptions-update-videos-by-channel', + UPDATE_LIVE_STREAMS_BY_CHANNEL: 'sync-subscriptions-update-live-streams-by-channel', + UPDATE_SHORTS_BY_CHANNEL: 'sync-subscriptions-update-shorts-by-channel', + UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL: 'sync-subscriptions-update-shorts-with-channel-page-shorts-by-channel', + UPDATE_COMMUNITY_POSTS_BY_CHANNEL: 'sync-subscriptions-update-community-posts-by-channel', + }, } // Utils diff --git a/src/datastores/handlers/base.js b/src/datastores/handlers/base.js index 4a7db5cbb8c3d..614b21cd290e6 100644 --- a/src/datastores/handlers/base.js +++ b/src/datastores/handlers/base.js @@ -1,7 +1,15 @@ import * as db from '../index' class Settings { - static find() { + static async find() { + const currentLocale = await db.settings.findOneAsync({ _id: 'currentLocale' }) + + // In FreeTube 0.21.3 and earlier the locales 'en-GB', 'es-AR' and 'nb-NO' had underscores instead of a hyphens + // This is a one time migration for users that are using one of those locales + if (currentLocale?.value.includes('_')) { + await this.upsert('currentLocale', currentLocale.value.replace('_', '-')) + } + return db.settings.findAsync({ _id: { $ne: 'bounds' } }) } @@ -56,6 +64,12 @@ class History { return db.history.updateAsync({ videoId: record.videoId }, record, { upsert: true }) } + static async overwrite(records) { + await db.history.removeAsync({}, { multi: true }) + + await db.history.insertAsync(records) + } + static updateWatchProgress(videoId, watchProgress) { return db.history.updateAsync({ videoId }, { $set: { watchProgress } }, { upsert: true }) } @@ -197,12 +211,92 @@ class Playlists { } } +class SubscriptionCache { + static find() { + return db.subscriptionCache.findAsync({}) + } + + static updateVideosByChannelId({ channelId, entries, timestamp }) { + return db.subscriptionCache.updateAsync( + { _id: channelId }, + { $set: { videos: entries, videosTimestamp: timestamp } }, + { upsert: true } + ) + } + + static updateLiveStreamsByChannelId({ channelId, entries, timestamp }) { + return db.subscriptionCache.updateAsync( + { _id: channelId }, + { $set: { liveStreams: entries, liveStreamsTimestamp: timestamp } }, + { upsert: true } + ) + } + + static updateShortsByChannelId({ channelId, entries, timestamp }) { + return db.subscriptionCache.updateAsync( + { _id: channelId }, + { $set: { shorts: entries, shortsTimestamp: timestamp } }, + { upsert: true } + ) + } + + static updateShortsWithChannelPageShortsByChannelId({ channelId, entries }) { + return db.subscriptionCache.findOneAsync({ _id: channelId }, { shorts: 1 }).then((doc) => { + if (doc == null) { return } + + const shorts = doc.shorts + const cacheShorts = Array.isArray(shorts) ? shorts : [] + + cacheShorts.forEach(cachedVideo => { + const channelVideo = entries.find(short => cachedVideo.videoId === short.videoId) + if (!channelVideo) { return } + + // authorId probably never changes, so we don't need to update that + cachedVideo.title = channelVideo.title + cachedVideo.author = channelVideo.author + + // as the channel shorts page only has compact view counts for numbers above 1000 e.g. 12k + // and the RSS feeds include an exact value, we only want to overwrite it when the number is larger than the cached value + // 12345 vs 12000 => 12345 + // 12345 vs 15000 => 15000 + + if (channelVideo.viewCount > cachedVideo.viewCount) { + cachedVideo.viewCount = channelVideo.viewCount + } + }) + + return db.subscriptionCache.updateAsync( + { _id: channelId }, + { $set: { shorts: cacheShorts } }, + { upsert: true } + ) + }) + } + + static updateCommunityPostsByChannelId({ channelId, entries, timestamp }) { + return db.subscriptionCache.updateAsync( + { _id: channelId }, + { $set: { communityPosts: entries, communityPostsTimestamp: timestamp } }, + { upsert: true } + ) + } + + static deleteMultipleChannels(channelIds) { + return db.subscriptionCache.removeAsync({ _id: { $in: channelIds } }, { multi: true }) + } + + static deleteAll() { + return db.subscriptionCache.removeAsync({}, { multi: true }) + } +} + function compactAllDatastores() { return Promise.allSettled([ db.settings.compactDatafileAsync(), db.history.compactDatafileAsync(), db.profiles.compactDatafileAsync(), db.playlists.compactDatafileAsync(), + db.subscriptionCache.compactDatafileAsync(), ]) } @@ -211,6 +305,7 @@ export { History as history, Profiles as profiles, Playlists as playlists, + SubscriptionCache as subscriptionCache, compactAllDatastores, } diff --git a/src/datastores/handlers/electron.js b/src/datastores/handlers/electron.js index cc0b473a3b990..889c91d4f060c 100644 --- a/src/datastores/handlers/electron.js +++ b/src/datastores/handlers/electron.js @@ -32,6 +32,13 @@ class History { ) } + static overwrite(records) { + return ipcRenderer.invoke( + IpcChannels.DB_HISTORY, + { action: DBActions.HISTORY.OVERWRITE, data: records } + ) + } + static updateWatchProgress(videoId, watchProgress) { return ipcRenderer.invoke( IpcChannels.DB_HISTORY, @@ -211,9 +218,83 @@ class Playlists { } } +class SubscriptionCache { + static find() { + return ipcRenderer.invoke( + IpcChannels.DB_SUBSCRIPTION_CACHE, + { action: DBActions.GENERAL.FIND } + ) + } + + static updateVideosByChannelId({ channelId, entries, timestamp }) { + return ipcRenderer.invoke( + IpcChannels.DB_SUBSCRIPTION_CACHE, + { + action: DBActions.SUBSCRIPTION_CACHE.UPDATE_VIDEOS_BY_CHANNEL, + data: { channelId, entries, timestamp }, + } + ) + } + + static updateLiveStreamsByChannelId({ channelId, entries, timestamp }) { + return ipcRenderer.invoke( + IpcChannels.DB_SUBSCRIPTION_CACHE, + { + action: DBActions.SUBSCRIPTION_CACHE.UPDATE_LIVE_STREAMS_BY_CHANNEL, + data: { channelId, entries, timestamp }, + } + ) + } + + static updateShortsByChannelId({ channelId, entries, timestamp }) { + return ipcRenderer.invoke( + IpcChannels.DB_SUBSCRIPTION_CACHE, + { + action: DBActions.SUBSCRIPTION_CACHE.UPDATE_SHORTS_BY_CHANNEL, + data: { channelId, entries, timestamp }, + } + ) + } + + static updateShortsWithChannelPageShortsByChannelId({ channelId, entries }) { + return ipcRenderer.invoke( + IpcChannels.DB_SUBSCRIPTION_CACHE, + { + action: DBActions.SUBSCRIPTION_CACHE.UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL, + data: { channelId, entries }, + } + ) + } + + static updateCommunityPostsByChannelId({ channelId, entries, timestamp }) { + return ipcRenderer.invoke( + IpcChannels.DB_SUBSCRIPTION_CACHE, + { + action: DBActions.SUBSCRIPTION_CACHE.UPDATE_COMMUNITY_POSTS_BY_CHANNEL, + data: { channelId, entries, timestamp }, + } + ) + } + + static deleteMultipleChannels(channelIds) { + return ipcRenderer.invoke( + IpcChannels.DB_SUBSCRIPTION_CACHE, + { action: DBActions.GENERAL.DELETE_MULTIPLE, data: channelIds } + ) + } + + static deleteAll() { + return ipcRenderer.invoke( + IpcChannels.DB_SUBSCRIPTION_CACHE, + { action: DBActions.GENERAL.DELETE_ALL } + ) + } +} + export { Settings as settings, History as history, Profiles as profiles, - Playlists as playlists + Playlists as playlists, + SubscriptionCache as subscriptionCache, } diff --git a/src/datastores/handlers/index.js b/src/datastores/handlers/index.js index df6ebffd9d808..6d9b8ab729d43 100644 --- a/src/datastores/handlers/index.js +++ b/src/datastores/handlers/index.js @@ -2,5 +2,6 @@ export { settings as DBSettingHandlers, history as DBHistoryHandlers, profiles as DBProfileHandlers, - playlists as DBPlaylistHandlers + playlists as DBPlaylistHandlers, + subscriptionCache as DBSubscriptionCacheHandlers, } from 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB' diff --git a/src/datastores/handlers/web.js b/src/datastores/handlers/web.js index 93ffa3d68c8ff..d68e24f042615 100644 --- a/src/datastores/handlers/web.js +++ b/src/datastores/handlers/web.js @@ -29,6 +29,10 @@ class History { return baseHandlers.history.upsert(record) } + static overwrite(records) { + return baseHandlers.history.overwrite(records) + } + static updateWatchProgress(videoId, watchProgress) { return baseHandlers.history.updateWatchProgress(videoId, watchProgress) } @@ -118,9 +122,63 @@ class Playlists { } } +class SubscriptionCache { + static find() { + return baseHandlers.subscriptionCache.find() + } + + static updateVideosByChannelId({ channelId, entries, timestamp }) { + return baseHandlers.subscriptionCache.updateVideosByChannelId({ + channelId, + entries, + timestamp, + }) + } + + static updateLiveStreamsByChannelId({ channelId, entries, timestamp }) { + return baseHandlers.subscriptionCache.updateLiveStreamsByChannelId({ + channelId, + entries, + timestamp, + }) + } + + static updateShortsByChannelId({ channelId, entries, timestamp }) { + return baseHandlers.subscriptionCache.updateShortsByChannelId({ + channelId, + entries, + timestamp, + }) + } + + static updateShortsWithChannelPageShortsByChannelId({ channelId, entries }) { + return baseHandlers.subscriptionCache.updateShortsWithChannelPageShortsByChannelId({ + channelId, + entries, + }) + } + + static updateCommunityPostsByChannelId({ channelId, entries, timestamp }) { + return baseHandlers.subscriptionCache.updateCommunityPostsByChannelId({ + channelId, + entries, + timestamp, + }) + } + + static deleteMultipleChannels(channelIds) { + return baseHandlers.subscriptionCache.deleteMultipleChannels(channelIds) + } + + static deleteAll() { + return baseHandlers.subscriptionCache.deleteAll() + } +} + export { Settings as settings, History as history, Profiles as profiles, - Playlists as playlists + Playlists as playlists, + SubscriptionCache as subscriptionCache, } diff --git a/src/datastores/index.js b/src/datastores/index.js index 442fed6497bfb..7a3da53356f00 100644 --- a/src/datastores/index.js +++ b/src/datastores/index.js @@ -26,3 +26,4 @@ export const settings = new Datastore({ filename: dbPath('settings'), autoload: export const profiles = new Datastore({ filename: dbPath('profiles'), autoload: true }) export const playlists = new Datastore({ filename: dbPath('playlists'), autoload: true }) export const history = new Datastore({ filename: dbPath('history'), autoload: true }) +export const subscriptionCache = new Datastore({ filename: dbPath('subscription-cache'), autoload: true }) diff --git a/src/main/index.js b/src/main/index.js index d09bc6e98f1d8..b6143082be07d 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 @@ -94,7 +87,7 @@ function runApp() { const path = urlParts[1] if (path) { - visible = ['/channel', '/watch'].some(p => path.startsWith(p)) || + visible = ['/channel', '/watch', '/hashtag', '/post'].some(p => path.startsWith(p)) || // Only show copy link entry for non user playlists (path.startsWith('/playlist') && !/playlistType=user/.test(path)) } @@ -131,6 +124,8 @@ function runApp() { return `${origin}/playlist?list=${id}` case 'channel': return `${origin}/channel/${id}` + case 'hashtag': + return `${origin}/hashtag/${id}` case 'watch': { let url @@ -162,6 +157,21 @@ function runApp() { return url.toString() } + case 'post': { + if (query) { + const authorId = new URLSearchParams(query).get('authorId') + + if (authorId) { + if (toYouTube) { + return `${origin}/channel/${authorId}/community?lb=${id}` + } else { + return `${origin}/post/${id}?ucid=${authorId}` + } + } + } + + return `${origin}/post/${id}` + } } } @@ -199,9 +209,11 @@ function runApp() { let startupUrl if (process.platform === 'linux') { - // Enable hardware acceleration via VA-API + // Enable hardware acceleration via VA-API with OpenGL if no other feature flags are found // https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/gpu/vaapi.md - app.commandLine.appendSwitch('enable-features', 'VaapiVideoDecodeLinuxGL') + if (!app.commandLine.hasSwitch('enable-features')) { + app.commandLine.appendSwitch('enable-features', 'VaapiVideoDecodeLinuxGL') + } } const userDataPath = app.getPath('userData') @@ -399,15 +411,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 @@ -426,44 +442,20 @@ 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. - - // 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) + } else if (webContents) { + const invidiousAuthorization = invidiousAuthorizations.get(webContents.id) - 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 }) }) @@ -474,7 +466,7 @@ function runApp() { if (responseHeaders) { delete responseHeaders['set-cookie'] } - // eslint-disable-next-line n/no-callback-literal + callback({ responseHeaders }) }) @@ -484,8 +476,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) @@ -495,9 +489,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: @@ -544,19 +551,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` --- @@ -590,8 +598,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': @@ -631,6 +639,8 @@ function runApp() { return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' } + // Determine window color to be shown (shown most prominently during initial app load) + // Uses the --bg-color for each corresponding theme switch (setting.value) { case 'dark': return '#212121' @@ -680,7 +690,9 @@ function runApp() { webSecurity: false, backgroundThrottling: false, contextIsolation: false - } + }, + minWidth: 340, + minHeight: 380 } const newWindow = new BrowserWindow( @@ -747,7 +759,7 @@ function runApp() { // If called multiple times // Duplicate menu items will be added if (replaceMainWindow) { - // eslint-disable-next-line + setMenu() } @@ -992,12 +1004,6 @@ function runApp() { try { const contents = await asyncFs.readFile(filePath) - // Probably a corrupted/broken cache entry, pretend it's absent - // A valid entry should be a few KB large - if (contents.byteLength < 500) { - return undefined - } - return contents.buffer } catch (e) { console.error(e) @@ -1013,6 +1019,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 // *********** // @@ -1073,6 +1094,15 @@ function runApp() { ) return null + case DBActions.HISTORY.OVERWRITE: + await baseHandlers.history.overwrite(data) + syncOtherWindows( + IpcChannels.SYNC_HISTORY, + event, + { event: SyncEvents.HISTORY.OVERWRITE, data } + ) + return null + case DBActions.HISTORY.UPDATE_WATCH_PROGRESS: await baseHandlers.history.updateWatchProgress(data.videoId, data.watchProgress) syncOtherWindows( @@ -1289,6 +1319,89 @@ function runApp() { // *********** // + // *********** // + // Profiles + ipcMain.handle(IpcChannels.DB_SUBSCRIPTION_CACHE, async (event, { action, data }) => { + try { + switch (action) { + case DBActions.GENERAL.FIND: + return await baseHandlers.subscriptionCache.find() + + case DBActions.SUBSCRIPTION_CACHE.UPDATE_VIDEOS_BY_CHANNEL: + await baseHandlers.subscriptionCache.updateVideosByChannelId(data) + syncOtherWindows( + IpcChannels.SYNC_SUBSCRIPTION_CACHE, + event, + { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_VIDEOS_BY_CHANNEL, data } + ) + return null + + case DBActions.SUBSCRIPTION_CACHE.UPDATE_LIVE_STREAMS_BY_CHANNEL: + await baseHandlers.subscriptionCache.updateLiveStreamsByChannelId(data) + syncOtherWindows( + IpcChannels.SYNC_SUBSCRIPTION_CACHE, + event, + { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_LIVE_STREAMS_BY_CHANNEL, data } + ) + return null + + case DBActions.SUBSCRIPTION_CACHE.UPDATE_SHORTS_BY_CHANNEL: + await baseHandlers.subscriptionCache.updateShortsByChannelId(data) + syncOtherWindows( + IpcChannels.SYNC_SUBSCRIPTION_CACHE, + event, + { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_SHORTS_BY_CHANNEL, data } + ) + return null + + case DBActions.SUBSCRIPTION_CACHE.UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL: + await baseHandlers.subscriptionCache.updateShortsWithChannelPageShortsByChannelId(data) + syncOtherWindows( + IpcChannels.SYNC_SUBSCRIPTION_CACHE, + event, + { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_SHORTS_WITH_CHANNEL_PAGE_SHORTS_BY_CHANNEL, data } + ) + return null + + case DBActions.SUBSCRIPTION_CACHE.UPDATE_COMMUNITY_POSTS_BY_CHANNEL: + await baseHandlers.subscriptionCache.updateCommunityPostsByChannelId(data) + syncOtherWindows( + IpcChannels.SYNC_SUBSCRIPTION_CACHE, + event, + { event: SyncEvents.SUBSCRIPTION_CACHE.UPDATE_COMMUNITY_POSTS_BY_CHANNEL, data } + ) + return null + + case DBActions.GENERAL.DELETE_MULTIPLE: + await baseHandlers.subscriptionCache.deleteMultipleChannels(data) + syncOtherWindows( + IpcChannels.SYNC_SUBSCRIPTION_CACHE, + event, + { event: SyncEvents.GENERAL.DELETE_MULTIPLE, data } + ) + return null + + case DBActions.GENERAL.DELETE_ALL: + await baseHandlers.subscriptionCache.deleteAll() + syncOtherWindows( + IpcChannels.SYNC_SUBSCRIPTION_CACHE, + event, + { event: SyncEvents.GENERAL.DELETE_ALL, data } + ) + return null + + default: + // eslint-disable-next-line no-throw-literal + throw 'invalid subscriptionCache db action' + } + } catch (err) { + if (typeof err === 'string') throw err + else throw err.toString() + } + }) + + // *********** // + function syncOtherWindows(channel, event, payload) { const otherWindows = BrowserWindow.getAllWindows().filter((window) => { return window.webContents.id !== event.sender.id @@ -1378,6 +1491,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 @@ -1517,6 +1636,19 @@ function runApp() { } } }, + { + label: 'GPU Internals (chrome://gpu)', + click() { + const gpuWindow = new BrowserWindow({ + show: true, + autoHideMenuBar: true, + webPreferences: { + devTools: false + } + }) + gpuWindow.loadURL('chrome://gpu') + } + }, { type: 'separator' }, { role: 'resetzoom' }, { role: 'resetzoom', accelerator: 'CmdOrCtrl+num0', visible: false }, @@ -1534,7 +1666,7 @@ function runApp() { click: (_menuItem, browserWindow, _event) => { if (browserWindow == null) { return } - browserWindow.webContents.goBack() + browserWindow.webContents.navigationHistory.goBack() }, type: 'normal', }, @@ -1544,7 +1676,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.css b/src/renderer/App.css index 28607f039e3ff..866c01197bf6f 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -1,6 +1,6 @@ @font-face { font-family: Roboto; - src: url("assets/font/Roboto-Regular.ttf"); + src: url('assets/font/Roboto-Regular.ttf'); } .app { @@ -45,11 +45,13 @@ overflow-wrap: break-word; } -.fade-enter-active, .fade-leave-active { - transition: opacity .15s; +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.15s; } -.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { +.fade-enter, +.fade-leave-to /* .fade-leave-active below version 2.1.8 */ { opacity: 0; } diff --git a/src/renderer/App.js b/src/renderer/App.js index e2ecc75e4140b..6f29f4e2a7c42 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -83,7 +83,7 @@ export default defineComponent({ windowTitle: function () { const routePath = this.$route.path if (!routePath.startsWith('/channel/') && !routePath.startsWith('/watch/') && !routePath.startsWith('/hashtag/') && !routePath.startsWith('/playlist/')) { - let title = translateWindowTitle(this.$route.meta.title, this.$i18n) + let title = translateWindowTitle(this.$route.meta.title) if (!title) { title = packageDetails.productName } else { @@ -122,7 +122,7 @@ export default defineComponent({ }, locale: function() { - return this.$i18n.locale.replace('_', '-') + return this.$i18n.locale }, systemTheme: function () { @@ -178,6 +178,7 @@ export default defineComponent({ this.grabAllProfiles(this.$t('Profile.All Channels')).then(async () => { this.grabHistory() this.grabAllPlaylists() + this.grabAllSubscriptions() if (process.env.IS_ELECTRON) { ipcRenderer = require('electron').ipcRenderer @@ -453,6 +454,17 @@ export default defineComponent({ break } + case 'post': { + const { postId, query } = result + + openInternalPath({ + path: `/post/${postId}`, + query, + doCreateNewWindow + }) + break + } + case 'channel': { const { channelId, subPath, url } = result @@ -541,6 +553,7 @@ export default defineComponent({ 'grabAllProfiles', 'grabHistory', 'grabAllPlaylists', + 'grabAllSubscriptions', 'getYoutubeUrlInfo', 'getExternalPlayerCmdArgumentsData', 'fetchInvidiousInstances', diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 759c91240926e..a0235a5854898 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -23,6 +23,7 @@ > @@ -110,5 +111,4 @@ + +