diff --git a/.babelrc b/.babelrc index abf4798b246eb..bca0e3c2c303e 100644 --- a/.babelrc +++ b/.babelrc @@ -4,8 +4,8 @@ "@babel/env", { "targets": { - "chrome": "106", - "node": "16.16.0" + "chrome": "122", + "node": "20.9.0" } } ] diff --git a/.eslintrc.js b/.eslintrc.js index 2729411b7e10b..f342b62dd1104 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,8 @@ +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, @@ -47,11 +52,12 @@ module.exports = { 'plugin:vue/recommended', 'standard', 'plugin:jsonc/recommended-with-json', - 'plugin:vuejs-accessibility/recommended' + 'plugin:vuejs-accessibility/recommended', + 'plugin:@intlify/vue-i18n/recommended' ], // https://eslint.org/docs/user-guide/configuring#configuring-plugins - plugins: ['vue', 'vuejs-accessibility', 'n', 'unicorn'], + plugins: ['vue', 'vuejs-accessibility', 'n', 'unicorn', '@intlify/vue-i18n'], rules: { 'space-before-function-paren': 'off', @@ -77,6 +83,42 @@ module.exports = { 'unicorn/no-array-push-push': 'error', 'unicorn/prefer-keyboard-event-key': 'error', 'unicorn/prefer-regexp-test': 'error', - 'unicorn/prefer-string-replace-all': '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'] + } + ], + '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/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index c81ab531cd995..aef914ee3f668 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -80,26 +80,29 @@ body: - type: dropdown attributes: label: Installation Method + description: When you select an unofficial installation method, you must have verified that the bug is also present in one of the official installation methods. Please make sure you uninstall the unofficial installation before installing one of the official installations. If you can't reproduce this in one of the official installation methods, you should report the bug to the maintainer of the unofficial installation method you used. options: - .apk (Alpine Linux Package) - - .apk (Android, FreeTubeCordova) - .AppImage - - AUR - - Chocolatey - .deb - .dmg - .exe - Flathub - - MPR - - Nix - .pacman - Portable - - PortableApps - .rpm - - Scoop - - Snapcraft - - winget - .zip + - .apk (FreeTubeAndroid Unofficial) + - AUR (Unofficial) + - Chocolatey (Unofficial) + - Homebrew (Unofficial) + - MPR (Unofficial) + - Nix (Unofficial) + - PortableApps (Unofficial) + - Scoop (Unofficial) + - Snapcraft (Unofficial) + - WAPT (Unofficial) + - winget (Unofficial) - other validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3ba13e0cec6cb..65a5c0f0ce04e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,17 @@ blank_issues_enabled: false +contact_links: + - name: Discussions + url: https://github.com/FreeTubeApp/FreeTube/discussions/categories/general + about: View discussions or start one yourself + - name: Questions + url: https://github.com/FreeTubeApp/FreeTube/discussions/categories/q-a + about: Ask and answer questions + - name: Matrix Community + url: https://matrix.to/#/+freetube:matrix.org + about: 'Join our Matrix chatroom - "Note: Bugs and Feature requests should be made on GitHub and not in the Matrix room"' + - name: Translate FreeTube + url: https://hosted.weblate.org/engage/free-tube/ + about: Help translate FreeTube on Weblate + - name: FreeTube Documentation + url: https://docs.freetubeapp.io/ + about: View the Documentation to find all relevant information about FreeTube diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6fbd40c95d04f..cb49b32397ef2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,27 @@ updates: - "PR: waiting for review" - "PR: dependencies" open-pull-requests-limit: 15 + groups: + babel: + patterns: + - "@babel/*" + - "babel-*" + eslint: + patterns: + - "eslint" + - "eslint-*" + - "yaml-eslint-parser" + - "vue-eslint-parser" + stylelint: + patterns: + - "stylelint" + - "stylelint-*" + - "postcss" + - "postcss-*" + - "@double-great/stylelint-a11y" + fortawesome: + patterns: + - "@fortawesome/*" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml new file mode 100644 index 0000000000000..38178571e5d68 --- /dev/null +++ b/.github/issue-labeler.yml @@ -0,0 +1,65 @@ +'B: visual': + - '(visual bug)' + +'B: Unofficial Download': + - '(AUR \(Unofficial\)|Chocolatey \(Unofficial\)|\.apk \(FreeTubeAndroid Unofficial\)|Homebrew \(Unofficial\)|PortableApps \(Unofficial\)|WAPT \(Unofficial\)|winget \(Unofficial\)|Scoop \(Unofficial\)|Snapcraft \(Unofficial\)|MPR \(Unofficial\)|Nix \(Unofficial\))' + +'B: keyboard control': + - '(keyboard control not working)' + +'B: text/string': + - '(text/string issue)' + +'B: content not loading': + - '(content not loading)' + +'B: accessibility': + - '(accessibility issue)' + +'B: usability': + - '(usability issue)' + +'B: crash': + - '(causes crash)' + +'B: feature stopped working': + - '(feature stopped working)' + +'B: inconsistent behavior': + - '(inconsistent behavior)' + +'B: data loss': + - '(data loss)' + +'B: race condition': + - '(race condition)' + +'B: API issue': + - '(API issue)' + +'B: developer mode': + - '(only happens in developer mode)' + +'E: improvement existing feature': + - '(improvement to existing feature)' + +'E: new optional setting': + - '(new optional setting)' + +'E: visual improvement': + - '(visual improvement)' + +'E: display more information': + - '(display more information to user)' + +'E: ease of use improvement': + - '(ease of use improvement)' + +'E: support external software': + - '(support for external software)' + +'E: new feature': + - '(new feature)' + +'E: keyboard shortcut': + - '(new keyboard shortcut)' diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index dff087046453f..0000000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,19 +0,0 @@ -'PR: waiting for review': - - '*' - - '.babelrc' - - '.editorconfig' - - '.eslintignore' - - '.eslintrc.js' - - '.gitignore' - - '.prettierrc' - - '.whitesource' - - '.github/**/*' - - '.vscode/**/*' - - '_icons/**/*' - - '_scripts/**/*' - - 'src/**/*' - - 'static/**/*' - -'PR: dependencies': - - 'yarn.lock' - - 'package.json' diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml new file mode 100644 index 0000000000000..a6f19b9ca8c3c --- /dev/null +++ b/.github/pr-labeler.yml @@ -0,0 +1,8 @@ +'PR: waiting for review': +- changed-files: + - any-glob-to-any-file: '**' + +'PR: dependencies': +- any: + - changed-files: + - any-glob-to-any-file: ['yarn.lock', 'package.json'] diff --git a/.github/workflows/autoLabelDuplicate.yml b/.github/workflows/autoLabelDuplicate.yml index 4bbbc9b4cf622..56fa8681e7074 100644 --- a/.github/workflows/autoLabelDuplicate.yml +++ b/.github/workflows/autoLabelDuplicate.yml @@ -5,10 +5,10 @@ on: jobs: test: + if: github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER' runs-on: ubuntu-latest steps: - name: Check Comment Author - if: github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER' uses: Amwam/issue-comment-action@v1.3.1 with: keywords: '["duplicate of #", "duplicate of https://github.com/FreeTubeApp/FreeTube/issues/", "duplicate of https://github.com/FreeTubeApp/FreeTube/pulls/"]' diff --git a/.github/workflows/autoLabelIssue.yaml b/.github/workflows/autoLabelIssue.yaml deleted file mode 100644 index d0b61a4549111..0000000000000 --- a/.github/workflows/autoLabelIssue.yaml +++ /dev/null @@ -1,104 +0,0 @@ -name: "Set Issue Label and Assignee" -on: - issues: - types: [opened] - -jobs: - label_issue: - runs-on: ubuntu-latest - steps: - - uses: Naturalclar/issue-action@v2.0.2 - with: - title-or-body: "body" - parameters: >- - [ - { - "keywords": ["visual bug"], - "labels": ["B: visual"] - }, - { - "keywords": ["AUR", "Chocolatey", "FreeTubeCordova", "PortableApps", "winget", "Scoop", "Snapcraft", "MPR", "Nix"], - "labels": ["B: Unofficial Download"] - }, - { - "keywords": ["keyboard control not working"], - "labels": ["B: keyboard control"] - }, - { - "keywords": ["text/string issue"], - "labels": ["B: text/string"] - }, - { - "keywords": ["content not loading"], - "labels": ["B: content not loading"] - }, - { - "keywords": ["accessibility issue"], - "labels": ["B: accessibility"] - }, - { - "keywords": ["usability issue"], - "labels": ["B: usability"] - }, - { - "keywords": ["causes crash"], - "labels": ["B: crash"] - }, - { - "keywords": ["feature stopped working"], - "labels": ["B: feature stopped working"] - }, - { - "keywords": ["inconsistent behavior"], - "labels": ["B: inconsistent behavior"] - }, - { - "keywords": ["data loss"], - "labels": ["B: data loss"] - }, - { - "keywords": ["race condition"], - "labels": ["B: race condition"] - }, - { - "keywords": ["API issue"], - "labels": ["B: API issue"] - }, - { - "keywords": ["only happens in developer mode"], - "labels": ["B: developer mode"] - }, - { - "keywords": ["improvement to existing feature"], - "labels": ["E: improvement existing feature"] - }, - { - "keywords": ["new optional setting"], - "labels": ["E: new optional setting"] - }, - { - "keywords": ["visual improvement"], - "labels": ["E: visual improvement"] - }, - { - "keywords": ["display more information to user"], - "labels": ["E: display more information"] - }, - { - "keywords": ["ease of use improvement"], - "labels": ["E: ease of use improvement"] - }, - { - "keywords": ["support for external software"], - "labels": ["E: support external software"] - }, - { - "keywords": ["new feature"], - "labels": ["E: new feature"] - }, - { - "keywords": ["new keyboard shortcut"], - "labels": ["E: keyboard shortcut"] - } - ] - github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/autoMerge.yml b/.github/workflows/autoMerge.yml index c979c741475ad..db334ad2232fb 100644 --- a/.github/workflows/autoMerge.yml +++ b/.github/workflows/autoMerge.yml @@ -5,11 +5,11 @@ on: jobs: build: + if: ${{ !github.event.pull_request.draft && (contains(github.event.pull_request.base.ref, 'development') || contains(github.event.pull_request.base.ref, 'RC')) }} runs-on: ubuntu-latest steps: - name: Auto Merge PR - if: ${{ !github.event.pull_request.draft && (contains(github.event.pull_request.base.ref, 'development') || contains(github.event.pull_request.base.ref, 'RC')) }} run: | echo ${{ secrets.PUSH_TOKEN }} >> auth.txt gh auth login --with-token < auth.txt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6a14f1678372..3bbef663018de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: build: strategy: matrix: - node-version: [18.x] + node-version: [20.x] runtime: - linux-x64 - linux-armv7l @@ -48,9 +48,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "yarn" @@ -64,7 +64,7 @@ jobs: - name: Set Version Number Variable id: versionNumber - uses: actions/github-script@v6 + uses: actions/github-script@v7 env: IS_DEV: ${{ contains(github.ref, 'development') }} IS_RC: ${{ contains(github.ref, 'RC') }} @@ -108,91 +108,91 @@ jobs: run: yarn run build:arm64 - name: Upload Linux .zip x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_x64 path: build/freetube-${{ steps.versionNumber.outputs.result }}.zip - name: Upload Linux .7z x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_x64.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}.7z - name: Upload Linux .zip ARMv7l Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_armv7l path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.zip - name: Upload Linux .7z ARMv7l Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_armv7l.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.7z - name: Upload Linux .zip ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_arm64 path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.zip - name: Upload Linux .7z ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_arm64.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.7z - name: Upload .deb x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.deb path: build/freetube_${{ steps.versionNumber.outputs.result }}_amd64.deb - name: Upload .deb ARMv7l Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.result }}_armv7l.deb path: build/freetube_${{ steps.versionNumber.outputs.result }}_armv7l.deb - name: Upload .deb ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_arm64.deb path: build/freetube_${{ steps.versionNumber.outputs.result }}_arm64.deb - name: Upload AppImage x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.AppImage path: build/FreeTube-${{ steps.versionNumber.outputs.result }}.AppImage - name: Upload AppImage ARMv7l Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.result }}_armv7l.AppImage path: build/FreeTube-${{ steps.versionNumber.outputs.result }}-armv7l.AppImage - name: Upload AppImage ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_arm64.AppImage path: build/FreeTube-${{ steps.versionNumber.outputs.result }}-arm64.AppImage - name: Upload .rpm x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.rpm @@ -201,133 +201,133 @@ jobs: # rpm are not built for armv7l - name: Upload .rpm ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_arm64.rpm path: build/freetube-${{ steps.versionNumber.outputs.result }}.aarch64.rpm - name: Upload Alpine .apk x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_amd64.apk path: build/freetube-${{ steps.versionNumber.outputs.result }}.apk - name: Upload Alpine .apk ARMv7l Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_armv7l.apk path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.apk - name: Upload Alpine .apk ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_arm64.apk path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.apk - name: Upload Pacman .pacman x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.pacman path: build/freetube-${{ steps.versionNumber.outputs.result }}.pacman # - name: Upload Web Build - # uses: actions/upload-artifact@v3 + # uses: actions/upload-artifact@v4 # if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') # with: # name: freetube_${{ steps.versionNumber.outputs.result }}_static_web # path: dist/web - name: Upload Windows x64 .exe Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-setup-x64.exe path: build/freetube Setup ${{ steps.versionNumber.outputs.result }}.exe - name: Upload Windows arm64 .exe Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-setup-arm64.exe path: build/freetube Setup ${{ steps.versionNumber.outputs.result }}.exe - name: Upload Windows x64 .zip Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-win-x64-portable path: build/freetube-${{ steps.versionNumber.outputs.result }}-win.zip - name: Upload Windows x64 .7z Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-win-x64-portable.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}-win.7z - name: Upload Windows arm64 .zip Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-win-arm64-portable path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64-win.zip - name: Upload Windows arm64 .7z Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-win-arm64-portable.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64-win.7z - name: Upload Windows x64 Portable Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-portable-x64.exe path: build/freetube ${{ steps.versionNumber.outputs.result }}.exe - name: Upload Windows arm64 Portable Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-portable-arm64.exe path: build/freetube ${{ steps.versionNumber.outputs.result }}.exe - name: Upload Mac x64 .dmg Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.dmg path: build/freetube-${{ steps.versionNumber.outputs.result }}.dmg # - name: Upload Mac arm64 .dmg Artifact -# uses: actions/upload-artifact@v3 +# uses: actions/upload-artifact@v4 # if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') # with: # name: freetube-${{ steps.versionNumber.outputs.result }}-mac-arm64.dmg # path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.dmg - name: Upload Mac x64 .zip Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.zip path: build/freetube-${{ steps.versionNumber.outputs.result }}-mac.zip - name: Upload Mac x64 .7z Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}-mac.7z # - name: Upload Mac arm64 .zip Artifact -# uses: actions/upload-artifact@v3 +# uses: actions/upload-artifact@v4 # if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') # with: # name: freetube-${{ steps.versionNumber.outputs.result }}-mac-arm64.zip diff --git a/.github/workflows/calibreapp-image-actions.yml b/.github/workflows/calibreapp-image-actions.yml index bf2ed2886edcc..d336cad2328ab 100644 --- a/.github/workflows/calibreapp-image-actions.yml +++ b/.github/workflows/calibreapp-image-actions.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Compress Images id: calibre uses: calibreapp/image-actions@main @@ -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@v5 + uses: peter-evans/create-pull-request@v6 with: title: Compressed Images Nightly branch-suffix: timestamp diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f7c01e4fa4aae..521082d630464 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,11 +27,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -45,7 +45,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -58,6 +58,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 74d4a0f475882..9f926e9737198 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: repository: flathub/io.freetubeapp.FreeTube token: ${{ secrets.FLATHUB_TOKEN }} @@ -36,7 +36,7 @@ jobs: - name: Install xmlstarlet run: sudo apt -y install xmlstarlet - name: Create Version Variable - uses: bluwy/substitute-string-action@v2 + uses: bluwy/substitute-string-action@v3 id: sub with: _input-text: ${{ fromJson(steps.api_results.outputs.result).tag_name }} @@ -103,7 +103,7 @@ jobs: rm freetube-${{ steps.sub.outputs.result }}-linux-portable-x64.zip rm freetube-${{ steps.sub.outputs.result }}-linux-portable-arm64.zip - name: Commit Files - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v5 with: # Optional but recommended # Defaults to "Apply automatic changes" diff --git a/.github/workflows/label-issue.yml b/.github/workflows/label-issue.yml new file mode 100644 index 0000000000000..24e42dc04f3c9 --- /dev/null +++ b/.github/workflows/label-issue.yml @@ -0,0 +1,18 @@ +name: "Issue Labeler" +on: + issues: + types: [opened] + +permissions: + issues: write + contents: read + +jobs: + triage: + runs-on: ubuntu-latest + steps: + - uses: github/issue-labeler@v3.4 + with: + configuration-path: .github/issue-labeler.yml + enable-versioned-regex: 0 + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index dc2ce54205e5b..5c9c9927693d3 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -11,6 +11,7 @@ jobs: runs-on: ubuntu-latest if: ${{ !github.event.pull_request.draft }} steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" + configuration-path: .github/pr-labeler.yml diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index fa58643dd86d3..962a5f55de4c6 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -17,11 +17,15 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: - - uses: actions/checkout@v3 - - name: Use Node.js 18.x - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - name: Use Node.js 20.x + uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x cache: "yarn" - run: yarn run ci - run: yarn run lint + # let's verify that webpack is able to package the project + - run: yarn run pack + # verify that webpack is able to package the project using the web config + - run: yarn run pack:web diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2b08ad6e3e3b..87237a364258d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: build: strategy: matrix: - node-version: [18.x] + node-version: [20.x] runtime: - linux-x64 - linux-armv7l @@ -48,9 +48,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "yarn" diff --git a/.github/workflows/remove-outdated-labels.yml b/.github/workflows/remove-outdated-labels.yml index c72ce19e1e2d4..8ea7ee1d48d40 100644 --- a/.github/workflows/remove-outdated-labels.yml +++ b/.github/workflows/remove-outdated-labels.yml @@ -11,7 +11,7 @@ jobs: if: github.event.pull_request.merged runs-on: ubuntu-latest steps: - - uses: mondeja/remove-labels-gh-action@v1.1.1 + - uses: mondeja/remove-labels-gh-action@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} labels: | @@ -27,7 +27,7 @@ jobs: if: github.event_name == 'pull_request_target' && (! github.event.pull_request.merged) && (github.event.action != 'converted_to_draft') && (github.event.action != 'ready_for_review') runs-on: ubuntu-latest steps: - - uses: mondeja/remove-labels-gh-action@v1.1.1 + - uses: mondeja/remove-labels-gh-action@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} labels: | @@ -43,7 +43,7 @@ jobs: if: github.event_name == 'pull_request_target' && github.event.action == 'converted_to_draft' runs-on: ubuntu-latest steps: - - uses: mondeja/remove-labels-gh-action@v1.1.1 + - uses: mondeja/remove-labels-gh-action@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} labels: | @@ -54,7 +54,7 @@ jobs: if: github.event_name == 'pull_request_target' && github.event.action == 'ready_for_review' runs-on: ubuntu-latest steps: - - uses: mondeja/remove-labels-gh-action@v1.1.1 + - uses: mondeja/remove-labels-gh-action@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} labels: | diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index cbe16d3bd5ed4..834da028dd957 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -13,7 +13,7 @@ jobs: # For bug reports - name: New bug issue - uses: alex-page/github-project-automation-plus@v0.8.3 + 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 @@ -22,7 +22,7 @@ jobs: action: update - name: Bug issue closed - uses: alex-page/github-project-automation-plus@v0.8.3 + uses: alex-page/github-project-automation-plus@v0.9.0 if: github.event.action == 'closed' || github.event.action == 'deleted' with: action: delete @@ -31,7 +31,7 @@ jobs: repo-token: ${{ secrets.PUSH_TOKEN }} - name: Bug issue reopened - uses: alex-page/github-project-automation-plus@v0.8.3 + 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 @@ -41,7 +41,7 @@ jobs: # For feature requests - name: New feature issue - uses: alex-page/github-project-automation-plus@v0.8.3 + 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 @@ -50,7 +50,7 @@ jobs: action: update - name: Feature request issue closed - uses: alex-page/github-project-automation-plus@v0.8.3 + uses: alex-page/github-project-automation-plus@v0.9.0 if: github.event.action == 'closed' || github.event.action == 'deleted' with: action: delete @@ -59,7 +59,7 @@ jobs: repo-token: ${{ secrets.PUSH_TOKEN }} - name: Feature request issue reopened - uses: alex-page/github-project-automation-plus@v0.8.3 + 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 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5fd8db421addb..6945419318d9a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,7 +11,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: stale-issue-message: 'This issue is stale because it has been open 28 days with no activity. Remove stale label or comment or this will be closed in 7 days.' stale-pr-message: 'This PR is stale because it has been open 28 days with no activity. Remove stale label or comment or this will be closed in 14 days.' diff --git a/.stylelintrc.json b/.stylelintrc.json index bf2da3b109eeb..e32dc52314fb9 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,5 +1,5 @@ { - "plugins": ["stylelint-high-performance-animation", "@double-great/stylelint-a11y"], + "plugins": ["stylelint-use-logical-spec", "@double-great/stylelint-a11y"], "extends": ["stylelint-config-standard", "stylelint-config-sass-guidelines"], "overrides": [ { @@ -17,9 +17,12 @@ } ], "rules": { + "selector-no-qualifying-type": [ + true, { + "ignore": ["attribute"] + }], "selector-class-pattern": null, "selector-id-pattern": null, - "plugin/no-low-performance-animation-properties": null, "selector-pseudo-class-no-unknown": [ true, { @@ -27,7 +30,6 @@ } ], "a11y/no-outline-none": true, - "a11y/selector-pseudo-class-focus": true, - "a11y/font-size-is-readable": true + "liberty/use-logical-spec": ["always", { "except": ["float"] }] } } diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000..2f3cb81cc2abd --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "dev-runner (Electron)", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/_scripts/dev-runner.js", + "args": ["--remote-debug"] + }, + { + "name": "Attach to renderer process (Electron)", + "type": "chrome", + "request": "attach", + "port": 9223, + "webRoot": "http://localhost:9080", + "sourceMapPathOverrides": { + "webpack://freetube/./~/*": "${workspaceFolder}/node_modules/*", + "webpack://freetube/./*": "${workspaceFolder}/*" + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 3c0971b857e18..0000000000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "dev", - "problemMatcher": [] - }, - { - "type": "npm", - "script": "dev-runner", - "problemMatcher": [], - "label": "npm: dev-runner", - "detail": "node _scripts/dev-runner.js" - } - ] -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10f6618953db1..32a86cb197dd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Please follow these guidelines before sending your pull request and making contr * Please test your code. Make sure new features work as well as existing core features such as watching videos or loading subscriptions. New features need to work with both the Local API as well as the Invidious API * Please make sure your code does not violate any standards set by our linter. It's up to you to make fixes whenever necessary. You can run `npm run lint` to check locally and `npm run lint-fix` to automatically fix smaller issues. * Please limit the amount of Node Modules that you introduce into the project. Only include them when **absolutely necessary** for your code to work (Ex: Using nedb for databases) or if a module provides similar functionality to what you are trying to achieve (Ex: Using autolinker to create links to outside URLs instead of writing the functionality myself). -* Please try to stay involved with the community and maintain your code. We are only two developers working on FreeTube in our spare time. We do not have time to work on everything, and it would be nice if you can maintain your code when necessary. +* Please try to stay involved with the community and maintain your code. We are only a handful of developers working on FreeTube in our spare time. We do not have time to work on everything, and it would be nice if you can maintain your code when necessary. # Setting up Your Environment diff --git a/README.md b/README.md index 8c538e9df15a0..1969609485ef7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ FreeTube is an open source desktop YouTube player built with privacy in mind. Use YouTube without advertisements and prevent Google from tracking you with their cookies and JavaScript. -Available for Windows, Mac & Linux thanks to Electron. +Available for Windows (10 and later), Mac (macOS 10.15 and later) & Linux thanks to Electron.

Download FreeTube

@@ -21,17 +21,20 @@ Available for Windows, Mac & Linux thanks to Electron.

WebsiteBlogDocumentationFAQDiscussions


-Please note that FreeTube is currently in Beta. While it should work well for most users, there are still bugs and missing features that need to be addressed. If you have an idea or if you found a bug, please submit a [GitHub issue](https://github.com/FreeTubeApp/FreeTube/issues/new/choose) so that -we can track it. Please search [the existing issues](https://github.com/FreeTubeApp/FreeTube/issues) before submitting to -prevent duplicates! +> [!NOTE] +> FreeTube is currently in Beta. While it should work well for most users, there are still bugs and missing features that need to be addressed. +> +> If you have an idea or if you found a bug, please submit a [GitHub issue](https://github.com/FreeTubeApp/FreeTube/issues/new/choose) so that we can track it. Please search [the existing issues](https://github.com/FreeTubeApp/FreeTube/issues) before submitting to prevent duplicates! ## Screenshots ## How does it work? FreeTube uses a built in extractor to grab and serve data / videos. The [Invidious API](https://github.com/iv-org/invidious) can also optionally be used. FreeTube does not use any official APIs to obtain data. While YouTube can still see your video requests, it can no -longer track you using cookies or JavaScript. Your subscriptions and history are stored locally on your computer and never sent out. Using a VPN or Tor is highly recommended -to hide your IP while using FreeTube. +longer track you using cookies or JavaScript. Your subscriptions and history are stored locally on your computer and never sent out. + +> [!IMPORTANT] +> Using a VPN or Tor is highly recommended to hide your IP while using FreeTube. ## Features * Watch videos without ads @@ -59,16 +62,26 @@ to hide your IP while using FreeTube. * 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. You must enable the option within the advanced settings of the extension for it to work. +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. + +> [!IMPORTANT] +> You must enable the option within the advanced settings of the extension for it to work. * Download Privacy Redirect for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/privacy-redirect/) or [Google Chrome](https://chrome.google.com/webstore/detail/privacy-redirect/pmcmeagblkinmogikoikkdjiligflglb). * Download LibRedirect for [Firefox](https://addons.mozilla.org/firefox/addon/libredirect/) or [Google Chrome](https://libredirect.github.io/download_chromium.html). -If you have issues with the extension working with FreeTube, please create an issue in this repository instead of the extension repository. This extension does not work on Linux portable builds! +> [!NOTE] +> This extension does not work on Linux portable builds! +> +> If you have issues with the extension working with FreeTube, please create an issue in this repository instead of the extension repository. ## Download Links ### Official Downloads + +> [!CAUTION] +> FreeTube is only supported on Windows 10 and later, macOS 10.15 and above, and various Linux distributions. Installing it on unsupported systems may result in unexpected issues. + * [GitHub Releases](https://github.com/FreeTubeApp/FreeTube/releases) * [FreeTube Website](https://freetubeapp.io/#download) @@ -76,18 +89,27 @@ If you have issues with the extension working with FreeTube, please create an is * Flatpak on Flathub: [Download](https://flathub.org/apps/details/io.freetubeapp.FreeTube) and [Source Code](https://github.com/flathub/io.freetubeapp.FreeTube) #### Automated Builds (Nightly / Weekly) +> [!WARNING] +> Use these builds at your own risk. These are pre-release versions and are only intended for people that want to test changes early and are willing to accept that things could break from one build to another. + Builds are automatically created from changes to our development branch via [GitHub Actions](https://github.com/FreeTubeApp/FreeTube/actions?query=workflow%3ABuild). -The first build with a green check mark is the latest build. You will need to have a GitHub account to download these builds. +The first build with a green check mark is the latest build. + +> [!IMPORTANT] +> You will need to have a GitHub account to download these builds. ### Unofficial Downloads -These builds are maintained by the community. While they should be safe, download at your own risk. There may be issues with using these versus the official builds. Any issues specific with these builds should be sent to their respective maintainer. Make sure u always try an [official download](https://github.com/freetubeapp/freetube/#official-downloads) before reporting your issue to us! +> [!WARNING] +> These builds are maintained by the community. While they should be safe, download at your own risk. There may be issues with using these versus the official builds. Any issues specific with these builds should be sent to their respective maintainer. Make sure u always try an [official download](https://github.com/freetubeapp/freetube/#official-downloads) before reporting your issue to us! * Arch User Repository (AUR): [Download](https://aur.archlinux.org/packages/freetube-bin/) * Chocolatey: [Download](https://chocolatey.org/packages/freetube/) -* FreeTubeCordova (FreeTube port for Android and PWA): [Download](https://github.com/MarmadileManteater/FreeTubeCordova/releases) and [Source Code](https://github.com/MarmadileManteater/FreeTubeCordova) +* FreeTubeAndroid (FreeTube port for Android and PWA): [Download](https://github.com/MarmadileManteater/FreeTubeAndroid/releases) and [Source Code](https://github.com/MarmadileManteater/FreeTubeAndroid) + +* Homebrew Formulae (Mac only): [Download](https://formulae.brew.sh/cask/freetube) * makedeb Package Repository (MPR): [Download](https://mpr.makedeb.org/packages/freetube-bin) @@ -97,18 +119,20 @@ These builds are maintained by the community. While they should be safe, downloa * Scoop (Windows Only): [Usage](https://github.com/ScoopInstaller/Scoop) -* Snap: [Download](https://snapcraft.io/freetube-snap) and [Source Code](https://launchpad.net/freetube) +* Snap: [Download](https://snapcraft.io/freetube) and [Source Code](https://git.launchpad.net/freetube) + +* WAPT: [Download](https://wapt.tranquil.it/store/tis-freetube) * Windows Package Manager (winget): [Usage](https://docs.microsoft.com/en-us/windows/package-manager/winget/) ## Contributing +Thank you very much to the [People and Projects](https://docs.freetubeapp.io/credits/) that make FreeTube possible! + If you like to get your hands dirty and want to contribute, we would love to -have your help. Send a pull request and someone will review your code. Please -follow the [Contribution -Guidelines](https://github.com/FreeTubeApp/FreeTube/blob/development/CONTRIBUTING.md) -before sending your pull request. +have your help. Send a pull request and someone will review your code. -Thank you very much to the [People and Projects](https://docs.freetubeapp.io/credits/) that make FreeTube possible! +> [!IMPORTANT] +> Please follow the [Contribution Guidelines](https://github.com/FreeTubeApp/FreeTube/blob/development/CONTRIBUTING.md) before sending your pull request. ## Localization @@ -120,7 +144,10 @@ We are actively looking for translations! We use [Weblate](https://hosted.webla For the Linux Flatpak, the desktop entry comment string can be translated at our [Flatpak repository](https://github.com/flathub/io.freetubeapp.FreeTube/blob/master/io.freetubeapp.FreeTube.desktop). ## Contact -If you ever have any questions, feel free to ask it on our [Discussions](https://github.com/FreeTubeApp/FreeTube/discussions) page. Alternatively, you can email us at FreeTubeApp@protonmail.com or you can join our [Matrix Community](https://matrix.to/#/+freetube:matrix.org). Don't forget to check out the [rules](https://docs.freetubeapp.io/community/matrix/) before joining. +If you ever have any questions, feel free to ask it on our [Discussions](https://github.com/FreeTubeApp/FreeTube/discussions) page. Alternatively, you can email us at FreeTubeApp@protonmail.com or you can join our [Matrix Community](https://matrix.to/#/+freetube:matrix.org). + +> [!IMPORTANT] +> Don't forget to check out the [rules](https://docs.freetubeapp.io/community/matrix/) before joining. ## Donate If you enjoy using FreeTube, you're welcome to leave a donation using the following methods. @@ -131,7 +158,10 @@ If you enjoy using FreeTube, you're welcome to leave a donation using the follow * Monero Address: `48WyAPdjwc6VokeXACxSZCFeKEXBiYPV6GjfvBsfg4CrUJ95LLCQSfpM9pvNKy5GE5H4hNaw99P8RZyzmaU9kb1pD7kzhCB` -While your donations are much appreciated, only donate if you really want to. Donations are used for keeping the website up and running and eventual code signing costs. If you are using the Invidious API then we recommend that you donate to the instance that you use. You can also donate to the [Invidious team](https://invidious.io/donate/) or the [Local API developer](https://github.com/sponsors/LuanRT). +While your donations are much appreciated, only donate if you really want to. Donations are used for keeping the website up and running and eventual code signing costs. + +> [!TIP] +> If you are using the Invidious API then we recommend that you donate to the instance that you use. You can also donate to the [Invidious team](https://invidious.io/donate/) or the [Local API developer](https://github.com/sponsors/LuanRT). ## License [![GNU AGPLv3 Image](https://www.gnu.org/graphics/agplv3-155x51.png)](https://www.gnu.org/licenses/agpl-3.0.html) diff --git a/_icons/icon.ico b/_icons/icon.ico index cb73bbb4c7922..4d18cd5f545ca 100644 Binary files a/_icons/icon.ico and b/_icons/icon.ico differ diff --git a/_icons/iconBlack.png b/_icons/iconBlack.png deleted file mode 100644 index 1663986267da7..0000000000000 Binary files a/_icons/iconBlack.png and /dev/null differ diff --git a/_icons/iconBlack.svg b/_icons/iconBlack.svg new file mode 100644 index 0000000000000..09822b399508e --- /dev/null +++ b/_icons/iconBlack.svg @@ -0,0 +1,3 @@ + + + diff --git a/_icons/iconBlackSmall.png b/_icons/iconBlackSmall.png deleted file mode 100644 index 04528fd490505..0000000000000 Binary files a/_icons/iconBlackSmall.png and /dev/null differ diff --git a/_icons/iconBlackSmall.svg b/_icons/iconBlackSmall.svg new file mode 100644 index 0000000000000..6be52ec3a9775 --- /dev/null +++ b/_icons/iconBlackSmall.svg @@ -0,0 +1,3 @@ + + + diff --git a/_icons/iconCatppuccinMochaDarkSmall.png b/_icons/iconCatppuccinMochaDarkSmall.png deleted file mode 100644 index c963e8a0e823f..0000000000000 Binary files a/_icons/iconCatppuccinMochaDarkSmall.png and /dev/null differ diff --git a/_icons/iconCatppuccinMochaDarkSmall.svg b/_icons/iconCatppuccinMochaDarkSmall.svg new file mode 100644 index 0000000000000..a9cdbea80ea05 --- /dev/null +++ b/_icons/iconCatppuccinMochaDarkSmall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/_icons/iconCatppuccinMochaLightSmall.png b/_icons/iconCatppuccinMochaLightSmall.png deleted file mode 100644 index 618b08e2916ae..0000000000000 Binary files a/_icons/iconCatppuccinMochaLightSmall.png and /dev/null differ diff --git a/_icons/iconCatppuccinMochaLightSmall.svg b/_icons/iconCatppuccinMochaLightSmall.svg new file mode 100644 index 0000000000000..f5e1d9d43b57a --- /dev/null +++ b/_icons/iconCatppuccinMochaLightSmall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/_icons/iconColor.png b/_icons/iconColor.png index 191266143368e..300d763f9373d 100644 Binary files a/_icons/iconColor.png and b/_icons/iconColor.png differ diff --git a/_icons/iconColor.svg b/_icons/iconColor.svg new file mode 100644 index 0000000000000..1821fcad86fb9 --- /dev/null +++ b/_icons/iconColor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/_icons/iconColorSmall.png b/_icons/iconColorSmall.png deleted file mode 100644 index fd0060151726c..0000000000000 Binary files a/_icons/iconColorSmall.png and /dev/null differ diff --git a/_icons/iconColorSmall.svg b/_icons/iconColorSmall.svg new file mode 100644 index 0000000000000..c8e25b369d568 --- /dev/null +++ b/_icons/iconColorSmall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/_icons/iconDraculaDarkSmall.png b/_icons/iconDraculaDarkSmall.png deleted file mode 100644 index 5b515c64960e9..0000000000000 Binary files a/_icons/iconDraculaDarkSmall.png and /dev/null differ diff --git a/_icons/iconDraculaDarkSmall.svg b/_icons/iconDraculaDarkSmall.svg new file mode 100644 index 0000000000000..9a01efc1b3818 --- /dev/null +++ b/_icons/iconDraculaDarkSmall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/_icons/iconDraculaLightSmall.png b/_icons/iconDraculaLightSmall.png deleted file mode 100644 index e497925fb76ab..0000000000000 Binary files a/_icons/iconDraculaLightSmall.png and /dev/null differ diff --git a/_icons/iconDraculaLightSmall.svg b/_icons/iconDraculaLightSmall.svg new file mode 100644 index 0000000000000..1c2212ac474cf --- /dev/null +++ b/_icons/iconDraculaLightSmall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/_icons/iconNordicLightSmall.svg b/_icons/iconNordicLightSmall.svg new file mode 100644 index 0000000000000..8b5cbd2dc3049 --- /dev/null +++ b/_icons/iconNordicLightSmall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/_icons/iconWhite.png b/_icons/iconWhite.png deleted file mode 100644 index 38f58b591df9a..0000000000000 Binary files a/_icons/iconWhite.png and /dev/null differ diff --git a/_icons/iconWhite.svg b/_icons/iconWhite.svg new file mode 100644 index 0000000000000..bd0595084c1af --- /dev/null +++ b/_icons/iconWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/_icons/iconWhiteSmall.svg b/_icons/iconWhiteSmall.svg new file mode 100644 index 0000000000000..859901456a528 --- /dev/null +++ b/_icons/iconWhiteSmall.svg @@ -0,0 +1,3 @@ + + + diff --git a/_icons/logoBlack.svg b/_icons/logoBlack.svg new file mode 100644 index 0000000000000..8d167add15645 --- /dev/null +++ b/_icons/logoBlack.svg @@ -0,0 +1,193 @@ + + + +logotype + + + diff --git a/_icons/logoColor.svg b/_icons/logoColor.svg new file mode 100644 index 0000000000000..35d5e45a8a65d --- /dev/null +++ b/_icons/logoColor.svg @@ -0,0 +1,224 @@ + + + + + + diff --git a/_icons/logoWhite.svg b/_icons/logoWhite.svg new file mode 100644 index 0000000000000..d95bc268fb171 --- /dev/null +++ b/_icons/logoWhite.svg @@ -0,0 +1,210 @@ + + + + + + diff --git a/_icons/textBlack.png b/_icons/textBlack.png deleted file mode 100644 index e79196384afdf..0000000000000 Binary files a/_icons/textBlack.png and /dev/null differ diff --git a/_icons/textBlack.svg b/_icons/textBlack.svg new file mode 100644 index 0000000000000..bc1b89afd49e3 --- /dev/null +++ b/_icons/textBlack.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/_icons/textBlackSmall.png b/_icons/textBlackSmall.png deleted file mode 100644 index 80560ba9d0a73..0000000000000 Binary files a/_icons/textBlackSmall.png and /dev/null differ diff --git a/_icons/textBlackSmall.svg b/_icons/textBlackSmall.svg new file mode 100644 index 0000000000000..411d37c02cd99 --- /dev/null +++ b/_icons/textBlackSmall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textCatppuccinMochaDarkSmall.png b/_icons/textCatppuccinMochaDarkSmall.png deleted file mode 100644 index c5b70b21548f2..0000000000000 Binary files a/_icons/textCatppuccinMochaDarkSmall.png and /dev/null differ diff --git a/_icons/textCatppuccinMochaDarkSmall.svg b/_icons/textCatppuccinMochaDarkSmall.svg new file mode 100644 index 0000000000000..ef5d976be26f2 --- /dev/null +++ b/_icons/textCatppuccinMochaDarkSmall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textCatppuccinMochaLightSmall.png b/_icons/textCatppuccinMochaLightSmall.png deleted file mode 100644 index 8a7bd5bc534a4..0000000000000 Binary files a/_icons/textCatppuccinMochaLightSmall.png and /dev/null differ diff --git a/_icons/textCatppuccinMochaLightSmall.svg b/_icons/textCatppuccinMochaLightSmall.svg new file mode 100644 index 0000000000000..e47d287c6cdfd --- /dev/null +++ b/_icons/textCatppuccinMochaLightSmall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textColor.png b/_icons/textColor.png deleted file mode 100644 index bdc83759e6ece..0000000000000 Binary files a/_icons/textColor.png and /dev/null differ diff --git a/_icons/textColor.svg b/_icons/textColor.svg new file mode 100644 index 0000000000000..b3bb6cb3dcb61 --- /dev/null +++ b/_icons/textColor.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textColorSmall.png b/_icons/textColorSmall.png deleted file mode 100644 index 7a2196afffb52..0000000000000 Binary files a/_icons/textColorSmall.png and /dev/null differ diff --git a/_icons/textColorSmall.svg b/_icons/textColorSmall.svg new file mode 100644 index 0000000000000..c4aa40176c121 --- /dev/null +++ b/_icons/textColorSmall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textDraculaDarkSmall.png b/_icons/textDraculaDarkSmall.png deleted file mode 100644 index ae232959572db..0000000000000 Binary files a/_icons/textDraculaDarkSmall.png and /dev/null differ diff --git a/_icons/textDraculaDarkSmall.svg b/_icons/textDraculaDarkSmall.svg new file mode 100644 index 0000000000000..04e05e6db6549 --- /dev/null +++ b/_icons/textDraculaDarkSmall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textDraculaLightSmall.png b/_icons/textDraculaLightSmall.png deleted file mode 100644 index 219397d617a5f..0000000000000 Binary files a/_icons/textDraculaLightSmall.png and /dev/null differ diff --git a/_icons/textDraculaLightSmall.svg b/_icons/textDraculaLightSmall.svg new file mode 100644 index 0000000000000..1feb964f25c47 --- /dev/null +++ b/_icons/textDraculaLightSmall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textNordicLightSmall.svg b/_icons/textNordicLightSmall.svg new file mode 100644 index 0000000000000..692f9edd629d2 --- /dev/null +++ b/_icons/textNordicLightSmall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textWhite.png b/_icons/textWhite.png deleted file mode 100644 index 219397d617a5f..0000000000000 Binary files a/_icons/textWhite.png and /dev/null differ diff --git a/_icons/textWhite.svg b/_icons/textWhite.svg new file mode 100644 index 0000000000000..fcafdd3bd049e --- /dev/null +++ b/_icons/textWhite.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_icons/textWhiteSmall.svg b/_icons/textWhiteSmall.svg new file mode 100644 index 0000000000000..23258dd1465d2 --- /dev/null +++ b/_icons/textWhiteSmall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/_scripts/ProcessLocalesPlugin.js b/_scripts/ProcessLocalesPlugin.js index 0d2dd681849a2..e79345e106566 100644 --- a/_scripts/ProcessLocalesPlugin.js +++ b/_scripts/ProcessLocalesPlugin.js @@ -1,4 +1,4 @@ -const { existsSync, readFileSync } = require('fs') +const { existsSync, readFileSync, statSync } = require('fs') const { brotliCompress, constants } = require('zlib') const { promisify } = require('util') const { load: loadYaml } = require('js-yaml') @@ -8,6 +8,7 @@ const brotliCompressAsync = promisify(brotliCompress) class ProcessLocalesPlugin { constructor(options = {}) { this.compress = !!options.compress + this.isIncrementalBuild = false if (typeof options.inputDir !== 'string') { throw new Error('ProcessLocalesPlugin: no input directory `inputDir` specified.') @@ -21,10 +22,11 @@ class ProcessLocalesPlugin { } this.outputDir = options.outputDir - this.locales = [] + this.locales = {} this.localeNames = [] + this.activeLocales = [] - this.cache = [] + this.cache = {} this.loadLocales() } @@ -37,66 +39,94 @@ class ProcessLocalesPlugin { compilation.hooks.additionalAssets.tapPromise('process-locales-plugin', async (_assets) => { - // While running in the webpack dev server, this hook gets called for every incrememental build. + // While running in the webpack dev server, this hook gets called for every incremental build. // For incremental builds we can return the already processed versions, which saves time // and makes webpack treat them as cached - if (IS_DEV_SERVER && this.cache.length > 0) { - for (const { filename, source } of this.cache) { - compilation.emitAsset(filename, source, { minimized: true }) - } + const promises = [] + // Prevents `loadLocales` called twice on first time (e.g. release build) + if (this.isIncrementalBuild) { + this.loadLocales(true) } else { - const promises = [] + this.isIncrementalBuild = true + } - for (const { locale, data } of this.locales) { - promises.push(new Promise(async (resolve) => { - if (Object.prototype.hasOwnProperty.call(data, 'Locale Name')) { - delete data['Locale Name'] - } + Object.values(this.locales).forEach((localeEntry) => { + const { locale, data, mtimeMs } = localeEntry - this.removeEmptyValues(data) + promises.push(new Promise(async (resolve) => { + if (IS_DEV_SERVER) { + const cacheEntry = this.cache[locale] - let filename = `${this.outputDir}/${locale}.json` - let output = JSON.stringify(data) + if (cacheEntry != null) { + const { filename, source, mtimeMs: cachedMtimeMs } = cacheEntry - if (this.compress) { - filename += '.br' - output = await this.compressLocale(output) + if (cachedMtimeMs === mtimeMs) { + compilation.emitAsset(filename, source, { minimized: true }) + resolve() + return + } } + } - let source = new RawSource(output) + this.removeEmptyValues(data) - if (IS_DEV_SERVER) { - source = new CachedSource(source) - this.cache.push({ filename, source }) - } + let filename = `${this.outputDir}/${locale}.json` + let output = JSON.stringify(data) - compilation.emitAsset(filename, source, { minimized: true }) + if (this.compress) { + filename += '.br' + output = await this.compressLocale(output) + } - resolve() - })) - } + let source = new RawSource(output) - await Promise.all(promises) + if (IS_DEV_SERVER) { + source = new CachedSource(source) + this.cache[locale] = { filename, source, mtimeMs } + } + + compilation.emitAsset(filename, source, { minimized: true }) + + resolve() + })) if (IS_DEV_SERVER) { // we don't need the unmodified sources anymore, as we use the cache `this.cache` // so we can clear this to free some memory - delete this.locales + delete localeEntry.data } - } + }) + + await Promise.all(promises) }) }) } - loadLocales() { - const activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`)) + loadLocales(loadModifiedFilesOnly = false) { + if (this.activeLocales.length === 0) { + this.activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`)) + } - for (const locale of activeLocales) { - const contents = readFileSync(`${this.inputDir}/${locale}.yaml`, 'utf-8') + for (const locale of this.activeLocales) { + const filePath = `${this.inputDir}/${locale}.yaml` + // Cannot use `mtime` since values never equal + const mtimeMsFromStats = statSync(filePath).mtimeMs + if (loadModifiedFilesOnly) { + // Skip reading files where mtime (modified time) same as last read + // (stored in mtime) + const existingMtime = this.locales[locale]?.mtimeMs + if (existingMtime != null && existingMtime === mtimeMsFromStats) { + continue + } + } + const contents = readFileSync(filePath, 'utf-8') const data = loadYaml(contents) + this.locales[locale] = { locale, data, mtimeMs: mtimeMsFromStats } - this.localeNames.push(data['Locale Name'] ?? locale) - this.locales.push({ locale, data }) + const localeName = data['Locale Name'] ?? locale + if (!loadModifiedFilesOnly) { + this.localeNames.push(localeName) + } } } diff --git a/_scripts/build.js b/_scripts/build.js index 035f986c97dbe..bee33667fbeae 100644 --- a/_scripts/build.js +++ b/_scripts/build.js @@ -1,9 +1,9 @@ const os = require('os') const builder = require('electron-builder') +const config = require('./ebuilder.config.js') const Platform = builder.Platform const Arch = builder.Arch -const { name, productName } = require('../package.json') const args = process.argv let targets @@ -39,97 +39,6 @@ if (platform === 'darwin') { targets = Platform.LINUX.createTarget(['deb', 'zip', '7z', 'apk', 'rpm', 'AppImage', 'pacman'], arch) } -const config = { - appId: `io.freetubeapp.${name}`, - copyright: 'Copyleft © 2020-2023 freetubeapp@protonmail.com', - // asar: false, - // compression: 'store', - productName, - directories: { - output: './build/', - }, - protocols: [ - { - name: "FreeTube", - schemes: [ - "freetube" - ] - } - ], - files: [ - '_icons/iconColor.*', - 'icon.svg', - './dist/**/*', - '!dist/web/*', - '!node_modules/**/*', - ], - dmg: { - contents: [ - { - path: '/Applications', - type: 'link', - x: 410, - y: 230, - }, - { - type: 'file', - x: 130, - y: 230, - }, - ], - window: { - height: 380, - width: 540, - } - }, - linux: { - category: 'Network', - icon: '_icons/icon.svg', - target: ['deb', 'zip', '7z', 'apk', 'rpm', 'AppImage', 'pacman'], - }, - // See the following issues for more information - // https://github.com/jordansissel/fpm/issues/1503 - // https://github.com/jgraph/drawio-desktop/issues/259 - rpm: { - fpm: [`--rpm-rpmbuild-define=_build_id_links none`] - }, - deb: { - depends: [ - "libgtk-3-0", - "libnotify4", - "libnss3", - "libxss1", - "libxtst6", - "xdg-utils", - "libatspi2.0-0", - "libuuid1", - "libsecret-1-0" - ] - }, - mac: { - category: 'public.app-category.utilities', - icon: '_icons/iconMac.icns', - target: ['dmg', 'zip', '7z'], - type: 'distribution', - extendInfo: { - CFBundleURLTypes: [ - 'freetube' - ], - CFBundleURLSchemes: [ - 'freetube' - ] - } - }, - win: { - icon: '_icons/icon.ico', - target: ['nsis', 'zip', '7z', 'portable'], - }, - nsis: { - allowToChangeInstallationDirectory: true, - oneClick: false, - }, -} - builder .build({ targets, diff --git a/_scripts/dev-runner.js b/_scripts/dev-runner.js index d030c8b814035..fe25a836fff17 100644 --- a/_scripts/dev-runner.js +++ b/_scripts/dev-runner.js @@ -1,6 +1,5 @@ process.env.NODE_ENV = 'development' -const open = require('open') const electron = require('electron') const webpack = require('webpack') const WebpackDevServer = require('webpack-dev-server') @@ -112,13 +111,14 @@ function startRenderer(callback) { const server = new WebpackDevServer({ static: { - directory: path.join(process.cwd(), 'static'), + directory: path.resolve(__dirname, '..', 'static'), watch: { ignored: [ /(dashFiles|storyboards)\/*/, '/**/.DS_Store', ] - } + }, + publicPath: '/static' }, port }, compiler) @@ -161,7 +161,8 @@ function startWeb (callback) { if (!web) { startRenderer(startMain) } else { - startWeb(({ port }) => { + startWeb(async ({ port }) => { + const open = (await import('open')).default open(`http://localhost:${port}`) }) } diff --git a/_scripts/ebuilder.config.js b/_scripts/ebuilder.config.js new file mode 100644 index 0000000000000..5b79d96185685 --- /dev/null +++ b/_scripts/ebuilder.config.js @@ -0,0 +1,94 @@ +const { name, productName } = require('../package.json') + +const config = { + appId: `io.freetubeapp.${name}`, + copyright: 'Copyleft © 2020-2024 freetubeapp@protonmail.com', + // asar: false, + // compression: 'store', + productName, + directories: { + output: './build/', + }, + protocols: [ + { + name: "FreeTube", + schemes: [ + "freetube" + ] + } + ], + files: [ + '_icons/iconColor.*', + 'icon.svg', + './dist/**/*', + '!dist/web/*', + '!node_modules/**/*', + ], + dmg: { + contents: [ + { + path: '/Applications', + type: 'link', + x: 410, + y: 230, + }, + { + type: 'file', + x: 130, + y: 230, + }, + ], + window: { + height: 380, + width: 540, + } + }, + linux: { + category: 'Network', + icon: '_icons/icon.svg', + target: ['deb', 'zip', '7z', 'apk', 'rpm', 'AppImage', 'pacman'], + }, + // See the following issues for more information + // https://github.com/jordansissel/fpm/issues/1503 + // https://github.com/jgraph/drawio-desktop/issues/259 + rpm: { + fpm: [`--rpm-rpmbuild-define=_build_id_links none`] + }, + deb: { + depends: [ + "libgtk-3-0", + "libnotify4", + "libnss3", + "libxss1", + "libxtst6", + "xdg-utils", + "libatspi2.0-0", + "libuuid1", + "libsecret-1-0" + ] + }, + mac: { + category: 'public.app-category.utilities', + icon: '_icons/iconMac.icns', + target: ['dmg', 'zip', '7z'], + type: 'distribution', + extendInfo: { + CFBundleURLTypes: [ + 'freetube' + ], + CFBundleURLSchemes: [ + 'freetube' + ] + } + }, + win: { + icon: '_icons/icon.ico', + target: ['nsis', 'zip', '7z', 'portable'], + }, + nsis: { + allowToChangeInstallationDirectory: true, + oneClick: false, + }, +} + +module.exports = config diff --git a/_scripts/webpack.main.config.js b/_scripts/webpack.main.config.js index 0f3c706c6a973..0358f79196aec 100644 --- a/_scripts/webpack.main.config.js +++ b/_scripts/webpack.main.config.js @@ -56,7 +56,7 @@ if (!isDevMode) { to: path.join(__dirname, '../dist/static'), globOptions: { dot: true, - ignore: ['**/.*', '**/locales/**', '**/pwabuilder-sw.js', '**/dashFiles/**', '**/storyboards/**'], + ignore: ['**/.*', '**/locales/**', '**/pwabuilder-sw.js', '**/manifest.json', '**/dashFiles/**', '**/storyboards/**'], }, }, ] diff --git a/_scripts/webpack.renderer.config.js b/_scripts/webpack.renderer.config.js index eaf72b50dc36f..fcd9b0e580980 100644 --- a/_scripts/webpack.renderer.config.js +++ b/_scripts/webpack.renderer.config.js @@ -1,13 +1,18 @@ const path = require('path') +const { readFileSync, readdirSync } = require('fs') const webpack = require('webpack') const HtmlWebpackPlugin = require('html-webpack-plugin') const VueLoaderPlugin = require('vue-loader/lib/plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') +const WatchExternalFilesPlugin = require('webpack-watch-external-files-plugin') +const CopyWebpackPlugin = require('copy-webpack-plugin') const isDevMode = process.env.NODE_ENV === 'development' +const { version: swiperVersion } = JSON.parse(readFileSync(path.join(__dirname, '../node_modules/swiper/package.json'))) + const processLocalesPlugin = new ProcessLocalesPlugin({ compress: !isDevMode, inputDir: path.join(__dirname, '../static/locales'), @@ -106,34 +111,48 @@ const config = { ] }, node: { - __dirname: isDevMode, - __filename: isDevMode + __dirname: false, + __filename: false }, plugins: [ processLocalesPlugin, new webpack.DefinePlugin({ 'process.env.IS_ELECTRON': true, 'process.env.IS_ELECTRON_MAIN': false, - 'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames) + '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}'` }), new HtmlWebpackPlugin({ excludeChunks: ['processTaskWorker'], filename: 'index.html', - template: path.resolve(__dirname, '../src/index.ejs'), - nodeModules: isDevMode - ? path.resolve(__dirname, '../node_modules') - : false, + template: path.resolve(__dirname, '../src/index.ejs') }), new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: isDevMode ? '[name].css' : '[name].[contenthash].css', chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css', + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'), + to: `swiper-${swiperVersion}.css`, + context: path.join(__dirname, '../node_modules/swiper/modules'), + transformAll: (assets) => { + return Buffer.concat(assets.map(asset => asset.data)) + } + } + ] }) ], resolve: { alias: { vue$: 'vue/dist/vue.runtime.esm.js', + 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$': path.resolve(__dirname, '../src/datastores/handlers/electron.js'), + 'youtubei.js$': 'youtubei.js/web', // video.js's mpd-parser uses @xmldom/xmldom so that it can support both node and web browsers @@ -146,4 +165,16 @@ const config = { target: 'electron-renderer', } +if (isDevMode) { + const activeLocales = JSON.parse(readFileSync(path.join(__dirname, '../static/locales/activeLocales.json'))) + + config.plugins.push( + new WatchExternalFilesPlugin({ + files: [ + `./static/locales/{${activeLocales.join(',')}}.yaml`, + ], + }), + ) +} + module.exports = config diff --git a/_scripts/webpack.web.config.js b/_scripts/webpack.web.config.js index 420d350482ea4..04f4b9803d35b 100644 --- a/_scripts/webpack.web.config.js +++ b/_scripts/webpack.web.config.js @@ -11,6 +11,8 @@ const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') const isDevMode = process.env.NODE_ENV === 'development' +const { version: swiperVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '../node_modules/swiper/package.json'))) + const config = { name: 'web', mode: process.env.NODE_ENV, @@ -23,7 +25,6 @@ const config = { filename: '[name].js', }, externals: { - electron: '{}', 'youtubei.js': '{}' }, module: { @@ -107,13 +108,15 @@ const config = { ] }, node: { - __dirname: true, - __filename: isDevMode, + __dirname: false, + __filename: false }, plugins: [ new webpack.DefinePlugin({ '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 @@ -133,19 +136,32 @@ const config = { new HtmlWebpackPlugin({ excludeChunks: ['processTaskWorker'], filename: 'index.html', - template: path.resolve(__dirname, '../src/index.ejs'), - nodeModules: false, + template: path.resolve(__dirname, '../src/index.ejs') }), new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: isDevMode ? '[name].css' : '[name].[contenthash].css', chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css', + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'), + to: `swiper-${swiperVersion}.css`, + context: path.join(__dirname, '../node_modules/swiper/modules'), + transformAll: (assets) => { + return Buffer.concat(assets.map(asset => asset.data)) + } + } + ] }) ], resolve: { alias: { vue$: 'vue/dist/vue.runtime.esm.js', + '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 diff --git a/jsconfig.json b/jsconfig.json index 8f5ea8a228e3c..3dd46670d49e2 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,5 +1,15 @@ { "vueCompilerOptions": { "target": 2.7 + }, + "compilerOptions": { + "strictNullChecks": true, + "baseUrl": "./", + "paths": { + "DB_HANDLERS_ELECTRON_RENDERER_OR_WEB": [ + "src/datastores/handlers/electron", + "src/datastores/handlers/web" + ] + } } } diff --git a/lefthook.yml b/lefthook.yml index 6a09c9530afc2..5ae04fe0ec520 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -3,13 +3,18 @@ pre-commit: parallel: true commands: - lint: + eslint: # Only runs when any file with filename # matching the glob is being committed glob: "*.{js,vue}" run: yarn run eslint --no-color {staged_files} skip: - rebase + stylelint: + glob: "*.{css,scss}" + run: yarn stylelint --no-color --allow-empty-input {staged_files} + skip: + - rebase # EXAMPLE USAGE # diff --git a/package.json b/package.json index 83d767efe12c1..a9ae18cc225a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "freetube", "productName": "FreeTube", "description": "A private YouTube client", - "version": "0.19.0", + "version": "0.20.0", "license": "AGPL-3.0-or-later", "main": "./dist/main.js", "private": true, @@ -25,7 +25,7 @@ "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/ static/dashFiles/ dist/ static/storyboards/", + "clean": "rimraf build/ dist/", "debug": "run-s rebuild:electron debug-runner", "debug-runner": "node _scripts/dev-runner.js --remote-debug", "dev": "run-s rebuild:electron dev-runner", @@ -33,15 +33,14 @@ "dev-runner": "node _scripts/dev-runner.js", "get-instances": "node _scripts/getInstances.js", "get-regions": "node _scripts/getRegions.mjs", - "lint-all": "run-p lint lint-json lint-style", - "lint-fix": "eslint --fix --ext .js,.vue ./", - "lint": "eslint --ext .js,.vue ./", + "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 ./", - "lint-style": "run-p lint-style:scss lint-style:css", - "lint-style:scss": "stylelint \"**/*.scss\"", - "lint-style:css": "stylelint \"**/*.css\"", - "lint-style-fix:scss": "stylelint --fix \"**/*.scss\"", - "lint-style-fix:css": "stylelint --fix \"**/*.css\"", + "lint-style": "stylelint \"**/*.{css,scss}\"", + "lint-style-fix": "stylelint --fix \"**/*.{css,scss}\"", "lint-yml": "eslint --ext .yml,.yaml ./", "pack": "run-p pack:main pack:renderer", "pack:main": "webpack --mode=production --node-env=production --config _scripts/webpack.main.config.js", @@ -54,79 +53,82 @@ "ci": "yarn install --silent --frozen-lockfile" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.4.2", - "@fortawesome/free-brands-svg-icons": "^6.4.2", - "@fortawesome/free-solid-svg-icons": "^6.4.2", + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-brands-svg-icons": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/vue-fontawesome": "^2.0.10", - "@seald-io/nedb": "^4.0.2", - "@silvermine/videojs-quality-selector": "^1.3.0", + "@seald-io/nedb": "^4.0.4", + "@silvermine/videojs-quality-selector": "^1.3.1", "autolinker": "^4.0.0", "electron-context-menu": "^3.6.1", "lodash.debounce": "^4.0.8", - "marked": "^7.0.4", + "marked": "^12.0.1", "path-browserify": "^1.0.1", "process": "^0.11.10", + "swiper": "^11.1.1", "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", - "vue": "^2.7.14", + "vue": "^2.7.16", "vue-i18n": "^8.28.2", "vue-observe-visibility": "^1.0.0", "vue-router": "^3.6.5", - "vue-tiny-slider": "^0.1.39", "vuex": "^3.6.2", - "youtubei.js": "^6.0.0" + "youtubei.js": "^9.3.0" }, "devDependencies": { - "@babel/core": "^7.22.10", - "@babel/eslint-parser": "^7.22.10", + "@babel/core": "^7.24.4", + "@babel/eslint-parser": "^7.24.1", "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/preset-env": "^7.22.10", - "@double-great/stylelint-a11y": "^2.0.2", + "@babel/preset-env": "^7.24.4", + "@double-great/stylelint-a11y": "^3.0.2", + "@intlify/eslint-plugin-vue-i18n": "^2.0.0", "babel-loader": "^9.1.3", - "copy-webpack-plugin": "^11.0.0", - "css-loader": "^6.8.1", - "css-minimizer-webpack-plugin": "^5.0.1", - "electron": "^22.3.18", - "electron-builder": "^24.6.3", - "eslint": "^8.47.0", - "eslint-config-prettier": "^9.0.0", + "copy-webpack-plugin": "^12.0.2", + "css-loader": "^7.1.1", + "css-minimizer-webpack-plugin": "^6.0.0", + "electron": "^29.3.0", + "electron-builder": "^24.13.3", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", "eslint-config-standard": "^17.1.0", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jsonc": "^2.9.0", - "eslint-plugin-n": "^16.0.1", - "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jsonc": "^2.15.1", + "eslint-plugin-n": "^17.2.1", + "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-promise": "^6.1.1", - "eslint-plugin-unicorn": "^48.0.1", - "eslint-plugin-vue": "^9.17.0", - "eslint-plugin-vuejs-accessibility": "^2.2.0", - "eslint-plugin-yml": "^1.8.0", - "html-webpack-plugin": "^5.5.3", + "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-vue": "^9.25.0", + "eslint-plugin-vuejs-accessibility": "^2.2.1", + "eslint-plugin-yml": "^1.14.0", + "html-webpack-plugin": "^5.6.0", "js-yaml": "^4.1.0", - "json-minimizer-webpack-plugin": "^4.0.0", - "lefthook": "^1.4.9", - "mini-css-extract-plugin": "^2.7.6", + "json-minimizer-webpack-plugin": "^5.0.0", + "lefthook": "^1.6.10", + "mini-css-extract-plugin": "^2.8.1", "npm-run-all": "^4.1.5", - "postcss": "^8.4.26", - "postcss-scss": "^4.0.7", + "postcss": "^8.4.38", + "postcss-scss": "^4.0.9", "prettier": "^2.8.8", - "rimraf": "^5.0.1", - "sass": "^1.66.1", - "sass-loader": "^13.3.2", - "stylelint": "^15.10.3", - "stylelint-config-sass-guidelines": "^10.0.0", - "stylelint-config-standard": "^34.0.0", - "stylelint-high-performance-animation": "^1.9.0", + "rimraf": "^5.0.5", + "sass": "^1.75.0", + "sass-loader": "^14.2.0", + "stylelint": "^16.3.1", + "stylelint-config-sass-guidelines": "^11.1.0", + "stylelint-config-standard": "^36.0.0", + "stylelint-high-performance-animation": "^1.10.0", + "stylelint-use-logical-spec": "^5.0.1", "tree-kill": "1.2.2", "vue-devtools": "^5.1.4", - "vue-eslint-parser": "^9.3.1", + "vue-eslint-parser": "^9.4.2", "vue-loader": "^15.10.0", - "webpack": "^5.88.2", + "webpack": "^5.91.0", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1", + "webpack-dev-server": "^5.0.4", + "webpack-watch-external-files-plugin": "^3.0.0", "yaml-eslint-parser": "^1.2.2" } } diff --git a/src/constants.js b/src/constants.js index 4693d857623fd..3224725035b5b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -23,7 +23,10 @@ const IpcChannels = { SYNC_SETTINGS: 'sync-settings', SYNC_HISTORY: 'sync-history', SYNC_PROFILES: 'sync-profiles', - SYNC_PLAYLISTS: 'sync-playlists' + SYNC_PLAYLISTS: 'sync-playlists', + + GET_REPLACE_HTTP_CACHE: 'get-replace-http-cache', + TOGGLE_REPLACE_HTTP_CACHE: 'toggle-replace-http-cache' } const DBActions = { @@ -44,10 +47,10 @@ const DBActions = { PLAYLISTS: { UPSERT_VIDEO: 'db-action-playlists-upsert-video-by-playlist-name', - UPSERT_VIDEO_IDS: 'db-action-playlists-upsert-video-ids-by-playlist-id', + UPSERT_VIDEOS: 'db-action-playlists-upsert-videos-by-playlist-name', 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' + DELETE_ALL_VIDEOS: 'db-action-playlists-delete-all-videos', } } @@ -66,15 +69,10 @@ const SyncEvents = { PLAYLISTS: { UPSERT_VIDEO: 'sync-playlists-upsert-video', - DELETE_VIDEO: 'sync-playlists-delete-video' + DELETE_VIDEO: 'sync-playlists-delete-video', } } -// https://v2.vuejs.org/v2/api/#provide-inject -const Injectables = { - SHOW_OUTLINES: 'showOutlines' -} - // Utils const MAIN_PROFILE_ID = 'allChannels' @@ -82,6 +80,5 @@ export { IpcChannels, DBActions, SyncEvents, - Injectables, MAIN_PROFILE_ID } diff --git a/src/datastores/handlers/base.js b/src/datastores/handlers/base.js index 71e1ca3889682..7c2eb4ec0539d 100644 --- a/src/datastores/handlers/base.js +++ b/src/datastores/handlers/base.js @@ -1,4 +1,4 @@ -import db from '../index' +import * as db from '../index' class Settings { static find() { @@ -64,8 +64,8 @@ class History { return db.history.updateAsync({ videoId }, { $set: { watchProgress } }, { upsert: true }) } - static updateLastViewedPlaylist(videoId, lastViewedPlaylistId) { - return db.history.updateAsync({ videoId }, { $set: { lastViewedPlaylistId } }, { upsert: true }) + static updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) { + return db.history.updateAsync({ videoId }, { $set: { lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId } }, { upsert: true }) } static delete(videoId) { @@ -112,18 +112,22 @@ class Playlists { return db.playlists.findAsync({}) } - static upsertVideoByPlaylistName(playlistName, videoData) { + static upsert(playlist) { + return db.playlists.updateAsync({ _id: playlist._id }, { $set: playlist }, { upsert: true }) + } + + static upsertVideoByPlaylistId(_id, videoData) { return db.playlists.updateAsync( - { playlistName }, + { _id }, { $push: { videos: videoData } }, { upsert: true } ) } - static upsertVideoIdsByPlaylistId(_id, videoIds) { + static upsertVideosByPlaylistId(_id, videos) { return db.playlists.updateAsync( { _id }, - { $push: { videos: { $each: videoIds } } }, + { $push: { videos: { $each: videos } } }, { upsert: true } ) } @@ -132,25 +136,35 @@ class Playlists { return db.playlists.removeAsync({ _id, protected: { $ne: true } }) } - static deleteVideoIdByPlaylistName(playlistName, videoId) { - return db.playlists.updateAsync( - { playlistName }, - { $pull: { videos: { videoId } } }, - { upsert: true } - ) + static deleteVideoIdByPlaylistId({ _id, videoId, playlistItemId }) { + if (playlistItemId != null) { + return db.playlists.updateAsync( + { _id }, + { $pull: { videos: { playlistItemId } } }, + { upsert: true } + ) + } else if (videoId != null) { + return db.playlists.updateAsync( + { _id }, + { $pull: { videos: { videoId } } }, + { upsert: true } + ) + } else { + throw new Error(`Both videoId & playlistItemId are absent, _id: ${_id}`) + } } - static deleteVideoIdsByPlaylistName(playlistName, videoIds) { + static deleteVideoIdsByPlaylistId(_id, videoIds) { return db.playlists.updateAsync( - { playlistName }, - { $pull: { videos: { $in: videoIds } } }, + { _id }, + { $pull: { videos: { videoId: { $in: videoIds } } } }, { upsert: true } ) } - static deleteAllVideosByPlaylistName(playlistName) { + static deleteAllVideosByPlaylistId(_id) { return db.playlists.updateAsync( - { playlistName }, + { _id }, { $set: { videos: [] } }, { upsert: true } ) @@ -161,7 +175,7 @@ class Playlists { } static deleteAll() { - return db.playlists.removeAsync({ protected: { $ne: true } }) + return db.playlists.removeAsync({}, { multi: true }) } static persist() { @@ -174,17 +188,15 @@ function compactAllDatastores() { Settings.persist(), History.persist(), Profiles.persist(), - Playlists.persist() + Playlists.persist(), ]) } -const baseHandlers = { - settings: Settings, - history: History, - profiles: Profiles, - playlists: Playlists, +export { + Settings as settings, + History as history, + Profiles as profiles, + Playlists as playlists, - compactAllDatastores + compactAllDatastores, } - -export default baseHandlers diff --git a/src/datastores/handlers/electron.js b/src/datastores/handlers/electron.js index ddb90ff82434e..31e8c96305ece 100644 --- a/src/datastores/handlers/electron.js +++ b/src/datastores/handlers/electron.js @@ -42,12 +42,12 @@ class History { ) } - static updateLastViewedPlaylist(videoId, lastViewedPlaylistId) { + static updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) { return ipcRenderer.invoke( IpcChannels.DB_HISTORY, { action: DBActions.HISTORY.UPDATE_PLAYLIST, - data: { videoId, lastViewedPlaylistId } + data: { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId } } ) } @@ -126,22 +126,29 @@ class Playlists { ) } - static upsertVideoByPlaylistName(playlistName, videoData) { + static upsert(playlist) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { action: DBActions.GENERAL.UPSERT, data: playlist } + ) + } + + static upsertVideoByPlaylistId(_id, videoData) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { action: DBActions.PLAYLISTS.UPSERT_VIDEO, - data: { playlistName, videoData } + data: { _id, videoData } } ) } - static upsertVideoIdsByPlaylistId(_id, videoIds) { + static upsertVideosByPlaylistId(_id, videos) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { - action: DBActions.PLAYLISTS.UPSERT_VIDEO_IDS, - data: { _id, videoIds } + action: DBActions.PLAYLISTS.UPSERT_VIDEOS, + data: { _id, videos } } ) } @@ -153,32 +160,32 @@ class Playlists { ) } - static deleteVideoIdByPlaylistName(playlistName, videoId) { + static deleteVideoIdByPlaylistId({ _id, videoId, playlistItemId }) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { action: DBActions.PLAYLISTS.DELETE_VIDEO_ID, - data: { playlistName, videoId } + data: { _id, videoId, playlistItemId } } ) } - static deleteVideoIdsByPlaylistName(playlistName, videoIds) { + static deleteVideoIdsByPlaylistId(_id, videoIds) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { action: DBActions.PLAYLISTS.DELETE_VIDEO_IDS, - data: { playlistName, videoIds } + data: { _id, videoIds } } ) } - static deleteAllVideosByPlaylistName(playlistName) { + static deleteAllVideosByPlaylistId(_id) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { action: DBActions.PLAYLISTS.DELETE_ALL_VIDEOS, - data: playlistName + data: _id } ) } @@ -198,11 +205,9 @@ class Playlists { } } -const handlers = { - settings: Settings, - history: History, - profiles: Profiles, - playlists: Playlists +export { + Settings as settings, + History as history, + Profiles as profiles, + Playlists as playlists } - -export default handlers diff --git a/src/datastores/handlers/index.js b/src/datastores/handlers/index.js index e96565d1fba76..df6ebffd9d808 100644 --- a/src/datastores/handlers/index.js +++ b/src/datastores/handlers/index.js @@ -1,18 +1,6 @@ -let handlers -if (process.env.IS_ELECTRON) { - handlers = require('./electron').default -} else { - handlers = require('./web').default -} - -const DBSettingHandlers = handlers.settings -const DBHistoryHandlers = handlers.history -const DBProfileHandlers = handlers.profiles -const DBPlaylistHandlers = handlers.playlists - export { - DBSettingHandlers, - DBHistoryHandlers, - DBProfileHandlers, - DBPlaylistHandlers -} + settings as DBSettingHandlers, + history as DBHistoryHandlers, + profiles as DBProfileHandlers, + playlists as DBPlaylistHandlers +} from 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB' diff --git a/src/datastores/handlers/web.js b/src/datastores/handlers/web.js index a81eb305d1dd9..d5feccc998d07 100644 --- a/src/datastores/handlers/web.js +++ b/src/datastores/handlers/web.js @@ -1,4 +1,4 @@ -import baseHandlers from './base' +import * as baseHandlers from './base' // TODO: Syncing // Syncing on the web would involve a different implementation @@ -33,8 +33,8 @@ class History { return baseHandlers.history.updateWatchProgress(videoId, watchProgress) } - static updateLastViewedPlaylist(videoId, lastViewedPlaylistId) { - return baseHandlers.history.updateLastViewedPlaylist(videoId, lastViewedPlaylistId) + static updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) { + return baseHandlers.history.updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) } static delete(videoId) { @@ -81,28 +81,32 @@ class Playlists { return baseHandlers.playlists.find() } - static upsertVideoByPlaylistName(playlistName, videoData) { - return baseHandlers.playlists.upsertVideoByPlaylistName(playlistName, videoData) + static upsert(playlist) { + return baseHandlers.playlists.upsert(playlist) } - static upsertVideoIdsByPlaylistId(_id, videoIds) { - return baseHandlers.playlists.upsertVideoIdsByPlaylistId(_id, videoIds) + static upsertVideoByPlaylistId(_id, videoData) { + return baseHandlers.playlists.upsertVideoByPlaylistId(_id, videoData) + } + + static upsertVideosByPlaylistId(_id, videoData) { + return baseHandlers.playlists.upsertVideosByPlaylistId(_id, videoData) } static delete(_id) { return baseHandlers.playlists.delete(_id) } - static deleteVideoIdByPlaylistName(playlistName, videoId) { - return baseHandlers.playlists.deleteVideoIdByPlaylistName(playlistName, videoId) + static deleteVideoIdByPlaylistId({ _id, videoId, playlistItemId }) { + return baseHandlers.playlists.deleteVideoIdByPlaylistId({ _id, videoId, playlistItemId }) } - static deleteVideoIdsByPlaylistName(playlistName, videoIds) { - return baseHandlers.playlists.deleteVideoIdsByPlaylistName(playlistName, videoIds) + static deleteVideoIdsByPlaylistId(_id, videoIds) { + return baseHandlers.playlists.deleteVideoIdsByPlaylistId(_id, videoIds) } - static deleteAllVideosByPlaylistName(playlistName) { - return baseHandlers.playlists.deleteAllVideosByPlaylistName(playlistName) + static deleteAllVideosByPlaylistId(_id) { + return baseHandlers.playlists.deleteAllVideosByPlaylistId(_id) } static deleteMultiple(ids) { @@ -114,11 +118,9 @@ class Playlists { } } -const handlers = { - settings: Settings, - history: History, - profiles: Profiles, - playlists: Playlists +export { + Settings as settings, + History as history, + Profiles as profiles, + Playlists as playlists } - -export default handlers diff --git a/src/datastores/index.js b/src/datastores/index.js index a7038fec96651..442fed6497bfb 100644 --- a/src/datastores/index.js +++ b/src/datastores/index.js @@ -22,10 +22,7 @@ if (process.env.IS_ELECTRON_MAIN) { dbPath = (dbName) => `${dbName}.db` } -const db = {} -db.settings = new Datastore({ filename: dbPath('settings'), autoload: true }) -db.profiles = new Datastore({ filename: dbPath('profiles'), autoload: true }) -db.playlists = new Datastore({ filename: dbPath('playlists'), autoload: true }) -db.history = new Datastore({ filename: dbPath('history'), autoload: true }) - -export default db +export const settings = new Datastore({ filename: dbPath('settings'), autoload: true }) +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 }) diff --git a/src/index.ejs b/src/index.ejs index 1a224ca4a4a23..40f7075a171a6 100644 --- a/src/index.ejs +++ b/src/index.ejs @@ -9,13 +9,6 @@ <% } %> - <% if (htmlWebpackPlugin.options.nodeModules) { %> - - <% } %> diff --git a/src/main/index.js b/src/main/index.js index 6b9e505e725ee..928dfd7b6597c 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -7,9 +7,10 @@ import path from 'path' import cp from 'child_process' import { IpcChannels, DBActions, SyncEvents } from '../constants' -import baseHandlers from '../datastores/handlers/base' +import * as baseHandlers from '../datastores/handlers/base' import { extractExpiryTimestamp, ImageCache } from './ImageCache' import { existsSync } from 'fs' +import asyncFs from 'fs/promises' import packageDetails from '../../package.json' @@ -64,7 +65,9 @@ function runApp() { const path = urlParts[1] if (path) { - visible = ['/playlist', '/channel', '/watch'].some(p => path.startsWith(p)) + visible = ['/channel', '/watch'].some(p => path.startsWith(p)) || + // Only show copy link entry for non user playlists + (path.startsWith('/playlist') && !/playlistType=user/.test(path)) } } else { visible = true @@ -103,17 +106,17 @@ function runApp() { let url if (toYouTube) { - url = `https://youtu.be/${id}` + url = new URL(`https://youtu.be/${id}`) } else { - url = `https://redirect.invidious.io/watch?v=${id}` + url = new URL(`https://redirect.invidious.io/watch?v=${id}`) } if (query) { const params = new URLSearchParams(query) - const newParams = new URLSearchParams() + const newParams = new URLSearchParams(url.search) let hasParams = false - if (params.has('playlistId')) { + if (params.has('playlistId') && params.get('playlistType') !== 'user') { newParams.set('list', params.get('playlistId')) hasParams = true } @@ -124,11 +127,11 @@ function runApp() { } if (hasParams) { - url += '?' + newParams.toString() + url.search = newParams.toString() } } - return url + return url.toString() } } } @@ -166,14 +169,17 @@ function runApp() { let mainWindow let startupUrl - app.commandLine.appendSwitch('enable-accelerated-video-decode') - app.commandLine.appendSwitch('enable-file-cookies') - app.commandLine.appendSwitch('ignore-gpu-blacklist') + if (process.platform === 'linux') { + // Enable hardware acceleration via VA-API + // https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/gpu/vaapi.md + app.commandLine.appendSwitch('enable-features', 'VaapiVideoDecodeLinuxGL') + } // command line switches need to be added before the app ready event first - // that means we can't use the normal settings system as that is asynchonous, + // that means we can't use the normal settings system as that is asynchronous, // doing it synchronously ensures that we add it before the event fires - const replaceHttpCache = existsSync(`${app.getPath('userData')}/experiment-replace-http-cache`) + const REPLACE_HTTP_CACHE_PATH = `${app.getPath('userData')}/experiment-replace-http-cache` + const replaceHttpCache = existsSync(REPLACE_HTTP_CACHE_PATH) if (replaceHttpCache) { // the http cache causes excessive disk usage during video playback // we've got a custom image cache to make up for disabling the http cache @@ -285,6 +291,13 @@ function runApp() { }) }) + session.defaultSession.cookies.set({ + url: 'https://www.youtube.com', + name: 'SOCS', + value: 'CAI', + 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?*'] } @@ -304,7 +317,7 @@ function runApp() { // 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 loger than 10 mins, we get throttled, as the audio track file sizes surpass that 10MiB limit. + // 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. @@ -336,93 +349,81 @@ function runApp() { callback({ requestHeaders }) }) + // when we create a real session on the watch page, youtube returns tracking cookies, which we definitely don't want + const trackingCookieRequestFilter = { urls: ['https://www.youtube.com/sw.js_data', 'https://www.youtube.com/iframe_api'] } + + session.defaultSession.webRequest.onHeadersReceived(trackingCookieRequestFilter, ({ responseHeaders }, callback) => { + if (responseHeaders) { + delete responseHeaders['set-cookie'] + } + // eslint-disable-next-line n/no-callback-literal + callback({ responseHeaders }) + }) + if (replaceHttpCache) { // in-memory image cache const imageCache = new ImageCache() - protocol.registerBufferProtocol('imagecache', (request, callback) => { - // Remove `imagecache://` prefix - const url = decodeURIComponent(request.url.substring(13)) - if (imageCache.has(url)) { - const cached = imageCache.get(url) + protocol.handle('imagecache', (request) => { + return new Promise((resolve, reject) => { + const url = decodeURIComponent(request.url.substring(13)) + if (imageCache.has(url)) { + const cached = imageCache.get(url) - callback(cached) - return - } + resolve(new Response(cached.data, { + headers: { 'content-type': cached.mimeType } + })) + return + } - const newRequest = net.request({ - method: request.method, - url - }) + const newRequest = net.request({ + method: request.method, + url + }) - // Electron doesn't allow certain headers to be set: - // https://www.electronjs.org/docs/latest/api/client-request#requestsetheadername-value - // also blacklist Origin and Referrer as we don't want to let YouTube know about them - const blacklistedHeaders = ['content-length', 'host', 'trailer', 'te', 'upgrade', 'cookie2', 'keep-alive', 'transfer-encoding', 'origin', 'referrer'] + // Electron doesn't allow certain headers to be set: + // https://www.electronjs.org/docs/latest/api/client-request#requestsetheadername-value + // also blacklist Origin and Referrer as we don't want to let YouTube know about them + const blacklistedHeaders = ['content-length', 'host', 'trailer', 'te', 'upgrade', 'cookie2', 'keep-alive', 'transfer-encoding', 'origin', 'referrer'] - for (const header of Object.keys(request.headers)) { - if (!blacklistedHeaders.includes(header.toLowerCase())) { - newRequest.setHeader(header, request.headers[header]) + for (const header of Object.keys(request.headers)) { + if (!blacklistedHeaders.includes(header.toLowerCase())) { + newRequest.setHeader(header, request.headers[header]) + } } - } - newRequest.on('response', (response) => { - const chunks = [] - response.on('data', (chunk) => { - chunks.push(chunk) - }) + newRequest.on('response', (response) => { + const chunks = [] + response.on('data', (chunk) => { + chunks.push(chunk) + }) - response.on('end', () => { - const data = Buffer.concat(chunks) + response.on('end', () => { + const data = Buffer.concat(chunks) - const expiryTimestamp = extractExpiryTimestamp(response.headers) - const mimeType = response.headers['content-type'] + const expiryTimestamp = extractExpiryTimestamp(response.headers) + const mimeType = response.headers['content-type'] - imageCache.add(url, mimeType, data, expiryTimestamp) + imageCache.add(url, mimeType, data, expiryTimestamp) - // eslint-disable-next-line n/no-callback-literal - callback({ - mimeType, - data: data + resolve(new Response(data, { + headers: { 'content-type': mimeType } + })) }) - }) - - response.on('error', (error) => { - console.error('image cache error', error) - - // error objects don't get serialised properly - // https://stackoverflow.com/a/53624454 - - const errorJson = JSON.stringify(error, (key, value) => { - if (value instanceof Error) { - return { - // Pull all enumerable properties, supporting properties on custom Errors - ...value, - // Explicitly pull Error's non-enumerable properties - name: value.name, - message: value.message, - stack: value.stack - } - } - return value + response.on('error', (error) => { + console.error('image cache error', error) + reject(error) }) + }) - // eslint-disable-next-line n/no-callback-literal - callback({ - statusCode: response.statusCode ?? 400, - mimeType: 'application/json', - data: Buffer.from(errorJson) - }) + newRequest.on('error', (err) => { + console.error(err) }) - }) - newRequest.on('error', (err) => { - console.error(err) + newRequest.end() }) - - newRequest.end() }) const imageRequestFilter = { urls: ['https://*/*', 'http://*/*'] } @@ -472,8 +473,12 @@ function runApp() { searchQueryText = null } = { }) { // Syncing new window background to theme choice. - const windowBackground = await baseHandlers.settings._findTheme().then(({ value }) => { - switch (value) { + const windowBackground = await baseHandlers.settings._findTheme().then((setting) => { + if (!setting) { + return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' + } + + switch (setting.value) { case 'dark': return '#212121' case 'light': @@ -484,6 +489,12 @@ function runApp() { return '#282a36' case 'catppuccin-mocha': return '#1e1e2e' + case 'pastel-pink': + return '#ffd1dc' + case 'hot-pink': + return '#de1c85' + case 'nordic': + return '#2b2f3a' case 'system': default: return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1' @@ -653,7 +664,7 @@ function runApp() { } }) - ipcMain.once('relaunchRequest', () => { + function relaunch() { if (process.env.NODE_ENV === 'development') { app.exit(parseInt(process.env.FREETUBE_RELAUNCH_EXIT_CODE)) return @@ -684,6 +695,10 @@ function runApp() { } app.quit() + } + + ipcMain.once('relaunchRequest', () => { + relaunch() }) nativeTheme.on('updated', () => { @@ -698,10 +713,12 @@ function runApp() { session.defaultSession.setProxy({ proxyRules: url }) + session.defaultSession.closeAllConnections() }) ipcMain.on(IpcChannels.DISABLE_PROXY, () => { session.defaultSession.setProxy({}) + session.defaultSession.closeAllConnections() }) ipcMain.on(IpcChannels.OPEN_EXTERNAL_LINK, (_, url) => { @@ -709,7 +726,8 @@ function runApp() { }) ipcMain.handle(IpcChannels.GET_SYSTEM_LOCALE, () => { - return app.getLocale() + // we should switch to getPreferredSystemLanguages at some point and iterate through until we find a supported locale + return app.getSystemLocale() }) ipcMain.handle(IpcChannels.GET_USER_DATA_PATH, () => { @@ -768,6 +786,22 @@ function runApp() { child.unref() }) + ipcMain.handle(IpcChannels.GET_REPLACE_HTTP_CACHE, () => { + return replaceHttpCache + }) + + ipcMain.once(IpcChannels.TOGGLE_REPLACE_HTTP_CACHE, async () => { + if (replaceHttpCache) { + await asyncFs.rm(REPLACE_HTTP_CACHE_PATH) + } else { + // create an empty file + const handle = await asyncFs.open(REPLACE_HTTP_CACHE_PATH, 'w') + await handle.close() + } + + relaunch() + }) + // ************************************************* // // DB related IPC calls // *********** // @@ -838,7 +872,7 @@ function runApp() { return null case DBActions.HISTORY.UPDATE_PLAYLIST: - await baseHandlers.history.updateLastViewedPlaylist(data.videoId, data.lastViewedPlaylistId) + await baseHandlers.history.updateLastViewedPlaylist(data.videoId, data.lastViewedPlaylistId, data.lastViewedPlaylistType, data.lastViewedPlaylistItemId) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, @@ -939,15 +973,27 @@ function runApp() { switch (action) { case DBActions.GENERAL.CREATE: await baseHandlers.playlists.create(data) - // TODO: Syncing (implement only when it starts being used) - // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.GENERAL.CREATE, data } + ) return null case DBActions.GENERAL.FIND: return await baseHandlers.playlists.find() + case DBActions.GENERAL.UPSERT: + await baseHandlers.playlists.upsert(data) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.GENERAL.UPSERT, data } + ) + return null + case DBActions.PLAYLISTS.UPSERT_VIDEO: - await baseHandlers.playlists.upsertVideoByPlaylistName(data.playlistName, data.videoData) + await baseHandlers.playlists.upsertVideoByPlaylistId(data._id, data.videoData) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, @@ -955,20 +1001,30 @@ function runApp() { ) return null - case DBActions.PLAYLISTS.UPSERT_VIDEO_IDS: - await baseHandlers.playlists.upsertVideoIdsByPlaylistId(data._id, data.videoIds) - // TODO: Syncing (implement only when it starts being used) - // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + case DBActions.PLAYLISTS.UPSERT_VIDEOS: + await baseHandlers.playlists.upsertVideosByPlaylistId(data._id, data.videos) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.PLAYLISTS.UPSERT_VIDEOS, data } + ) return null case DBActions.GENERAL.DELETE: await baseHandlers.playlists.delete(data) - // TODO: Syncing (implement only when it starts being used) - // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.GENERAL.DELETE, data } + ) return null case DBActions.PLAYLISTS.DELETE_VIDEO_ID: - await baseHandlers.playlists.deleteVideoIdByPlaylistName(data.playlistName, data.videoId) + await baseHandlers.playlists.deleteVideoIdByPlaylistId({ + _id: data._id, + videoId: data.videoId, + playlistItemId: data.playlistItemId, + }) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, @@ -977,13 +1033,13 @@ function runApp() { return null case DBActions.PLAYLISTS.DELETE_VIDEO_IDS: - await baseHandlers.playlists.deleteVideoIdsByPlaylistName(data.playlistName, data.videoIds) + await baseHandlers.playlists.deleteVideoIdsByPlaylistId(data._id, data.videoIds) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null case DBActions.PLAYLISTS.DELETE_ALL_VIDEOS: - await baseHandlers.playlists.deleteAllVideosByPlaylistName(data) + await baseHandlers.playlists.deleteAllVideosByPlaylistId(data) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null @@ -1024,6 +1080,8 @@ function runApp() { // ************************************************* // + let resourcesCleanUpDone = false + app.on('window-all-closed', () => { // Clean up resources (datastores' compaction + Electron cache and storage data clearing) cleanUpResources().finally(() => { @@ -1033,8 +1091,32 @@ function runApp() { }) }) - function cleanUpResources() { - return Promise.allSettled([ + if (process.platform === 'darwin') { + // `window-all-closed` doesn't fire for Cmd+Q + // https://www.electronjs.org/docs/latest/api/app#event-window-all-closed + // This is also fired when `app.quit` called + // Not using `before-quit` since that one is fired before windows are closed + app.on('will-quit', e => { + // Let app quit when the cleanup is finished + + if (resourcesCleanUpDone) { return } + + e.preventDefault() + cleanUpResources().finally(() => { + // Quit AFTER the resources cleanup is finished + // Which calls the listener again, which is why we have the variable + + app.quit() + }) + }) + } + + async function cleanUpResources() { + if (resourcesCleanUpDone) { + return + } + + await Promise.allSettled([ baseHandlers.compactAllDatastores(), session.defaultSession.clearCache(), session.defaultSession.clearStorageData({ @@ -1050,6 +1132,8 @@ function runApp() { ] }) ]) + + resourcesCleanUpDone = true } // MacOS event @@ -1297,6 +1381,13 @@ function runApp() { }, type: 'normal' }, + { + label: 'Profile Manager', + click: (_menuItem, browserWindow, _event) => { + navigateTo('/settings/profile/', browserWindow) + }, + type: 'normal' + }, ].filter((v) => v !== false), }, { diff --git a/src/renderer/App.css b/src/renderer/App.css index 76363e2111be3..0364353145afd 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -1,60 +1,67 @@ @font-face { font-family: Roboto; - src: url(assets/font/Roboto-Regular.ttf); + src: url("assets/font/Roboto-Regular.ttf"); } -#app { +.app { display: flex; flex-wrap: wrap; - font-family: 'Roboto', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - height: 100%; + font-family: Roboto, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + block-size: 100%; } .routerView { flex: 1 1 0%; - margin: 18px 10px; + margin-block: 18px; + margin-inline: 10px; } .banner { - width: 85%; - margin: 20px auto 0; + inline-size: 85%; + margin-block: 40px 0; + margin-inline: auto; +} + +.banner + .banner { + margin-block: 20px; } .banner-wrapper { - margin: 0 10px; + margin-block: 0; + margin-inline: 10px; } .flexBox { display: block; - user-select: all; - -webkit-user-select: all; + user-select: unset; } -#changeLogText { +.changeLogText { overflow-y: scroll; - height: 40vh; - display: block + block-size: 40vh; + display: block; } .fade-enter-active, .fade-leave-active { transition: opacity .15s; } + .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { opacity: 0; } -@media only screen and (max-width: 680px) { +@media only screen and (width <= 680px) { .routerView { - margin: 68px 8px 68px; + margin-block: 68px; + margin-inline: 8px; } .banner { - width: 80%; - margin-top: 20px; + inline-size: 90%; + margin-block: 60px 0; } .flexBox { - margin-top: 60px; - margin-bottom: -75px; + margin-block: 60px -75px; } } diff --git a/src/renderer/App.js b/src/renderer/App.js index 340e7b9825ece..009dc2cc46805 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -9,10 +9,14 @@ import FtPrompt from './components/ft-prompt/ft-prompt.vue' import FtButton from './components/ft-button/ft-button.vue' import FtToast from './components/ft-toast/ft-toast.vue' import FtProgressBar from './components/ft-progress-bar/ft-progress-bar.vue' +import FtPlaylistAddVideoPrompt from './components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.vue' +import FtCreatePlaylistPrompt from './components/ft-create-playlist-prompt/ft-create-playlist-prompt.vue' +import FtSearchFilters from './components/ft-search-filters/ft-search-filters.vue' import { marked } from 'marked' -import { Injectables, IpcChannels } from '../constants' +import { IpcChannels } from '../constants' import packageDetails from '../../package.json' import { openExternalLink, openInternalPath, showToast } from './helpers/utils' +import { translateWindowTitle } from './helpers/strings' let ipcRenderer = null @@ -28,17 +32,14 @@ export default defineComponent({ FtPrompt, FtButton, FtToast, - FtProgressBar - }, - provide: function () { - return { - [Injectables.SHOW_OUTLINES]: this.showOutlines - } + FtProgressBar, + FtPlaylistAddVideoPrompt, + FtCreatePlaylistPrompt, + FtSearchFilters }, data: function () { return { dataReady: false, - hideOutlines: true, showUpdatesBanner: false, showBlogBanner: false, showReleaseNotes: false, @@ -59,8 +60,12 @@ export default defineComponent({ showProgressBar: function () { return this.$store.getters.getShowProgressBar }, - isRightAligned: function () { - return this.locale === 'ar' || this.locale === 'he' + outlinesHidden: function () { + return this.$store.getters.getOutlinesHidden + }, + isLocaleRightToLeft: function () { + return this.locale === 'ar' || this.locale === 'fa' || this.locale === 'he' || + this.locale === 'ur' || this.locale === 'yi' || this.locale === 'ku' }, checkForUpdates: function () { return this.$store.getters.getCheckForUpdates @@ -68,15 +73,23 @@ export default defineComponent({ checkForBlogPosts: function () { return this.$store.getters.getCheckForBlogPosts }, + showAddToPlaylistPrompt: function () { + return this.$store.getters.getShowAddToPlaylistPrompt + }, + showCreatePlaylistPrompt: function () { + return this.$store.getters.getShowCreatePlaylistPrompt + }, + showSearchFilters: function () { + return this.$store.getters.getShowSearchFilters + }, windowTitle: function () { - const routeTitle = this.$route.meta.title - if (routeTitle !== 'Channel' && routeTitle !== 'Watch' && routeTitle !== 'Hashtag') { - let title = - this.$route.meta.path === '/home' - ? packageDetails.productName - : `${this.$t(this.$route.meta.title)} - ${packageDetails.productName}` + const routePath = this.$route.path + if (!routePath.startsWith('/channel/') && !routePath.startsWith('/watch/') && !routePath.startsWith('/hashtag/')) { + let title = translateWindowTitle(this.$route.meta.title, this.$i18n) if (!title) { title = packageDetails.productName + } else { + title = `${title} - ${packageDetails.productName}` } return title } else { @@ -110,6 +123,10 @@ export default defineComponent({ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' }, + landingPage: function() { + return '/' + this.$store.getters.getLandingPage + }, + externalLinkOpeningPromptNames: function () { return [ this.$t('Yes'), @@ -135,7 +152,7 @@ export default defineComponent({ $route () { // react to route changes... // Hide top nav filter panel on page change - this.$refs.topNav.hideFilters() + this.$refs.topNav?.hideFilters() } }, created () { @@ -177,7 +194,13 @@ export default defineComponent({ }) this.$router.afterEach((to, from) => { - this.$refs.topNav.navigateHistory() + this.$refs.topNav?.navigateHistory() + }) + + this.$router.onReady(() => { + if (this.$router.currentRoute.path === '/') { + this.$router.replace({ path: this.landingPage }) + } }) }) }, @@ -258,10 +281,7 @@ export default defineComponent({ }, checkExternalPlayer: async function () { - const payload = { - externalPlayer: this.externalPlayer - } - this.getExternalPlayerCmdArgumentsData(payload) + this.getExternalPlayerCmdArgumentsData() }, handleUpdateBannerClick: function (response) { @@ -290,7 +310,7 @@ export default defineComponent({ activateKeyboardShortcuts: function () { document.addEventListener('keydown', this.handleKeyboardShortcuts) document.addEventListener('mousedown', () => { - this.hideOutlines = true + this.hideOutlines() }) }, @@ -315,7 +335,7 @@ export default defineComponent({ } switch (event.key) { case 'Tab': - this.hideOutlines = false + this.showOutlines() break case 'L': case 'l': @@ -448,12 +468,7 @@ export default defineComponent({ default: { // Unknown URL type - let message = 'Unknown YouTube url type, cannot be opened in app' - if (this.$te(message) && this.$t(message) !== '') { - message = this.$t(message) - } - - showToast(message) + showToast(this.$t('Unknown YouTube url type, cannot be opened in app')) } } }) @@ -509,15 +524,11 @@ export default defineComponent({ setLocale: function() { document.documentElement.setAttribute('lang', this.locale) - }, - - /** - * provided to all child components, see `provide` near the top of this file - * after injecting it, they can show outlines during keyboard navigation - * e.g. cycling through tabs with the arrow keys - */ - showOutlines: function () { - this.hideOutlines = false + if (this.isLocaleRightToLeft) { + document.body.dir = 'rtl' + } else { + document.body.dir = 'ltr' + } }, ...mapMutations([ @@ -536,7 +547,9 @@ export default defineComponent({ 'setupListenersToSyncWindows', 'updateBaseTheme', 'updateMainColor', - 'updateSecColor' + 'updateSecColor', + 'showOutlines', + 'hideOutlines' ]) } }) diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 84ecb7ba348ab..dbd207735b66c 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -2,9 +2,10 @@
@@ -52,7 +53,7 @@ @click="showReleaseNotes = !showReleaseNotes" > @@ -74,6 +75,16 @@ :option-values="externalLinkOpeningPromptValues" @click="handleExternalLinkOpeningPromptAnswer" /> + + + + diff --git a/src/renderer/components/channel-about/channel-about.css b/src/renderer/components/channel-about/channel-about.css index 348768ec4aaa2..b7d8b49c9a7f6 100644 --- a/src/renderer/components/channel-about/channel-about.css +++ b/src/renderer/components/channel-about/channel-about.css @@ -1,6 +1,6 @@ .about { background-color: var(--card-bg-color); - margin-top: 0; + margin-block-start: 0; padding: 10px; position: relative; z-index: 1; @@ -27,16 +27,16 @@ .aboutTagLink { background-color: var(--secondary-card-bg-color); + color: var(--primary-text-color); border-radius: 7px; - color: inherit; padding: 7px; text-decoration: none; } .aboutDetails { - text-align: left; + text-align: start; } .aboutDetails th { - padding-right: 10px; + padding-inline-end: 10px; } diff --git a/src/renderer/components/channel-about/channel-about.js b/src/renderer/components/channel-about/channel-about.js index d0571276aae8e..3eb151e1b02bb 100644 --- a/src/renderer/components/channel-about/channel-about.js +++ b/src/renderer/components/channel-about/channel-about.js @@ -24,6 +24,10 @@ export default defineComponent({ type: Number, default: null }, + videos: { + type: Number, + default: null + }, location: { type: String, default: null @@ -61,10 +65,9 @@ export default defineComponent({ formattedViews: function () { return formatNumber(this.views) }, - }, - methods: { - goToChannel: function (id) { - this.$router.push({ path: `/channel/${id}` }) + + formattedVideos: function () { + return formatNumber(this.videos) }, } }) diff --git a/src/renderer/components/channel-about/channel-about.vue b/src/renderer/components/channel-about/channel-about.vue index d10f00138d14c..5829f8bb58293 100644 --- a/src/renderer/components/channel-about/channel-about.vue +++ b/src/renderer/components/channel-about/channel-about.vue @@ -12,7 +12,7 @@ /> diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index 6e8ec6c3df723..9cda4bad1504c 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -4,6 +4,7 @@ import { mapActions, mapMutations } from 'vuex' import FtButton from '../ft-button/ft-button.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtPrompt from '../ft-prompt/ft-prompt.vue' +import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' import { MAIN_PROFILE_ID } from '../../../constants' import { calculateColorLuminance, getRandomColor } from '../../helpers/colors' @@ -27,7 +28,8 @@ export default defineComponent({ 'ft-settings-section': FtSettingsSection, 'ft-button': FtButton, 'ft-flex-box': FtFlexBox, - 'ft-prompt': FtPrompt + 'ft-prompt': FtPrompt, + 'ft-toggle-switch': FtToggleSwitch, }, data: function () { return { @@ -38,7 +40,9 @@ export default defineComponent({ 'youtube', 'youtubeold', 'newpipe' - ] + ], + + shouldExportPlaylistForOlderVersions: false, } }, computed: { @@ -54,8 +58,8 @@ export default defineComponent({ allPlaylists: function () { return this.$store.getters.getAllPlaylists }, - historyCache: function () { - return this.$store.getters.getHistoryCache + historyCacheSorted: function () { + return this.$store.getters.getHistoryCacheSorted }, exportSubscriptionsPromptNames: function () { const exportFreeTube = this.$t('Settings.Data Settings.Export FreeTube') @@ -159,7 +163,7 @@ export default defineComponent({ const message = this.$t('Settings.Data Settings.Profile object has insufficient data, skipping item') showToast(message) } else { - if (profileObject.name === 'All Channels' || profileObject._id === MAIN_PROFILE_ID) { + if (profileObject._id === MAIN_PROFILE_ID) { this.primaryProfile.subscriptions = this.primaryProfile.subscriptions.concat(profileObject.subscriptions) this.primaryProfile.subscriptions = this.primaryProfile.subscriptions.filter((sub, index) => { const profileIndex = this.primaryProfile.subscriptions.findIndex((x) => { @@ -516,7 +520,7 @@ export default defineComponent({ ] } - await this.promptAndWriteToFile(options, subscriptionsDb, 'Subscriptions have been successfully exported') + await this.promptAndWriteToFile(options, subscriptionsDb, this.$t('Settings.Data Settings.Subscriptions have been successfully exported')) }, exportYouTubeSubscriptions: async function () { @@ -569,7 +573,7 @@ export default defineComponent({ return object }) - await this.promptAndWriteToFile(options, JSON.stringify(subscriptionsObject), 'Subscriptions have been successfully exported') + await this.promptAndWriteToFile(options, JSON.stringify(subscriptionsObject), this.$t('Settings.Data Settings.Subscriptions have been successfully exported')) }, exportOpmlYouTubeSubscriptions: async function () { @@ -597,7 +601,7 @@ export default defineComponent({ opmlData += '' - await this.promptAndWriteToFile(options, opmlData, 'Subscriptions have been successfully exported') + await this.promptAndWriteToFile(options, opmlData, this.$t('Settings.Data Settings.Subscriptions have been successfully exported')) }, exportCsvYouTubeSubscriptions: async function () { @@ -624,7 +628,7 @@ export default defineComponent({ }) exportText += '\n' - await this.promptAndWriteToFile(options, exportText, 'Subscriptions have been successfully exported') + await this.promptAndWriteToFile(options, exportText, this.$t('Settings.Data Settings.Subscriptions have been successfully exported')) }, exportNewPipeSubscriptions: async function () { @@ -658,7 +662,7 @@ export default defineComponent({ newPipeObject.subscriptions.push(subscription) }) - await this.promptAndWriteToFile(options, JSON.stringify(newPipeObject), 'Subscriptions have been successfully exported') + await this.promptAndWriteToFile(options, JSON.stringify(newPipeObject), this.$t('Settings.Data Settings.Subscriptions have been successfully exported')) }, importHistory: async function () { @@ -698,20 +702,28 @@ export default defineComponent({ textDecode.pop() const requiredKeys = [ - '_id', 'author', 'authorId', 'description', 'isLive', 'lengthSeconds', - 'paid', 'published', 'timeWatched', 'title', 'type', 'videoId', 'viewCount', - 'watchProgress' + 'watchProgress', + ] + + const optionalKeys = [ + // `_id` absent if marked as watched manually + '_id', + 'lastViewedPlaylistId', + ] + + const ignoredKeys = [ + 'paid', ] textDecode.forEach((history) => { @@ -723,15 +735,19 @@ export default defineComponent({ const historyObject = {} Object.keys(historyData).forEach((key) => { - if (!requiredKeys.includes(key)) { - showToast(`Unknown data key: ${key}`) - } else { + if (requiredKeys.includes(key) || optionalKeys.includes(key)) { historyObject[key] = historyData[key] + } else if (!ignoredKeys.includes(key)) { + showToast(`Unknown data key: ${key}`) } + // Else do not import the key }) - if (Object.keys(historyObject).length < (requiredKeys.length - 2)) { + const historyObjectKeysSet = new Set(Object.keys(historyObject)) + const missingKeys = requiredKeys.filter(x => !historyObjectKeysSet.has(x)) + if (missingKeys.length > 0) { showToast(this.$t('Settings.Data Settings.History object has insufficient data, skipping item')) + console.error('Missing Keys: ', missingKeys, historyData) } else { this.updateHistory(historyObject) } @@ -815,7 +831,6 @@ export default defineComponent({ historyObject.lengthSeconds = null historyObject.watchProgress = 1 historyObject.isLive = false - historyObject.paid = false this.updateHistory(historyObject) } @@ -825,7 +840,7 @@ export default defineComponent({ }, exportHistory: async function () { - const historyDb = this.historyCache.map((historyEntry) => { + const historyDb = this.historyCacheSorted.map((historyEntry) => { return JSON.stringify(historyEntry) }).join('\n') + '\n' const dateStr = getTodayDateStrLocalTimezone() @@ -841,7 +856,7 @@ export default defineComponent({ ] } - await this.promptAndWriteToFile(options, historyDb, 'All watched history has been successfully exported') + await this.promptAndWriteToFile(options, historyDb, this.$t('Settings.Data Settings.All watched history has been successfully exported')) }, importPlaylists: async function () { @@ -867,17 +882,48 @@ export default defineComponent({ showToast(`${message}: ${err}`) return } - const playlists = JSON.parse(data) + let playlists = null + + // for the sake of backwards compatibility, + // check if this is the old JSON array export (used until version 0.19.1), + // that didn't match the actual database format + const trimmedData = data.trim() + + if (trimmedData[0] === '[' && trimmedData[trimmedData.length - 1] === ']') { + playlists = JSON.parse(trimmedData) + } else { + // otherwise assume this is the correct database format, + // which is also what we export now (used in 0.20.0 and later versions) + data = data.split('\n') + data.pop() + + playlists = data.map(playlistJson => JSON.parse(playlistJson)) + } const requiredKeys = [ 'playlistName', - 'videos' + 'videos', ] const optionalKeys = [ + 'description', + 'createdAt', + ] + + const ignoredKeys = [ '_id', + 'title', + 'type', 'protected', - 'removeOnWatched' + 'lastUpdatedAt', + 'lastPlayedAt', + 'removeOnWatched', + + 'thumbnail', + 'channelName', + 'channelId', + 'playlistId', + 'videoCount', ] const requiredVideoKeys = [ @@ -885,15 +931,14 @@ export default defineComponent({ 'title', 'author', 'authorId', - 'published', 'lengthSeconds', 'timeAdded', - 'isLive', - 'paid', - 'type' + + // `playlistItemId` should be optional for backward compatibility + // 'playlistItemId', ] - playlists.forEach(async (playlistData) => { + playlists.forEach((playlistData) => { // We would technically already be done by the time the data is parsed, // however we want to limit the possibility of malicious data being sent // to the app, so we'll only grab the data we need here. @@ -901,58 +946,71 @@ export default defineComponent({ const playlistObject = {} Object.keys(playlistData).forEach((key) => { - if (!requiredKeys.includes(key) && !optionalKeys.includes(key)) { + if ([requiredKeys, optionalKeys, ignoredKeys].every((ks) => !ks.includes(key))) { const message = `${this.$t('Settings.Data Settings.Unknown data key')}: ${key}` showToast(message) } else if (key === 'videos') { const videoArray = [] playlistData.videos.forEach((video) => { - let hasAllKeys = true - requiredVideoKeys.forEach((videoKey) => { - if (!Object.keys(video).includes(videoKey)) { - hasAllKeys = false - } - }) + const videoPropertyKeys = Object.keys(video) + const videoObjectHasAllRequiredKeys = requiredVideoKeys.every((k) => videoPropertyKeys.includes(k)) - if (hasAllKeys) { + if (videoObjectHasAllRequiredKeys) { videoArray.push(video) } }) playlistObject[key] = videoArray - } else { + } else if (!ignoredKeys.includes(key)) { + // Do nothing for keys to be ignored playlistObject[key] = playlistData[key] } }) - const objectKeys = Object.keys(playlistObject) + const playlistObjectKeys = Object.keys(playlistObject) + const playlistObjectHasAllRequiredKeys = requiredKeys.every((k) => playlistObjectKeys.includes(k)) - if ((objectKeys.length < requiredKeys.length) || playlistObject.videos.length === 0) { - const message = this.$t('Settings.Data Settings.Playlist insufficient data', { playlist: playlistData.playlistName }) - showToast(message) - } else { + if (playlistObjectHasAllRequiredKeys) { const existingPlaylist = this.allPlaylists.find((playlist) => { return playlist.playlistName === playlistObject.playlistName }) if (existingPlaylist !== undefined) { playlistObject.videos.forEach((video) => { - const videoExists = existingPlaylist.videos.some((x) => { - return x.videoId === video.videoId - }) + let videoExists = false + if (video.playlistItemId != null) { + // Find by `playlistItemId` if present + videoExists = existingPlaylist.videos.some((x) => { + // Allow duplicate (by videoId) videos to be added + return x.videoId === video.videoId && x.playlistItemId === video.playlistItemId + }) + } else { + // Older playlist exports have no `playlistItemId` but have `timeAdded` + // Which might be duplicate for copied playlists with duplicate `videoId` + videoExists = existingPlaylist.videos.some((x) => { + // Allow duplicate (by videoId) videos to be added + return x.videoId === video.videoId && x.timeAdded === video.timeAdded + }) + } if (!videoExists) { + // Keep original `timeAdded` value const payload = { - playlistName: existingPlaylist.playlistName, - videoData: video + _id: existingPlaylist._id, + videoData: video, } this.addVideo(payload) } }) + // Update playlist's `lastUpdatedAt` + this.updatePlaylist({ _id: existingPlaylist._id }) } else { this.addPlaylist(playlistObject) } + } else { + const message = this.$t('Settings.Data Settings.Playlist insufficient data', { playlist: playlistData.playlistName }) + showToast(message) } }) @@ -973,7 +1031,60 @@ export default defineComponent({ ] } - await this.promptAndWriteToFile(options, JSON.stringify(this.allPlaylists), 'All playlists has been successfully exported') + const playlistsDb = this.allPlaylists.map(playlist => { + return JSON.stringify(playlist) + }).join('\n') + '\n'// a trailing line is expected + + await this.promptAndWriteToFile(options, playlistsDb, this.$t('Settings.Data Settings.All playlists has been successfully exported')) + }, + + exportPlaylistsForOlderVersionsSometimes: function () { + if (this.shouldExportPlaylistForOlderVersions) { + this.exportPlaylistsForOlderVersions() + } else { + this.exportPlaylists() + } + }, + + exportPlaylistsForOlderVersions: async function () { + const dateStr = getTodayDateStrLocalTimezone() + const exportFileName = 'freetube-playlists-as-single-favorites-playlist-' + dateStr + '.db' + + const options = { + defaultPath: exportFileName, + filters: [ + { + name: 'Database File', + extensions: ['db'] + } + ] + } + + const favoritesPlaylistData = { + playlistName: 'Favorites', + protected: true, + videos: [], + } + + this.allPlaylists.forEach((playlist) => { + playlist.videos.forEach((video) => { + const videoAlreadyAdded = favoritesPlaylistData.videos.some((v) => { + return v.videoId === video.videoId + }) + if (videoAlreadyAdded) { return } + + favoritesPlaylistData.videos.push( + Object.assign({ + // The "required" keys during import (but actually unused) in older versions + isLive: false, + paid: false, + published: '', + }, video) + ) + }) + }) + + await this.promptAndWriteToFile(options, JSON.stringify([favoritesPlaylistData]), this.$t('Settings.Data Settings.All playlists has been successfully exported')) }, convertOldFreeTubeFormatToNew(oldData) { @@ -983,7 +1094,7 @@ export default defineComponent({ for (const profile of channel.profile) { let index = convertedData.findIndex(p => p.name === profile.value) if (index === -1) { // profile doesn't exist yet - const randomBgColor = getRandomColor() + const randomBgColor = getRandomColor().value const contrastyTextColor = calculateColorLuminance(randomBgColor) convertedData.push({ name: profile.value, @@ -1007,7 +1118,7 @@ export default defineComponent({ return convertedData }, - promptAndWriteToFile: async function (saveOptions, content, successMessageKeySuffix) { + promptAndWriteToFile: async function (saveOptions, content, successMessage) { const response = await showSaveDialog(saveOptions) if (response.canceled || response.filePath === '') { // User canceled the save dialog @@ -1022,7 +1133,7 @@ export default defineComponent({ return } - showToast(this.$t(`Settings.Data Settings.${successMessageKeySuffix}`)) + showToast(successMessage) }, getChannelInfoInvidious: function (channelId) { @@ -1041,8 +1152,8 @@ export default defineComponent({ copyToClipboard(err) }) - if (process.env.IS_ELECTRON && this.backendFallback && this.backendPreference === 'invidious') { - showToast(this.$t('Falling back to the local API')) + if (process.env.SUPPORTS_LOCAL_API && this.backendFallback && this.backendPreference === 'invidious') { + showToast(this.$t('Falling back to Local API')) resolve(this.getChannelInfoLocal(channelId)) } else { resolve([]) @@ -1071,7 +1182,7 @@ export default defineComponent({ }) if (this.backendFallback && this.backendPreference === 'local') { - showToast(this.$t('Falling back to the Invidious API')) + showToast(this.$t('Falling back to Invidious API')) return await this.getChannelInfoInvidious(channelId) } else { return [] @@ -1141,7 +1252,8 @@ export default defineComponent({ 'updateShowProgressBar', 'updateHistory', 'addPlaylist', - 'addVideo' + 'addVideo', + 'updatePlaylist', ]), ...mapMutations([ diff --git a/src/renderer/components/data-settings/data-settings.vue b/src/renderer/components/data-settings/data-settings.vue index f9ad92d72087a..456c6d3585c57 100644 --- a/src/renderer/components/data-settings/data-settings.vue +++ b/src/renderer/components/data-settings/data-settings.vue @@ -2,24 +2,21 @@ - +

+ {{ $t('Subscriptions.Subscriptions') }} +

+ - - - @@ -29,20 +26,40 @@

- +

+ {{ $t('History.History') }} +

+ + - +

+ {{ $t('Playlists') }} +

+ + + + { + // Legacy support + if (typeof ch === 'string') { + return { name: ch, preferredName: '', icon: '' } + } + return ch + }) + }, + forbiddenTitles: function() { + return JSON.parse(this.$store.getters.getForbiddenTitles) }, hideSubscriptionsLiveTooltip: function () { return this.$t('Tooltips.Distraction Free Settings.Hide Subscriptions Live', { @@ -101,7 +129,10 @@ export default defineComponent({ subsection: this.$t('Settings.Distraction Free Settings.Sections.General'), settingsSection: this.$t('Settings.Distraction Free Settings.Distraction Free Settings') }) - } + }, + }, + mounted: function () { + this.verifyChannelsHidden() }, methods: { handleHideRecommendedVideos: function (value) { @@ -111,9 +142,54 @@ export default defineComponent({ this.updateHideRecommendedVideos(value) }, - handleChannelsHidden: function(value) { + handleInvalidChannel: function () { + showToast(this.$t('Settings.Distraction Free Settings.Hide Channels Invalid')) + }, + handleChannelAPIError: function () { + showToast(this.$t('Settings.Distraction Free Settings.Hide Channels API Error')) + }, + handleChannelsHidden: function (value) { this.updateChannelsHidden(JSON.stringify(value)) }, + handleForbiddenTitles: function (value) { + this.updateForbiddenTitles(JSON.stringify(value)) + }, + handleChannelsExists: function () { + showToast(this.$t('Settings.Distraction Free Settings.Hide Channels Already Exists')) + }, + validateChannelId: function (text) { + return checkYoutubeChannelId(text) + }, + findChannelTagInfo: async function (text) { + return await findChannelTagInfo(text, this.backendOptions) + }, + verifyChannelsHidden: async function () { + const channelsHiddenCpy = [...this.channelsHidden] + + for (let i = 0; i < channelsHiddenCpy.length; i++) { + const tag = this.channelsHidden[i] + + // if channel has been processed and confirmed as non existent, skip + if (tag.invalid) continue + + // process if no preferred name and is possibly a YouTube ID + if (tag.preferredName === '' && checkYoutubeChannelId(tag.name)) { + this.channelHiderDisabled = true + + const { preferredName, icon, iconHref, invalidId } = await this.findChannelTagInfo(tag.name) + if (invalidId) { + channelsHiddenCpy[i] = { name: tag.name, invalid: invalidId } + } else { + channelsHiddenCpy[i] = { name: tag.name, preferredName, icon, iconHref } + } + + // update on every tag in case it closes + this.handleChannelsHidden(channelsHiddenCpy) + } + } + + this.channelHiderDisabled = false + }, ...mapActions([ 'updateHideVideoViews', @@ -130,11 +206,13 @@ export default defineComponent({ 'updateDefaultTheatreMode', 'updateHideVideoDescription', 'updateHideComments', + 'updateHideCommentPhotos', 'updateHideLiveStreams', 'updateHideUpcomingPremieres', 'updateHideSharingActions', 'updateHideChapters', 'updateChannelsHidden', + 'updateForbiddenTitles', 'updateShowDistractionFreeTitles', 'updateHideFeaturedChannels', 'updateHideChannelShorts', @@ -144,7 +222,8 @@ export default defineComponent({ 'updateHideChannelReleases', 'updateHideSubscriptionsVideos', 'updateHideSubscriptionsShorts', - 'updateHideSubscriptionsLive' + 'updateHideSubscriptionsLive', + 'updateHideSubscriptionsCommunity', ]) } }) diff --git a/src/renderer/components/distraction-settings/distraction-settings.vue b/src/renderer/components/distraction-settings/distraction-settings.vue index dc1af66a7663e..6420d447c8916 100644 --- a/src/renderer/components/distraction-settings/distraction-settings.vue +++ b/src/renderer/components/distraction-settings/distraction-settings.vue @@ -66,6 +66,12 @@ :tooltip="hideLiveStreams ? hideSubscriptionsLiveTooltip : ''" v-on="!hideLiveStreams ? { change: updateHideSubscriptionsLive } : {}" /> +

+

+ + + diff --git a/src/renderer/components/download-settings/download-settings.css b/src/renderer/components/download-settings/download-settings.css index 0c5ccdad0b5ac..ebf029e3c1e6f 100644 --- a/src/renderer/components/download-settings/download-settings.css +++ b/src/renderer/components/download-settings/download-settings.css @@ -1,3 +1,3 @@ .folderDisplay { - width: 50vh; + inline-size: 50vh; } diff --git a/src/renderer/components/download-settings/download-settings.js b/src/renderer/components/download-settings/download-settings.js index e60d1a56c87f2..a0ad9efa067da 100644 --- a/src/renderer/components/download-settings/download-settings.js +++ b/src/renderer/components/download-settings/download-settings.js @@ -6,7 +6,6 @@ import FtSelect from '../ft-select/ft-select.vue' import FtButton from '../ft-button/ft-button.vue' import FtInput from '../ft-input/ft-input.vue' import { mapActions } from 'vuex' -import { ipcRenderer } from 'electron' import { IpcChannels } from '../../../constants' export default defineComponent({ @@ -21,7 +20,6 @@ export default defineComponent({ }, data: function () { return { - askForDownloadPath: false, downloadBehaviorValues: [ 'download', 'open' @@ -32,6 +30,9 @@ export default defineComponent({ downloadPath: function() { return this.$store.getters.getDownloadFolderPath }, + askForDownloadPath: function() { + return this.$store.getters.getDownloadAskPath + }, downloadBehaviorNames: function () { return [ this.$t('Settings.Download Settings.Download in app'), @@ -42,26 +43,26 @@ export default defineComponent({ return this.$store.getters.getDownloadBehavior } }, - mounted: function () { - this.askForDownloadPath = this.downloadPath === '' - }, methods: { handleDownloadingSettingChange: function (value) { - this.askForDownloadPath = value - if (value === true) { - this.updateDownloadFolderPath('') - } + this.updateDownloadAskPath(value) }, - chooseDownloadingFolder: async function() { - // only use with electron - const folder = await ipcRenderer.invoke( - IpcChannels.SHOW_OPEN_DIALOG, - { properties: ['openDirectory'] } - ) + chooseDownloadingFolder: async function () { + if (process.env.IS_ELECTRON) { + const { ipcRenderer } = require('electron') - this.updateDownloadFolderPath(folder.filePaths[0]) + const folder = await ipcRenderer.invoke( + IpcChannels.SHOW_OPEN_DIALOG, + { properties: ['openDirectory'] } + ) + + if (folder.canceled) return + + this.updateDownloadFolderPath(folder.filePaths[0]) + } }, ...mapActions([ + 'updateDownloadAskPath', 'updateDownloadFolderPath', 'updateDownloadBehavior' ]) diff --git a/src/renderer/components/experimental-settings/experimental-settings.css b/src/renderer/components/experimental-settings/experimental-settings.css index 58e50c3ab6762..2b20e6e674d38 100644 --- a/src/renderer/components/experimental-settings/experimental-settings.css +++ b/src/renderer/components/experimental-settings/experimental-settings.css @@ -1,6 +1,5 @@ .experimental-warning { text-align: center; font-weight: bold; - padding-left: 4%; - padding-right: 4% + padding-inline: 4% 4% } diff --git a/src/renderer/components/experimental-settings/experimental-settings.js b/src/renderer/components/experimental-settings/experimental-settings.js index b4d5e89239d98..1c313c86688e7 100644 --- a/src/renderer/components/experimental-settings/experimental-settings.js +++ b/src/renderer/components/experimental-settings/experimental-settings.js @@ -1,11 +1,9 @@ -import fs from 'fs/promises' import { defineComponent } from 'vue' import FtSettingsSection from '../ft-settings-section/ft-settings-section.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' import FtPrompt from '../ft-prompt/ft-prompt.vue' -import { pathExists } from '../../helpers/filesystem' -import { getUserDataPath } from '../../helpers/utils' +import { IpcChannels } from '../../../constants' export default defineComponent({ name: 'ExperimentalSettings', @@ -19,19 +17,16 @@ export default defineComponent({ return { replaceHttpCacheLoading: true, replaceHttpCache: false, - replaceHttpCachePath: '', showRestartPrompt: false } }, - mounted: function () { - getUserDataPath().then((userData) => { - this.replaceHttpCachePath = `${userData}/experiment-replace-http-cache` + mounted: async function () { + if (process.env.IS_ELECTRON) { + const { ipcRenderer } = require('electron') + this.replaceHttpCache = await ipcRenderer.invoke(IpcChannels.GET_REPLACE_HTTP_CACHE) + } - pathExists(this.replaceHttpCachePath).then((exists) => { - this.replaceHttpCache = exists - this.replaceHttpCacheLoading = false - }) - }) + this.replaceHttpCacheLoading = false }, methods: { handleRestartPrompt: function (value) { @@ -39,7 +34,7 @@ export default defineComponent({ this.showRestartPrompt = true }, - handleReplaceHttpCache: async function (value) { + handleReplaceHttpCache: function (value) { this.showRestartPrompt = false if (value === null || value === 'no') { @@ -47,16 +42,10 @@ export default defineComponent({ return } - if (this.replaceHttpCache) { - // create an empty file - const handle = await fs.open(this.replaceHttpCachePath, 'w') - await handle.close() - } else { - await fs.rm(this.replaceHttpCachePath) + if (process.env.IS_ELECTRON) { + const { ipcRenderer } = require('electron') + ipcRenderer.send(IpcChannels.TOGGLE_REPLACE_HTTP_CACHE) } - - const { ipcRenderer } = require('electron') - ipcRenderer.send('relaunchRequest') } } }) diff --git a/src/renderer/components/external-player-settings/external-player-settings.js b/src/renderer/components/external-player-settings/external-player-settings.js index c2fc0647d038f..d9e30a2b5d586 100644 --- a/src/renderer/components/external-player-settings/external-player-settings.js +++ b/src/renderer/components/external-player-settings/external-player-settings.js @@ -21,9 +21,15 @@ export default defineComponent({ computed: { externalPlayerNames: function () { const fallbackNames = this.$store.getters.getExternalPlayerNames - const nameTranslationKeys = this.$store.getters.getExternalPlayerNameTranslationKeys + const translations = [{ + name: 'None', + translatedValue: this.$t('Settings.External Player Settings.Players.None.Name') + }] - return nameTranslationKeys.map((translationKey, idx) => this.$te(translationKey) ? this.$t(translationKey) : fallbackNames[idx]) + return fallbackNames.map((name) => { + const translation = translations.find(e => e.name === name) + return translation ? translation.translatedValue : name + }) }, externalPlayerValues: function () { return this.$store.getters.getExternalPlayerValues @@ -37,6 +43,9 @@ export default defineComponent({ externalPlayerIgnoreWarnings: function () { return this.$store.getters.getExternalPlayerIgnoreWarnings }, + externalPlayerIgnoreDefaultArgs: function () { + return this.$store.getters.getExternalPlayerIgnoreDefaultArgs + }, externalPlayerCustomArgs: function () { return this.$store.getters.getExternalPlayerCustomArgs }, @@ -58,6 +67,7 @@ export default defineComponent({ 'updateExternalPlayer', 'updateExternalPlayerExecutable', 'updateExternalPlayerIgnoreWarnings', + 'updateExternalPlayerIgnoreDefaultArgs', 'updateExternalPlayerCustomArgs' ]) } diff --git a/src/renderer/components/external-player-settings/external-player-settings.vue b/src/renderer/components/external-player-settings/external-player-settings.vue index b3b98862d7da0..8254eff8fc76b 100644 --- a/src/renderer/components/external-player-settings/external-player-settings.vue +++ b/src/renderer/components/external-player-settings/external-player-settings.vue @@ -21,6 +21,14 @@ :tooltip="$t('Tooltips.External Player Settings.Ignore Warnings')" @change="updateExternalPlayerIgnoreWarnings" /> + { + // This is also fired when **hidden** + // No point doing anything if not visible + if (!isVisible) { return } + + this.$emit('load-next-page') + }, + intersection: { + // Only when it intersects with N% above bottom + rootMargin: '0% 0% 0% 0%', + }, + // Callback responsible for loading multiple pages + once: false, + } + }, + + }, +}) diff --git a/src/renderer/components/ft-auto-load-next-page-wrapper/ft-auto-load-next-page-wrapper.vue b/src/renderer/components/ft-auto-load-next-page-wrapper/ft-auto-load-next-page-wrapper.vue new file mode 100644 index 0000000000000..7a2714862c544 --- /dev/null +++ b/src/renderer/components/ft-auto-load-next-page-wrapper/ft-auto-load-next-page-wrapper.vue @@ -0,0 +1,16 @@ + + +