Merge branch 'FreeTubeApp:development' into development

This commit is contained in:
fdarcey 2024-02-09 20:02:05 -07:00 committed by GitHub
commit 7fddb9ea2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
143 changed files with 8637 additions and 2774 deletions

View File

@ -101,6 +101,7 @@ body:
- PortableApps (Unofficial)
- Scoop (Unofficial)
- Snapcraft (Unofficial)
- WAPT (Unofficial)
- winget (Unofficial)
- other
validations:

View File

@ -2,7 +2,7 @@
- '(visual bug)'
'B: Unofficial Download':
- '(AUR \(Unofficial\)|Chocolatey \(Unofficial\)|\.apk \(Android, FreeTubeCordova Unofficial\)|Homebrew \(Unofficial\)|PortableApps \(Unofficial\)|winget \(Unofficial\)|Scoop \(Unofficial\)|Snapcraft \(Unofficial\)|MPR \(Unofficial\)|Nix \(Unofficial\))'
- '(AUR \(Unofficial\)|Chocolatey \(Unofficial\)|\.apk \(Android, FreeTubeCordova Unofficial\)|Homebrew \(Unofficial\)|PortableApps \(Unofficial\)|WAPT \(Unofficial\)|winget \(Unofficial\)|Scoop \(Unofficial\)|Snapcraft \(Unofficial\)|MPR \(Unofficial\)|Nix \(Unofficial\))'
'B: keyboard control':
- '(keyboard control not working)'

View File

@ -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

View File

@ -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

View File

@ -11,7 +11,7 @@ jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: github/issue-labeler@v3.3
- uses: github/issue-labeler@v3.4
with:
configuration-path: .github/issue-labeler.yml
enable-versioned-regex: 0

View File

@ -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

View File

@ -101,6 +101,8 @@ These builds are maintained by the community. While they should be safe, downloa
* 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -68,10 +68,6 @@ class ProcessLocalesPlugin {
}
}
if (Object.prototype.hasOwnProperty.call(data, 'Locale Name')) {
delete data['Locale Name']
}
this.removeEmptyValues(data)
let filename = `${this.outputDir}/${locale}.json`

View File

@ -2,7 +2,7 @@ const { name, productName } = require('../package.json')
const config = {
appId: `io.freetubeapp.${name}`,
copyright: 'Copyleft © 2020-2023 freetubeapp@protonmail.com',
copyright: 'Copyleft © 2020-2024 freetubeapp@protonmail.com',
// asar: false,
// compression: 'store',
productName,

View File

@ -1,5 +1,5 @@
const path = require('path')
const { readFileSync } = require('fs')
const { readFileSync, readdirSync } = require('fs')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
@ -117,7 +117,8 @@ const config = {
new webpack.DefinePlugin({
'process.env.IS_ELECTRON': true,
'process.env.IS_ELECTRON_MAIN': false,
'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames)
'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', '')))
}),
new HtmlWebpackPlugin({
excludeChunks: ['processTaskWorker'],

View File

@ -1,5 +1,8 @@
{
"vueCompilerOptions": {
"target": 2.7
},
"compilerOptions": {
"strictNullChecks": true
}
}

View File

@ -57,12 +57,12 @@
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^2.0.10",
"@seald-io/nedb": "^4.0.3",
"@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": "^11.1.0",
"marked": "^11.2.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"swiper": "^11.0.5",
@ -72,62 +72,62 @@
"videojs-mobile-ui": "^0.8.0",
"videojs-overlay": "^3.1.0",
"videojs-vtt-thumbnails-freetube": "0.0.15",
"vue": "^2.7.15",
"vue": "^2.7.16",
"vue-i18n": "^8.28.2",
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
"vuex": "^3.6.2",
"youtubei.js": "^8.0.0"
"youtubei.js": "^9.0.2"
},
"devDependencies": {
"@babel/core": "^7.23.6",
"@babel/eslint-parser": "^7.23.3",
"@babel/core": "^7.23.9",
"@babel/eslint-parser": "^7.23.10",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/preset-env": "^7.23.6",
"@double-great/stylelint-a11y": "^2.0.2",
"@babel/preset-env": "^7.23.9",
"@double-great/stylelint-a11y": "^3.0.1",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"electron": "^28.0.0",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"electron": "^28.2.1",
"electron-builder": "^24.9.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsonc": "^2.11.1",
"eslint-plugin-n": "^16.4.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-jsonc": "^2.13.0",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-unicorn": "^49.0.0",
"eslint-plugin-vue": "^9.19.2",
"eslint-plugin-vuejs-accessibility": "^2.2.0",
"eslint-plugin-yml": "^1.11.0",
"html-webpack-plugin": "^5.5.4",
"eslint-plugin-unicorn": "^50.0.1",
"eslint-plugin-vue": "^9.21.1",
"eslint-plugin-vuejs-accessibility": "^2.2.1",
"eslint-plugin-yml": "^1.12.2",
"html-webpack-plugin": "^5.6.0",
"js-yaml": "^4.1.0",
"json-minimizer-webpack-plugin": "^4.0.0",
"lefthook": "^1.5.5",
"mini-css-extract-plugin": "^2.7.6",
"json-minimizer-webpack-plugin": "^5.0.0",
"lefthook": "^1.6.1",
"mini-css-extract-plugin": "^2.8.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.32",
"postcss": "^8.4.33",
"postcss-scss": "^4.0.9",
"prettier": "^2.8.8",
"rimraf": "^5.0.5",
"sass": "^1.69.5",
"sass-loader": "^13.3.2",
"stylelint": "^15.11.0",
"stylelint-config-sass-guidelines": "^10.0.0",
"stylelint-config-standard": "^34.0.0",
"stylelint-high-performance-animation": "^1.9.0",
"stylelint-use-logical-spec": "^5.0.0",
"sass": "^1.70.0",
"sass-loader": "^14.1.0",
"stylelint": "^16.2.1",
"stylelint-config-sass-guidelines": "^11.0.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.2",
"vue-eslint-parser": "^9.4.2",
"vue-loader": "^15.10.0",
"webpack": "^5.89.0",
"webpack": "^5.90.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"webpack-watch-external-files-plugin": "^2.0.0",
"webpack-watch-external-files-plugin": "^3.0.0",
"yaml-eslint-parser": "^1.2.2"
}
}

View File

@ -44,10 +44,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,7 +66,7 @@ const SyncEvents = {
PLAYLISTS: {
UPSERT_VIDEO: 'sync-playlists-upsert-video',
DELETE_VIDEO: 'sync-playlists-delete-video'
DELETE_VIDEO: 'sync-playlists-delete-video',
}
}

View File

@ -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) {
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 deleteVideoIdsByPlaylistId(_id, videoIds) {
return db.playlists.updateAsync(
{ playlistName },
{ $pull: { videos: { videoId } } },
{ _id },
{ $pull: { videos: { videoId: { $in: videoIds } } } },
{ upsert: true }
)
}
static deleteVideoIdsByPlaylistName(playlistName, videoIds) {
static deleteAllVideosByPlaylistId(_id) {
return db.playlists.updateAsync(
{ playlistName },
{ $pull: { videos: { $in: videoIds } } },
{ upsert: true }
)
}
static deleteAllVideosByPlaylistName(playlistName) {
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,7 +188,7 @@ function compactAllDatastores() {
Settings.persist(),
History.persist(),
Profiles.persist(),
Playlists.persist()
Playlists.persist(),
])
}
@ -184,7 +198,7 @@ const baseHandlers = {
profiles: Profiles,
playlists: Playlists,
compactAllDatastores
compactAllDatastores,
}
export default baseHandlers

View File

@ -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
}
)
}

View File

@ -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) {

View File

@ -166,9 +166,11 @@ 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')
}
// Work around for context menus in the devtools being displayed behind the window
// https://github.com/electron/electron/issues/38790
@ -491,6 +493,8 @@ function runApp() {
return '#ffd1dc'
case 'hot-pink':
return '#de1c85'
case 'nordic':
return '#2b2f3a'
case 'system':
default:
return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1'
@ -718,7 +722,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, () => {
@ -847,7 +852,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,
@ -948,15 +953,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,
@ -964,20 +981,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,
@ -986,13 +1013,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

View File

@ -9,6 +9,8 @@ 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 { marked } from 'marked'
import { IpcChannels } from '../constants'
import packageDetails from '../../package.json'
@ -28,7 +30,9 @@ export default defineComponent({
FtPrompt,
FtButton,
FtToast,
FtProgressBar
FtProgressBar,
FtPlaylistAddVideoPrompt,
FtCreatePlaylistPrompt,
},
data: function () {
return {
@ -66,6 +70,12 @@ export default defineComponent({
checkForBlogPosts: function () {
return this.$store.getters.getCheckForBlogPosts
},
showAddToPlaylistPrompt: function () {
return this.$store.getters.getShowAddToPlaylistPrompt
},
showCreatePlaylistPrompt: function () {
return this.$store.getters.getShowCreatePlaylistPrompt
},
windowTitle: function () {
const routeTitle = this.$route.meta.title
if (routeTitle !== 'Channel' && routeTitle !== 'Watch' && routeTitle !== 'Hashtag') {

View File

@ -74,6 +74,12 @@
:option-values="externalLinkOpeningPromptValues"
@click="handleExternalLinkOpeningPromptAnswer"
/>
<ft-playlist-add-video-prompt
v-if="showAddToPlaylistPrompt"
/>
<ft-create-playlist-prompt
v-if="showCreatePlaylistPrompt"
/>
<ft-toast />
<ft-progress-bar
v-if="showProgressBar"

View File

@ -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: {
@ -882,13 +886,28 @@ export default defineComponent({
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 = [
@ -896,14 +915,14 @@ export default defineComponent({
'title',
'author',
'authorId',
'published',
'lengthSeconds',
'timeAdded',
'isLive',
'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.
@ -911,58 +930,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)
}
})
@ -986,6 +1018,55 @@ export default defineComponent({
await this.promptAndWriteToFile(options, JSON.stringify(this.allPlaylists), '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]), 'All playlists has been successfully exported')
},
convertOldFreeTubeFormatToNew(oldData) {
const convertedData = []
for (const channel of oldData) {
@ -1151,7 +1232,8 @@ export default defineComponent({
'updateShowProgressBar',
'updateHistory',
'addPlaylist',
'addVideo'
'addVideo',
'updatePlaylist',
]),
...mapMutations([

View File

@ -49,7 +49,17 @@
/>
<ft-button
:label="$t('Settings.Data Settings.Export Playlists')"
@click="exportPlaylists"
@click="exportPlaylistsForOlderVersionsSometimes"
/>
</ft-flex-box>
<ft-flex-box>
<ft-toggle-switch
:label="$t('Settings.Data Settings.Export Playlists For Older FreeTube Versions.Label')"
:compact="true"
:default-value="shouldExportPlaylistForOlderVersions"
:tooltip="$t('Settings.Data Settings.Export Playlists For Older FreeTube Versions.Tooltip')"
:tooltip-allow-newlines="true"
@change="shouldExportPlaylistForOlderVersions = !shouldExportPlaylistForOlderVersions"
/>
</ft-flex-box>
<ft-prompt

View File

@ -120,13 +120,16 @@ export default defineComponent({
return ch
})
},
forbiddenTitles: function() {
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
hideSubscriptionsLiveTooltip: function () {
return this.$t('Tooltips.Distraction Free Settings.Hide Subscriptions Live', {
appWideSetting: this.$t('Settings.Distraction Free Settings.Hide Live Streams'),
subsection: this.$t('Settings.Distraction Free Settings.Sections.General'),
settingsSection: this.$t('Settings.Distraction Free Settings.Distraction Free Settings')
})
}
},
},
mounted: function () {
this.verifyChannelsHidden()
@ -148,6 +151,9 @@ export default defineComponent({
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'))
},
@ -206,6 +212,7 @@ export default defineComponent({
'updateHideSharingActions',
'updateHideChapters',
'updateChannelsHidden',
'updateForbiddenTitles',
'updateShowDistractionFreeTitles',
'updateHideFeaturedChannels',
'updateHideChannelShorts',

View File

@ -239,12 +239,24 @@
:tooltip="$t('Tooltips.Distraction Free Settings.Hide Channels')"
:validate-tag-name="validateChannelId"
:find-tag-info="findChannelTagInfo"
:are-channel-tags="true"
@invalid-name="handleInvalidChannel"
@error-find-tag-info="handleChannelAPIError"
@change="handleChannelsHidden"
@already-exists="handleChannelsExists"
/>
</ft-flex-box>
<ft-flex-box>
<ft-input-tags
:label="$t('Settings.Distraction Free Settings.Hide Videos and Playlists Containing Text')"
:tag-name-placeholder="$t('Settings.Distraction Free Settings.Hide Videos and Playlists Containing Text Placeholder')"
:show-action-button="true"
:tag-list="forbiddenTitles"
:min-input-length="3"
:tooltip="$t('Tooltips.Distraction Free Settings.Hide Videos and Playlists Containing Text')"
@change="handleForbiddenTitles"
/>
</ft-flex-box>
</ft-settings-section>
</template>

View File

@ -37,6 +37,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 +61,7 @@ export default defineComponent({
'updateExternalPlayer',
'updateExternalPlayerExecutable',
'updateExternalPlayerIgnoreWarnings',
'updateExternalPlayerIgnoreDefaultArgs',
'updateExternalPlayerCustomArgs'
])
}

View File

@ -21,6 +21,14 @@
:tooltip="$t('Tooltips.External Player Settings.Ignore Warnings')"
@change="updateExternalPlayerIgnoreWarnings"
/>
<ft-toggle-switch
:label="$t('Settings.External Player Settings.Ignore Default Arguments')"
:default-value="externalPlayerIgnoreDefaultArgs"
:disabled="externalPlayer===''"
:compact="true"
:tooltip="$t('Tooltips.External Player Settings.Ignore Default Arguments')"
@change="updateExternalPlayerIgnoreDefaultArgs"
/>
</ft-flex-box>
<ft-flex-box
v-if="externalPlayer !== ''"

View File

@ -25,6 +25,7 @@
block-size: 50px;
border-radius: 100%;
-webkit-border-radius: 100%;
object-fit: cover;
}
.selected {

View File

@ -25,6 +25,10 @@ export default defineComponent({
appearance: {
type: String,
required: true
},
hideForbiddenTitles: {
type: Boolean,
default: true
}
},
data: function () {
@ -44,6 +48,15 @@ export default defineComponent({
computed: {
listType: function () {
return this.$store.getters.getListType
},
forbiddenTitles() {
if (!this.hideForbiddenTitles) { return [] }
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
hideVideo() {
return this.forbiddenTitles.some((text) => this.data.postContent.content.title?.toLowerCase().includes(text.toLowerCase()))
}
},
created: function () {

View File

@ -13,6 +13,12 @@
box-sizing: border-box;
}
.hiddenVideo {
font-style: italic;
opacity: 0.85;
text-align: center;
}
.communityImage {
block-size: 100%;
inline-size: 100%;
@ -138,5 +144,5 @@
}
.sliderContainer {
display: block;
display: grid;
}

View File

@ -56,27 +56,25 @@
class="postText"
v-html="postText"
/>
<div class="sliderContainer">
<swiper-container
v-if="type === 'multiImage' && postContent.content.length > 0"
ref="swiperContainer"
init="false"
class="slider"
<swiper-container
v-if="type === 'multiImage' && postContent.content.length > 0"
ref="swiperContainer"
init="false"
class="sliderContainer"
>
<swiper-slide
v-for="(img, index) in postContent.content"
:key="index"
lazy="true"
>
<swiper-slide
v-for="(img, index) in postContent.content"
:key="index"
lazy="true"
<img
:src="getBestQualityImage(img)"
class="communityImage"
alt=""
loading="lazy"
>
<img
:src="getBestQualityImage(img)"
class="communityImage"
alt=""
loading="lazy"
>
</swiper-slide>
</swiper-container>
</div>
</swiper-slide>
</swiper-container>
<div
v-if="type === 'image' && postContent.content.length > 0"
>
@ -90,9 +88,16 @@
v-if="type === 'video'"
>
<ft-list-video
v-if="!hideVideo"
:data="data.postContent.content"
appearance=""
/>
<p
v-else
class="hiddenVideo"
>
{{ '[' + $t('Channel.Community.Video hidden by FreeTube') + ']' }}
</p>
</div>
<div
v-if="type === 'poll' || type === 'quiz'"

View File

@ -0,0 +1,8 @@
.center {
text-align: center;
}
.playlistNameInput {
inline-size: 80%;
max-inline-size: 600px;
}

View File

@ -0,0 +1,79 @@
import { defineComponent } from 'vue'
import { mapActions } from 'vuex'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtPrompt from '../ft-prompt/ft-prompt.vue'
import FtButton from '../ft-button/ft-button.vue'
import FtInput from '../ft-input/ft-input.vue'
import FtPlaylistSelector from '../ft-playlist-selector/ft-playlist-selector.vue'
import {
showToast,
} from '../../helpers/utils'
export default defineComponent({
name: 'FtCreatePlaylistPrompt',
components: {
FtFlexBox,
FtPrompt,
FtButton,
FtInput,
FtPlaylistSelector
},
data: function () {
return {
playlistName: '',
}
},
computed: {
allPlaylists: function () {
return this.$store.getters.getAllPlaylists
},
newPlaylistVideoObject: function () {
return this.$store.getters.getNewPlaylistVideoObject
},
},
mounted: function () {
this.playlistName = this.newPlaylistVideoObject.title
// Faster to input required playlist name
this.$refs.playlistNameInput.focus()
},
methods: {
createNewPlaylist: function () {
if (this.playlistName === '') {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["Playlist name cannot be empty. Please input a name."]'))
return
}
const nameExists = this.allPlaylists.findIndex((playlist) => {
return playlist.playlistName === this.playlistName
})
if (nameExists !== -1) {
showToast(this.$t('User Playlists.CreatePlaylistPrompt.Toast["There is already a playlist with this name. Please pick a different name."]'))
return
}
const playlistObject = {
playlistName: this.playlistName,
protected: false,
description: '',
videos: [],
}
try {
this.addPlaylist(playlistObject)
showToast(this.$t('User Playlists.CreatePlaylistPrompt.Toast["Playlist {playlistName} has been successfully created."]', {
playlistName: this.playlistName,
}))
} catch (e) {
showToast(this.$t('User Playlists.CreatePlaylistPrompt.Toast["There was an issue with creating the playlist."]'))
console.error(e)
} finally {
this.hideCreatePlaylistPrompt()
}
},
...mapActions([
'addPlaylist',
'hideCreatePlaylistPrompt',
])
}
})

View File

@ -0,0 +1,34 @@
<template>
<ft-prompt
@click="hideCreatePlaylistPrompt"
>
<h2 class="center">
{{ $t('User Playlists.CreatePlaylistPrompt.New Playlist Name') }}
</h2>
<ft-flex-box>
<ft-input
ref="playlistNameInput"
:placeholder="$t('User Playlists.Playlist Name')"
:show-action-button="false"
:show-label="false"
:value="playlistName"
class="playlistNameInput"
@input="(input) => playlistName = input"
@click="createNewPlaylist"
/>
</ft-flex-box>
<ft-flex-box>
<ft-button
:label="$t('User Playlists.CreatePlaylistPrompt.Create')"
@click="createNewPlaylist"
/>
<ft-button
:label="$t('User Playlists.Cancel')"
@click="hideCreatePlaylistPrompt"
/>
</ft-flex-box>
</ft-prompt>
</template>
<script src="./ft-create-playlist-prompt.js" />
<style scoped src="./ft-create-playlist-prompt.css" />

View File

@ -13,6 +13,10 @@ export default defineComponent({
type: Array,
required: true
},
dataType: {
type: String,
default: null,
},
display: {
type: String,
required: false,
@ -26,6 +30,10 @@ export default defineComponent({
type: Boolean,
default: true,
},
hideForbiddenTitles: {
type: Boolean,
default: true
}
},
computed: {
listType: function () {

View File

@ -4,13 +4,15 @@
>
<ft-list-lazy-wrapper
v-for="(result, index) in data"
:key="`${result.type}-${result.videoId || result.playlistId || result.postId || result.id || result.authorId || result.title}-${index}`"
:key="`${dataType || result.type}-${result.videoId || result.playlistId || result.postId || result.id || result._id || result.authorId || result.title}-${index}-${result.lastUpdatedAt || 0}`"
appearance="result"
:data="result"
:data-type="dataType || result.type"
:first-screen="index < 16"
:layout="displayValue"
:show-video-with-last-viewed-playlist="showVideoWithLastViewedPlaylist"
:use-channels-hidden-preference="useChannelsHiddenPreference"
:hide-forbidden-titles="hideForbiddenTitles"
/>
</ft-auto-grid>
</template>

View File

@ -1,5 +1,6 @@
import { defineComponent } from 'vue'
import FtInput from '../ft-input/ft-input.vue'
import { showToast } from '../../helpers/utils'
export default defineComponent({
name: 'FtInputTags',
@ -7,6 +8,10 @@ export default defineComponent({
'ft-input': FtInput,
},
props: {
areChannelTags: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
@ -23,6 +28,10 @@ export default defineComponent({
type: String,
required: true
},
minInputLength: {
type: Number,
default: 1
},
showActionButton: {
type: Boolean,
default: true
@ -46,6 +55,30 @@ export default defineComponent({
},
methods: {
updateTags: async function (text, _e) {
if (this.areChannelTags) {
await this.updateChannelTags(text, _e)
return
}
// add tag and update tag list
const trimmedText = text.trim()
if (this.minInputLength > trimmedText.length) {
showToast(this.$tc('Trimmed input must be at least N characters long', this.minInputLength, { length: this.minInputLength }))
return
}
if (this.tagList.includes(trimmedText)) {
showToast(this.$t('Tag already exists', { tagName: trimmedText }))
return
}
const newList = this.tagList.slice(0)
newList.push(trimmedText)
this.$emit('change', newList)
// clear input box
this.$refs.tagNameInput.handleClearTextClick()
},
updateChannelTags: async function (text, _e) {
// get text without spaces after last '/' in url, if any
const name = text.split('/').pop().trim()
@ -73,6 +106,20 @@ export default defineComponent({
this.$refs.tagNameInput.handleClearTextClick()
},
removeTag: function (tag) {
if (this.areChannelTags) {
this.removeChannelTag(tag)
return
}
// Remove tag from list
const tagName = tag.trim()
if (this.tagList.includes(tagName)) {
const newList = this.tagList.slice(0)
const index = newList.indexOf(tagName)
newList.splice(index, 1)
this.$emit('change', newList)
}
},
removeChannelTag: function (tag) {
// Remove tag from list
if (this.tagList.some((tmpTag) => tmpTag.name === tag.name)) {
const newList = this.tagList.filter((tmpTag) => tmpTag.name !== tag.name)

View File

@ -13,6 +13,7 @@
:disabled="disabled"
:placeholder="tagNamePlaceholder"
:label="label"
:min-input-length="minInputLength"
:show-label="true"
:tooltip="tooltip"
:show-action-button="showActionButton"
@ -26,18 +27,21 @@
v-for="tag in tagList"
:key="tag.id"
>
<router-link
v-if="tag.icon"
:to="tag.iconHref ?? ''"
class="tag-icon-link"
>
<img
:src="tag.icon"
alt=""
class="tag-icon"
<template v-if="areChannelTags">
<router-link
v-if="tag.icon"
:to="tag.iconHref ?? ''"
class="tag-icon-link"
>
</router-link>
<span>{{ (tag.preferredName) ? tag.preferredName : tag.name }}</span>
<img
:src="tag.icon"
alt=""
class="tag-icon"
>
</router-link>
<span>{{ (tag.preferredName) ? tag.preferredName : tag.name }}</span>
</template>
<span v-else>{{ tag }}</span>
<font-awesome-icon
v-if="!disabled"
:icon="['fas', 'fa-times']"

View File

@ -1,6 +1,7 @@
import { defineComponent } from 'vue'
import FtTooltip from '../ft-tooltip/ft-tooltip.vue'
import { mapActions } from 'vuex'
import FtTooltip from '../ft-tooltip/ft-tooltip.vue'
import { isKeyboardEventKeyPrintableChar, isNullOrEmpty } from '../../helpers/strings'
export default defineComponent({
@ -143,7 +144,9 @@ export default defineComponent({
methods: {
handleClick: function (e) {
// No action if no input text
if (!this.inputDataPresent) { return }
if (!this.inputDataPresent) {
return
}
this.searchState.showOptions = false
this.searchState.selectedOption = -1

View File

@ -19,6 +19,10 @@ export default defineComponent({
type: Object,
required: true
},
dataType: {
type: String,
default: null,
},
appearance: {
type: String,
required: true
@ -39,6 +43,10 @@ export default defineComponent({
type: Boolean,
default: true,
},
hideForbiddenTitles: {
type: Boolean,
default: true
},
},
data: function () {
return {
@ -61,6 +69,10 @@ export default defineComponent({
return ch
})
},
forbiddenTitles: function() {
if (!this.hideForbiddenTitles) { return [] }
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
hideUpcomingPremieres: function () {
return this.$store.getters.getHideUpcomingPremieres
},
@ -71,10 +83,11 @@ export default defineComponent({
*/
showResult: function () {
const { data } = this
if (!data.type) {
const dataType = this.finalDataType
if (!dataType) {
return false
}
if (data.type === 'video' || data.type === 'shortVideo') {
if (dataType === 'video' || dataType === 'shortVideo') {
if (this.hideLiveStreams && (data.liveNow || data.lengthSeconds == null)) {
// hide livestreams
return false
@ -97,7 +110,10 @@ export default defineComponent({
// hide videos by author
return false
}
} else if (data.type === 'channel') {
if (this.forbiddenTitles.some((text) => this.data.title?.toLowerCase().includes(text.toLowerCase()))) {
return false
}
} else if (dataType === 'channel') {
const attrsToCheck = [
// Local API
data.id,
@ -111,7 +127,10 @@ export default defineComponent({
// hide channels by author
return false
}
} else if (data.type === 'playlist') {
} else if (dataType === 'playlist') {
if (this.forbiddenTitles.some((text) => this.data.title?.toLowerCase().includes(text.toLowerCase()))) {
return false
}
const attrsToCheck = [
// Local API
data.channelId,
@ -127,7 +146,11 @@ export default defineComponent({
}
}
return true
}
},
finalDataType() {
return this.data.type ?? this.dataType
},
},
methods: {
onVisibilityChanged: function (visible) {

View File

@ -14,23 +14,24 @@
v-if="visible"
>
<ft-list-video
v-if="data.type === 'video' || data.type === 'shortVideo'"
v-if="finalDataType === 'video' || finalDataType === 'shortVideo'"
:appearance="appearance"
:data="data"
:show-video-with-last-viewed-playlist="showVideoWithLastViewedPlaylist"
/>
<ft-list-channel
v-else-if="data.type === 'channel'"
v-else-if="finalDataType === 'channel'"
:appearance="appearance"
:data="data"
/>
<ft-list-playlist
v-else-if="data.type === 'playlist'"
v-else-if="finalDataType === 'playlist'"
:appearance="appearance"
:data="data"
/>
<ft-community-post
v-else-if="data.type === 'community'"
v-else-if="finalDataType === 'community'"
:hide-forbidden-titles="hideForbiddenTitles"
:appearance="appearance"
:data="data"
/>

View File

@ -22,12 +22,15 @@ export default defineComponent({
playlistId: '',
channelId: '',
title: 'Pop Music Playlist - Timeless Pop Songs (Updated Weekly 2020)',
thumbnail: 'https://i.ytimg.com/vi/JGwWNGJdvx8/mqdefault.jpg',
thumbnail: require('../../assets/img/thumbnail_placeholder.svg'),
channelName: '#RedMusic: Just Hits',
videoCount: 200,
}
},
computed: {
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
@ -44,6 +47,13 @@ export default defineComponent({
return this.$store.getters.getDefaultPlayback
},
titleForDisplay: function () {
if (typeof this.title !== 'string') { return '' }
if (this.title.length <= 255) { return this.title }
return `${this.title.substring(0, 255)}...`
},
blurThumbnails: function () {
return this.$store.getters.getBlurThumbnails
},
@ -54,10 +64,29 @@ export default defineComponent({
thumbnailPreference: function () {
return this.$store.getters.getThumbnailPreference
}
},
thumbnailCanBeShown() {
return this.thumbnailPreference !== 'hidden'
},
isUserPlaylist() {
return this.data._id != null
},
playlistPageLinkTo() {
// For `router-link` attribute `to`
return {
path: `/playlist/${this.playlistId}`,
query: {
playlistType: this.isUserPlaylist ? 'user' : '',
},
}
},
},
created: function () {
if (this.data.dataSource === 'local') {
if (this.isUserPlaylist) {
this.parseUserData()
} else if (this.data.dataSource === 'local') {
this.parseLocalData()
} else {
this.parseInvidiousData()
@ -79,9 +108,7 @@ export default defineComponent({
parseInvidiousData: function () {
this.title = this.data.title
if (this.thumbnailPreference === 'hidden') {
this.thumbnail = require('../../assets/img/thumbnail_placeholder.svg')
} else {
if (this.thumbnailCanBeShown) {
this.thumbnail = this.data.playlistThumbnail.replace('https://i.ytimg.com', this.currentInvidiousInstance).replace('hqdefault', 'mqdefault')
}
this.channelName = this.data.author
@ -96,9 +123,7 @@ export default defineComponent({
parseLocalData: function () {
this.title = this.data.title
if (this.thumbnailPreference === 'hidden') {
this.thumbnail = require('../../assets/img/thumbnail_placeholder.svg')
} else {
if (this.thumbnailCanBeShown) {
this.thumbnail = this.data.thumbnail
}
this.channelName = this.data.channelName
@ -107,6 +132,22 @@ export default defineComponent({
this.videoCount = this.data.videoCount
},
parseUserData: function () {
this.title = this.data.playlistName
if (this.thumbnailCanBeShown && this.data.videos.length > 0) {
const thumbnailURL = `https://i.ytimg.com/vi/${this.data.videos[0].videoId}/mqdefault.jpg`
if (this.backendPreference === 'invidious') {
this.thumbnail = thumbnailURL.replace('https://i.ytimg.com', this.currentInvidiousInstance)
} else {
this.thumbnail = thumbnailURL
}
}
this.channelName = ''
this.channelId = ''
this.playlistId = this.data._id
this.videoCount = this.data.videos.length
},
...mapActions([
'openInExternalPlayer'
])

View File

@ -12,7 +12,7 @@
>
<router-link
class="thumbnailLink"
:to="`/playlist/${playlistId}`"
:to="playlistPageLinkTo"
tabindex="-1"
aria-hidden="true"
>
@ -36,10 +36,10 @@
<div class="info">
<router-link
class="title"
:to="`/playlist/${playlistId}`"
:to="playlistPageLinkTo"
>
<h3 class="h3Title">
{{ title }}
{{ titleForDisplay }}
</h3>
</router-link>
<div class="infoLine">
@ -58,7 +58,7 @@
</span>
</div>
<ft-icon-button
v-if="externalPlayer !== ''"
v-if="externalPlayer !== '' && !isUserPlaylist"
:title="$t('Video.External Player.OpenInTemplate', { externalPlayer })"
:icon="['fas', 'external-link-alt']"
class="externalPlayerButton"

View File

@ -15,6 +15,10 @@ export default defineComponent({
type: String,
default: null
},
playlistType: {
type: String,
default: null
},
playlistIndex: {
type: Number,
default: null
@ -31,6 +35,10 @@ export default defineComponent({
type: Boolean,
default: false
},
playlistItemId: {
type: String,
default: null,
},
forceListType: {
type: String,
default: null
@ -43,14 +51,39 @@ export default defineComponent({
type: Boolean,
default: false,
},
alwaysShowAddToPlaylistButton: {
type: Boolean,
default: false,
},
quickBookmarkButtonEnabled: {
type: Boolean,
default: true,
},
canMoveVideoUp: {
type: Boolean,
default: false,
},
canMoveVideoDown: {
type: Boolean,
default: false,
},
canRemoveFromPlaylist: {
type: Boolean,
default: false,
},
useChannelsHiddenPreference: {
type: Boolean,
default: false,
},
hideForbiddenTitles: {
type: Boolean,
default: true
}
},
data: function () {
return {
visible: false
visible: false,
display: 'block'
}
},
computed: {
@ -67,9 +100,15 @@ export default defineComponent({
})
},
forbiddenTitles() {
if (!this.hideForbiddenTitles) { return [] }
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
shouldBeVisible() {
return !(this.channelsHidden.some(ch => ch.name === this.data.authorId) ||
this.channelsHidden.some(ch => ch.name === this.data.author))
this.channelsHidden.some(ch => ch.name === this.data.author) ||
this.forbiddenTitles.some((text) => this.data.title?.toLowerCase().includes(text.toLowerCase())))
}
},
created() {
@ -79,6 +118,8 @@ export default defineComponent({
onVisibilityChanged: function (visible) {
if (visible && this.shouldBeVisible) {
this.visible = visible
} else if (visible) {
this.display = 'none'
}
}
}

View File

@ -4,18 +4,29 @@
callback: onVisibilityChanged,
once: true,
}"
:style="{ display }"
>
<ft-list-video
v-if="visible"
:data="data"
:playlist-id="playlistId"
:playlist-type="playlistType"
:playlist-index="playlistIndex"
:playlist-reverse="playlistReverse"
:playlist-shuffle="playlistShuffle"
:playlist-loop="playlistLoop"
:playlist-item-id="playlistItemId"
:force-list-type="forceListType"
:appearance="appearance"
:always-show-add-to-playlist-button="alwaysShowAddToPlaylistButton"
:quick-bookmark-button-enabled="quickBookmarkButtonEnabled"
:can-move-video-up="canMoveVideoUp"
:can-move-video-down="canMoveVideoDown"
:can-remove-from-playlist="canRemoveFromPlaylist"
@pause-player="$emit('pause-player')"
@move-video-up="$emit('move-video-up')"
@move-video-down="$emit('move-video-down')"
@remove-from-playlist="$emit('remove-from-playlist')"
/>
</div>
</template>

View File

@ -0,0 +1,17 @@
/*
Set a height to invisible/unloaded elements, so that lazy loading actually works.
If we don't set a height, they all get a height of 0px (because they have no content),
so they all bunch up together and end up loading all of them in one go.
*/
.placeholder {
block-size: 40px;
}
.videoIndex {
color: var(--tertiary-text-color);
text-align: center;
}
.videoIndexIcon {
font-size: 14px;
}

View File

@ -0,0 +1,124 @@
import { defineComponent } from 'vue'
import FtListVideo from '../ft-list-video/ft-list-video.vue'
export default defineComponent({
name: 'FtListVideoNumbered',
components: {
'ft-list-video': FtListVideo
},
props: {
data: {
type: Object,
required: true
},
playlistId: {
type: String,
default: null
},
playlistType: {
type: String,
default: null
},
playlistIndex: {
type: Number,
default: null
},
playlistReverse: {
type: Boolean,
default: false
},
playlistShuffle: {
type: Boolean,
default: false
},
playlistLoop: {
type: Boolean,
default: false
},
playlistItemId: {
type: String,
default: null,
},
appearance: {
type: String,
required: true
},
initialVisibleState: {
type: Boolean,
default: false,
},
alwaysShowAddToPlaylistButton: {
type: Boolean,
default: false,
},
quickBookmarkButtonEnabled: {
type: Boolean,
default: true,
},
canMoveVideoUp: {
type: Boolean,
default: false,
},
canMoveVideoDown: {
type: Boolean,
default: false,
},
canRemoveFromPlaylist: {
type: Boolean,
default: false,
},
videoIndex: {
type: Number,
default: -1
},
isCurrentVideo: {
type: Boolean,
default: false
},
useChannelsHiddenPreference: {
type: Boolean,
default: false,
}
},
data: function () {
return {
visible: false,
show: true
}
},
computed: {
channelsHidden() {
// Some component users like channel view will have this disabled
if (!this.useChannelsHiddenPreference) { return [] }
return JSON.parse(this.$store.getters.getChannelsHidden).map((ch) => {
// Legacy support
if (typeof ch === 'string') {
return { name: ch, preferredName: '', icon: '' }
}
return ch
})
},
// As we only use this component in Playlist and watch-video-playlist,
// where title filtering is never desired, we don't have any title filtering logic here,
// like we do in ft-list-video-lazy
shouldBeVisible() {
return !(this.channelsHidden.some(ch => ch.name === this.data.authorId) ||
this.channelsHidden.some(ch => ch.name === this.data.author))
}
},
created() {
this.visible = this.initialVisibleState
},
methods: {
onVisibilityChanged: function (visible) {
if (visible && this.shouldBeVisible) {
this.visible = visible
} else if (visible) {
this.show = false
}
}
}
})

View File

@ -0,0 +1,53 @@
<template>
<div
v-show="show"
v-observe-visibility="!initialVisibleState ? {
callback: onVisibilityChanged,
once: true,
} : null"
:class="{ placeholder: !visible }"
>
<template
v-if="visible"
>
<p
class="videoIndex"
>
<font-awesome-icon
v-if="isCurrentVideo"
class="videoIndexIcon"
:icon="['fas', 'play']"
/>
<template
v-else
>
{{ videoIndex + 1 }}
</template>
</p>
<ft-list-video
:data="data"
:playlist-id="playlistId"
:playlist-type="playlistType"
:playlist-index="playlistIndex"
:playlist-reverse="playlistReverse"
:playlist-shuffle="playlistShuffle"
:playlist-loop="playlistLoop"
:playlist-item-id="playlistItemId"
force-list-type="list"
:appearance="appearance"
:always-show-add-to-playlist-button="alwaysShowAddToPlaylistButton"
:quick-bookmark-button-enabled="quickBookmarkButtonEnabled"
:can-move-video-up="canMoveVideoUp"
:can-move-video-down="canMoveVideoDown"
:can-remove-from-playlist="canRemoveFromPlaylist"
@pause-player="$emit('pause-player')"
@move-video-up="$emit('move-video-up')"
@move-video-down="$emit('move-video-down')"
@remove-from-playlist="$emit('remove-from-playlist')"
/>
</template>
</div>
</template>
<script src="./ft-list-video-numbered.js" />
<style scoped src="./ft-list-video-numbered.css" />

View File

@ -8,9 +8,11 @@ import {
openExternalLink,
showToast,
toLocalePublicationString,
toDistractionFreeTitle
toDistractionFreeTitle,
deepCopy
} from '../../helpers/utils'
import { deArrowData } from '../../helpers/sponsorblock'
import { deArrowData, deArrowThumbnail } from '../../helpers/sponsorblock'
import debounce from 'lodash.debounce'
export default defineComponent({
name: 'FtListVideo',
@ -26,6 +28,14 @@ export default defineComponent({
type: String,
default: null
},
playlistType: {
type: String,
default: null
},
playlistItemId: {
type: String,
default: null
},
playlistIndex: {
type: Number,
default: null
@ -54,6 +64,26 @@ export default defineComponent({
type: Boolean,
default: false
},
alwaysShowAddToPlaylistButton: {
type: Boolean,
default: false,
},
quickBookmarkButtonEnabled: {
type: Boolean,
default: true,
},
canMoveVideoUp: {
type: Boolean,
default: false,
},
canMoveVideoDown: {
type: Boolean,
default: false,
},
canRemoveFromPlaylist: {
type: Boolean,
default: false,
},
},
data: function () {
return {
@ -64,15 +94,17 @@ export default defineComponent({
viewCount: 0,
parsedViewCount: '',
uploadedTime: '',
lengthSeconds: 0,
duration: '',
description: '',
watched: false,
watchProgress: 0,
publishedText: '',
isLive: false,
isUpcoming: false,
isPremium: false,
hideViews: false
hideViews: false,
addToPlaylistPromptCloseCallback: null,
debounceGetDeArrowThumbnail: null,
}
},
computed: {
@ -108,16 +140,37 @@ export default defineComponent({
return this.$store.getters.getCurrentInvidiousInstance
},
showPlaylists: function () {
return !this.$store.getters.getHidePlaylists
},
inHistory: function () {
// When in the history page, showing relative dates isn't very useful.
// We want to show the exact date instead
return this.$route.name === 'history'
},
inUserPlaylist: function () {
return this.playlistTypeFinal === 'user' || this.selectedUserPlaylist != null
},
selectedUserPlaylist: function () {
if (this.playlistIdFinal == null) { return null }
if (this.playlistIdFinal === '') { return null }
return this.$store.getters.getPlaylist(this.playlistIdFinal)
},
playlistSharable() {
// `playlistId` can be undefined
// User playlist ID should not be shared
return this.playlistIdFinal && this.playlistIdFinal.length !== 0 && !this.inUserPlaylist
},
invidiousUrl: function () {
let videoUrl = `${this.currentInvidiousInstance}/watch?v=${this.id}`
// `playlistId` can be undefined
if (this.playlistIdFinal && this.playlistIdFinal.length !== 0) {
if (this.playlistSharable) {
// `index` seems can be ignored
videoUrl += `&list=${this.playlistIdFinal}`
}
@ -130,8 +183,7 @@ export default defineComponent({
youtubeUrl: function () {
let videoUrl = `https://www.youtube.com/watch?v=${this.id}`
// `playlistId` can be undefined
if (this.playlistIdFinal && this.playlistIdFinal.length !== 0) {
if (this.playlistSharable) {
// `index` seems can be ignored
videoUrl += `&list=${this.playlistIdFinal}`
}
@ -139,12 +191,12 @@ export default defineComponent({
},
youtubeShareUrl: function () {
// `playlistId` can be undefined
if (this.playlistIdFinal && this.playlistIdFinal.length !== 0) {
const videoUrl = `https://youtu.be/${this.id}`
if (this.playlistSharable) {
// `index` seems can be ignored
return `https://youtu.be/${this.id}?list=${this.playlistIdFinal}`
return `${videoUrl}?list=${this.playlistIdFinal}`
}
return `https://youtu.be/${this.id}`
return videoUrl
},
youtubeChannelUrl: function () {
@ -156,7 +208,11 @@ export default defineComponent({
},
progressPercentage: function () {
return (this.watchProgress / this.data.lengthSeconds) * 100
if (typeof this.lengthSeconds !== 'number') {
return 0
}
return (this.watchProgress / this.lengthSeconds) * 100
},
hideSharingActions: function() {
@ -166,7 +222,7 @@ export default defineComponent({
dropdownOptions: function () {
const options = [
{
label: this.watched
label: this.historyEntryExists
? this.$t('Video.Remove From History')
: this.$t('Video.Mark As Watched'),
value: 'history'
@ -254,6 +310,14 @@ export default defineComponent({
},
thumbnail: function () {
if (this.thumbnailPreference === 'hidden') {
return require('../../assets/img/thumbnail_placeholder.svg')
}
if (this.useDeArrowThumbnails && this.deArrowCache?.thumbnail != null) {
return this.deArrowCache.thumbnail
}
let baseUrl
if (this.backendPreference === 'invidious') {
baseUrl = this.currentInvidiousInstance
@ -268,8 +332,6 @@ export default defineComponent({
return `${baseUrl}/vi/${this.id}/mq2.jpg`
case 'end':
return `${baseUrl}/vi/${this.id}/mq3.jpg`
case 'hidden':
return require('../../assets/img/thumbnail_placeholder.svg')
default:
return `${baseUrl}/vi/${this.id}/mqdefault.jpg`
}
@ -280,23 +342,7 @@ export default defineComponent({
},
addWatchedStyle: function () {
return this.watched && !this.inHistory
},
favoritesPlaylist: function () {
return this.$store.getters.getFavorites
},
inFavoritesPlaylist: function () {
const index = this.favoritesPlaylist.videos.findIndex((video) => {
return video.videoId === this.id
})
return index !== -1
},
favoriteIconTheme: function () {
return this.inFavoritesPlaylist ? 'base favorite' : 'base'
return this.historyEntryExists && !this.inHistory
},
externalPlayer: function () {
@ -334,60 +380,179 @@ export default defineComponent({
}
},
playlistIdFinal: function () {
displayDuration: function () {
if (this.useDeArrowTitles && (this.duration === '' || this.duration === '0:00') && this.deArrowCache?.videoDuration) {
return formatDurationAsTimestamp(this.deArrowCache.videoDuration)
}
return this.duration
},
playlistIdTypePairFinal() {
if (this.playlistId) {
return this.playlistId
return {
playlistId: this.playlistId,
playlistType: this.playlistType,
playlistItemId: this.playlistItemId,
}
}
// Get playlist ID from history ONLY if option enabled
if (!this.showVideoWithLastViewedPlaylist) { return }
if (!this.saveVideoHistoryWithLastViewedPlaylist) { return }
return this.historyEntry?.lastViewedPlaylistId
return {
playlistId: this.historyEntry?.lastViewedPlaylistId,
playlistType: this.historyEntry?.lastViewedPlaylistType,
playlistItemId: this.historyEntry?.lastViewedPlaylistItemId,
}
},
playlistIdFinal: function () {
return this.playlistIdTypePairFinal?.playlistId
},
playlistTypeFinal: function () {
return this.playlistIdTypePairFinal?.playlistType
},
playlistItemIdFinal: function () {
return this.playlistIdTypePairFinal?.playlistItemId
},
quickBookmarkPlaylistId() {
return this.$store.getters.getQuickBookmarkTargetPlaylistId
},
quickBookmarkPlaylist() {
return this.$store.getters.getPlaylist(this.quickBookmarkPlaylistId)
},
isQuickBookmarkEnabled() {
return this.quickBookmarkPlaylist != null
},
isInQuickBookmarkPlaylist: function () {
if (!this.isQuickBookmarkEnabled) { return false }
return this.quickBookmarkPlaylist.videos.some((video) => {
return video.videoId === this.id
})
},
quickBookmarkIconText: function () {
if (!this.isQuickBookmarkEnabled) { return false }
const translationProperties = {
playlistName: this.quickBookmarkPlaylist.playlistName,
}
return this.isInQuickBookmarkPlaylist
? this.$t('User Playlists.Remove from Favorites', translationProperties)
: this.$t('User Playlists.Add to Favorites', translationProperties)
},
quickBookmarkIconTheme: function () {
return this.isInQuickBookmarkPlaylist ? 'base favorite' : 'base'
},
watchPageLinkTo() {
// For `router-link` attribute `to`
return {
path: `/watch/${this.id}`,
query: this.watchPageLinkQuery,
}
},
watchPageLinkQuery() {
const query = {}
if (this.playlistIdFinal) { query.playlistId = this.playlistIdFinal }
if (this.playlistTypeFinal) { query.playlistType = this.playlistTypeFinal }
if (this.playlistItemIdFinal) { query.playlistItemId = this.playlistItemIdFinal }
return query
},
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
},
showAddToPlaylistPrompt: function () {
return this.$store.getters.getShowAddToPlaylistPrompt
},
useDeArrowTitles: function () {
return this.$store.getters.getUseDeArrowTitles
},
useDeArrowThumbnails: function () {
return this.$store.getters.getUseDeArrowThumbnails
},
deArrowCache: function () {
return this.$store.getters.getDeArrowCache[this.id]
}
},
},
watch: {
historyEntry() {
this.checkIfWatched()
},
showAddToPlaylistPrompt(value) {
if (value) { return }
// Execute on prompt close
if (this.addToPlaylistPromptCloseCallback == null) { return }
this.addToPlaylistPromptCloseCallback()
},
},
created: function () {
this.parseVideoData()
this.checkIfWatched()
if (this.useDeArrowTitles && !this.deArrowCache) {
if ((this.useDeArrowTitles || this.useDeArrowThumbnails) && !this.deArrowCache) {
this.fetchDeArrowData()
}
if (this.useDeArrowThumbnails && this.deArrowCache && this.deArrowCache.thumbnail == null) {
if (this.debounceGetDeArrowThumbnail == null) {
this.debounceGetDeArrowThumbnail = debounce(this.fetchDeArrowThumbnail, 1000)
}
this.debounceGetDeArrowThumbnail()
}
},
methods: {
fetchDeArrowThumbnail: async function() {
if (this.thumbnailPreference === 'hidden') { return }
const videoId = this.id
const thumbnail = await deArrowThumbnail(videoId, this.deArrowCache.thumbnailTimestamp)
if (thumbnail) {
const deArrowCacheClone = deepCopy(this.deArrowCache)
deArrowCacheClone.thumbnail = thumbnail
this.$store.commit('addThumbnailToDeArrowCache', deArrowCacheClone)
}
},
fetchDeArrowData: async function() {
const videoId = this.id
const data = await deArrowData(this.id)
const cacheData = { videoId, title: null }
const cacheData = { videoId, title: null, videoDuration: null, thumbnail: null, thumbnailTimestamp: null }
if (Array.isArray(data?.titles) && data.titles.length > 0 && (data.titles[0].locked || data.titles[0].votes >= 0)) {
cacheData.title = data.titles[0].title
// remove dearrow formatting markers https://github.com/ajayyy/DeArrow/blob/0da266485be902fe54259214c3cd7c942f2357c5/src/titles/titleFormatter.ts#L460
cacheData.title = data.titles[0].title.replaceAll(/(^|\s)>(\S)/g, '$1$2').trim()
}
if (Array.isArray(data?.thumbnails) && data.thumbnails.length > 0 && (data.thumbnails[0].locked || data.thumbnails[0].votes >= 0)) {
cacheData.thumbnailTimestamp = data.thumbnails.at(0).timestamp
} else if (data?.videoDuration != null) {
cacheData.thumbnailTimestamp = data.videoDuration * data.randomTime
}
cacheData.videoDuration = data?.videoDuration ? Math.floor(data.videoDuration) : null
// Save data to cache whether data available or not to prevent duplicate requests
this.$store.commit('addVideoToDeArrowCache', cacheData)
// fetch dearrow thumbnails if enabled
if (this.useDeArrowThumbnails && this.deArrowCache?.thumbnail === null) {
if (this.debounceGetDeArrowThumbnail == null) {
this.debounceGetDeArrowThumbnail = debounce(this.fetchDeArrowThumbnail, 1000)
}
this.debounceGetDeArrowThumbnail()
}
},
handleExternalPlayer: function () {
this.$emit('pause-player')
this.openInExternalPlayer({
const payload = {
watchProgress: this.watchProgress,
playbackRate: this.defaultPlayback,
videoId: this.id,
@ -396,26 +561,29 @@ export default defineComponent({
playlistIndex: this.playlistIndex,
playlistReverse: this.playlistReverse,
playlistShuffle: this.playlistShuffle,
playlistLoop: this.playlistLoop
})
if (this.saveWatchedProgress && !this.watched) {
this.markAsWatched()
playlistLoop: this.playlistLoop,
}
},
// Only play video in non playlist mode when user playlist detected
if (this.inUserPlaylist) {
Object.assign(payload, {
playlistId: null,
playlistIndex: null,
playlistReverse: null,
playlistShuffle: null,
playlistLoop: null,
})
}
this.openInExternalPlayer(payload)
toggleSave: function () {
if (this.inFavoritesPlaylist) {
this.removeFromPlaylist()
} else {
this.addToPlaylist()
if (this.saveWatchedProgress && !this.historyEntryExists) {
this.markAsWatched()
}
},
handleOptionsClick: function (option) {
switch (option) {
case 'history':
if (this.watched) {
if (this.historyEntryExists) {
this.removeFromWatched()
} else {
this.markAsWatched()
@ -468,9 +636,11 @@ export default defineComponent({
this.channelName = this.data.author ?? null
this.channelId = this.data.authorId ?? null
if (this.data.isRSS && this.historyEntryExists) {
if ((this.data.lengthSeconds === '' || this.data.lengthSeconds === '0:00') && this.historyEntryExists) {
this.lengthSeconds = this.historyEntry.lengthSeconds
this.duration = formatDurationAsTimestamp(this.historyEntry.lengthSeconds)
} else {
this.lengthSeconds = this.data.lengthSeconds
this.duration = formatDurationAsTimestamp(this.data.lengthSeconds)
}
@ -556,8 +726,6 @@ export default defineComponent({
checkIfWatched: function () {
if (this.historyEntryExists) {
this.watched = true
const historyEntry = this.historyEntry
if (this.saveWatchedProgress) {
@ -573,7 +741,6 @@ export default defineComponent({
this.publishedText = ''
}
} else {
this.watched = false
this.watchProgress = 0
}
},
@ -595,8 +762,6 @@ export default defineComponent({
}
this.updateHistory(videoData)
showToast(this.$t('Video.Video has been marked as watched'))
this.watched = true
},
removeFromWatched: function () {
@ -604,44 +769,31 @@ export default defineComponent({
showToast(this.$t('Video.Video has been removed from your history'))
this.watched = false
this.watchProgress = 0
},
addToPlaylist: function () {
togglePlaylistPrompt: function () {
const videoData = {
videoId: this.id,
title: this.title,
author: this.channelName,
authorId: this.channelId,
published: '',
description: this.description,
viewCount: this.viewCount,
lengthSeconds: this.data.lengthSeconds,
timeAdded: new Date().getTime(),
isLive: false,
type: 'video',
}
const payload = {
playlistName: 'Favorites',
videoData: videoData
this.showAddToPlaylistPromptForManyVideos({ videos: [videoData] })
// Focus when prompt closed
this.addToPlaylistPromptCloseCallback = () => {
// Run once only
this.addToPlaylistPromptCloseCallback = null
// `thumbnailLink` is a `router-link`
// `focus()` can only be called on the actual element
this.$refs.addToPlaylistIcon?.$el?.focus()
}
this.addVideo(payload)
showToast(this.$t('Video.Video has been saved'))
},
removeFromPlaylist: function () {
const payload = {
playlistName: 'Favorites',
videoId: this.id
}
this.removeVideo(payload)
showToast(this.$t('Video.Video has been removed from your saved list'))
},
hideChannel: function(channelName, channelId) {
@ -659,13 +811,61 @@ export default defineComponent({
showToast(this.$t('Channel Unhidden', { channel: channelName }))
},
toggleQuickBookmarked() {
if (!this.isQuickBookmarkEnabled) {
// This should be prevented by UI
return
}
if (this.isInQuickBookmarkPlaylist) {
this.removeFromQuickBookmarkPlaylist()
} else {
this.addToQuickBookmarkPlaylist()
}
},
addToQuickBookmarkPlaylist() {
const videoData = {
videoId: this.id,
title: this.title,
author: this.channelName,
authorId: this.channelId,
description: this.description,
viewCount: this.viewCount,
lengthSeconds: this.data.lengthSeconds,
}
this.addVideos({
_id: this.quickBookmarkPlaylist._id,
videos: [videoData],
})
// Update playlist's `lastUpdatedAt`
this.updatePlaylist({ _id: this.quickBookmarkPlaylist._id })
// TODO: Maybe show playlist name
showToast(this.$t('Video.Video has been saved'))
},
removeFromQuickBookmarkPlaylist() {
this.removeVideo({
_id: this.quickBookmarkPlaylist._id,
// Remove all playlist items with same videoId
videoId: this.id,
})
// Update playlist's `lastUpdatedAt`
this.updatePlaylist({ _id: this.quickBookmarkPlaylist._id })
// TODO: Maybe show playlist name
showToast(this.$t('Video.Video has been removed from your saved list'))
},
...mapActions([
'openInExternalPlayer',
'updateHistory',
'removeFromHistory',
'addVideo',
'updateChannelsHidden',
'showAddToPlaylistPromptForManyVideos',
'addVideos',
'updatePlaylist',
'removeVideo',
'updateChannelsHidden'
])
}
})

View File

@ -14,11 +14,7 @@
<router-link
class="thumbnailLink"
tabindex="-1"
aria-hidden="true"
:to="{
path: `/watch/${id}`,
query: playlistIdFinal ? {playlistId: playlistIdFinal} : {}
}"
:to="watchPageLinkTo"
>
<img
:src="thumbnail"
@ -28,14 +24,14 @@
>
</router-link>
<div
v-if="isLive || duration !== '0:00'"
v-if="isLive || isUpcoming || (displayDuration !== '' && displayDuration !== '0:00')"
class="videoDuration"
:class="{
live: isLive,
upcoming: isUpcoming
}"
>
{{ isLive ? $t("Video.Live") : (isUpcoming ? $t("Video.Upcoming") : duration) }}
{{ isLive ? $t("Video.Live") : (isUpcoming ? $t("Video.Upcoming") : displayDuration) }}
</div>
<ft-icon-button
v-if="externalPlayer !== ''"
@ -47,17 +43,60 @@
:size="appearance === `watchPlaylistItem` ? 12 : 16"
@click="handleExternalPlayer"
/>
<ft-icon-button
v-if="!isUpcoming"
:title="$t('Video.Save Video')"
:icon="['fas', 'star']"
class="favoritesIcon"
:class="{ favorited: favoriteIconTheme === 'base favorite'}"
:theme="favoriteIconTheme"
:padding="appearance === `watchPlaylistItem` ? 5 : 6"
:size="appearance === `watchPlaylistItem` ? 14 : 18"
@click="toggleSave"
/>
<span class="playlistIcons">
<ft-icon-button
v-if="showPlaylists"
ref="addToPlaylistIcon"
:title="$t('User Playlists.Add to Playlist')"
:icon="['fas', 'plus']"
class="addToPlaylistIcon"
:class="alwaysShowAddToPlaylistButton ? 'alwaysVisible' : ''"
:padding="appearance === `watchPlaylistItem` ? 5 : 6"
:size="appearance === `watchPlaylistItem` ? 14 : 18"
@click="togglePlaylistPrompt"
/>
<ft-icon-button
v-if="isQuickBookmarkEnabled && quickBookmarkButtonEnabled"
:title="quickBookmarkIconText"
:icon="['fas', 'star']"
class="quickBookmarkVideoIcon"
:class="{
bookmarked: isInQuickBookmarkPlaylist,
alwaysVisible: alwaysShowAddToPlaylistButton,
}"
:theme="quickBookmarkIconTheme"
:padding="appearance === `watchPlaylistItem` ? 5 : 6"
:size="appearance === `watchPlaylistItem` ? 14 : 18"
@click="toggleQuickBookmarked"
/>
<ft-icon-button
v-if="inUserPlaylist && canMoveVideoUp"
:title="$t('User Playlists.Move Video Up')"
:icon="['fas', 'arrow-up']"
class="upArrowIcon"
:padding="appearance === `watchPlaylistItem` ? 5 : 6"
:size="appearance === `watchPlaylistItem` ? 14 : 18"
@click="$emit('move-video-up')"
/>
<ft-icon-button
v-if="inUserPlaylist && canMoveVideoDown"
:title="$t('User Playlists.Move Video Down')"
:icon="['fas', 'arrow-down']"
class="downArrowIcon"
:padding="appearance === `watchPlaylistItem` ? 5 : 6"
:size="appearance === `watchPlaylistItem` ? 14 : 18"
@click="$emit('move-video-down')"
/>
<ft-icon-button
v-if="inUserPlaylist && canRemoveFromPlaylist"
:title="$t('User Playlists.Remove from Playlist')"
:icon="['fas', 'trash']"
class="trashIcon"
:padding="appearance === `watchPlaylistItem` ? 5 : 6"
:size="appearance === `watchPlaylistItem` ? 14 : 18"
@click="$emit('remove-from-playlist')"
/>
</span>
<div
v-if="addWatchedStyle"
class="videoWatched"
@ -65,7 +104,7 @@
{{ $t("Video.Watched") }}
</div>
<div
v-if="watched"
v-if="historyEntryExists"
class="watchedProgressBar"
:style="{inlineSize: progressPercentage + '%'}"
/>
@ -73,10 +112,7 @@
<div class="info">
<router-link
class="title"
:to="{
path: `/watch/${id}`,
query: playlistIdFinal ? {playlistId: playlistIdFinal} : {}
}"
:to="watchPageLinkTo"
>
<h3 class="h3Title">
{{ displayTitle }}
@ -90,12 +126,16 @@
>
<span>{{ channelName }}</span>
</router-link>
<template v-if="!isLive && !isUpcoming && !isPremium && !hideViews">
<span class="viewCount">
<template v-if="channelId !== null"> </template>
{{ $tc('Global.Counts.View Count', viewCount, {count: parsedViewCount}) }}
</span>
</template>
<span v-else-if="channelName !== null">
{{ channelName }}
</span>
<span
v-if="!isLive && !isUpcoming && !isPremium && !hideViews && viewCount != null"
class="viewCount"
>
<template v-if="channelId !== null || channelName !== null"> </template>
{{ $tc('Global.Counts.View Count', viewCount, {count: parsedViewCount}) }}
</span>
<span
v-if="uploadedTime !== '' && !isLive && !inHistory"
class="uploadedTime"
@ -121,7 +161,7 @@
@click="handleOptionsClick"
/>
<p
v-if="((listType === 'list' || forceListType === 'list') && forceListType !== 'grid') &&
v-if="description && ((listType === 'list' || forceListType === 'list') && forceListType !== 'grid') &&
appearance === 'result'"
class="description"
v-html="description"

View File

@ -0,0 +1,36 @@
.heading {
text-align: center;
}
.selected-count {
text-align: center;
}
/* Style for `ft-prompt` */
:deep(.promptCard) {
/* Currently only this prompt has enough content to make prompt too high */
max-block-size: 95%;
/* Some child(s) will grow vertically */
display: flex;
flex-direction: column;
}
.sortSelect {
/* Put it on the right */
margin-inline-start: auto;
}
.playlists-container {
box-shadow: inset 0 0 5px rgba(0,0,0,.5);
/* Use remaining height */
flex-grow: 1;
overflow-y: scroll;
}
.playlist-selector-container {
/* Make them look selectable */
cursor: pointer;
}

View File

@ -0,0 +1,261 @@
import { defineComponent } from 'vue'
import { mapActions } from 'vuex'
import debounce from 'lodash.debounce'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtPrompt from '../ft-prompt/ft-prompt.vue'
import FtButton from '../ft-button/ft-button.vue'
import FtPlaylistSelector from '../ft-playlist-selector/ft-playlist-selector.vue'
import FtInput from '../../components/ft-input/ft-input.vue'
import FtSelect from '../../components/ft-select/ft-select.vue'
import {
showToast,
} from '../../helpers/utils'
const SORT_BY_VALUES = {
NameAscending: 'name_ascending',
NameDescending: 'name_descending',
LatestCreatedFirst: 'latest_created_first',
EarliestCreatedFirst: 'earliest_created_first',
LatestUpdatedFirst: 'latest_updated_first',
EarliestUpdatedFirst: 'earliest_updated_first',
}
export default defineComponent({
name: 'FtPlaylistAddVideoPrompt',
components: {
'ft-flex-box': FtFlexBox,
'ft-prompt': FtPrompt,
'ft-button': FtButton,
'ft-playlist-selector': FtPlaylistSelector,
'ft-input': FtInput,
'ft-select': FtSelect,
},
data: function () {
return {
selectedPlaylistIdList: [],
createdSincePromptShownPlaylistIdList: [],
query: '',
updateQueryDebounce: function() {},
lastShownAt: Date.now(),
lastActiveElement: null,
sortBy: SORT_BY_VALUES.LatestUpdatedFirst,
}
},
computed: {
showingCreatePlaylistPrompt: function () {
return this.$store.getters.getShowCreatePlaylistPrompt
},
allPlaylists: function () {
const playlists = this.$store.getters.getAllPlaylists
return [].concat(playlists).sort((a, b) => {
switch (this.sortBy) {
case SORT_BY_VALUES.NameAscending:
return a.playlistName.localeCompare(b.playlistName, this.locale)
case SORT_BY_VALUES.NameDescending:
return b.playlistName.localeCompare(a.playlistName, this.locale)
case SORT_BY_VALUES.LatestCreatedFirst: {
if (a.createdAt > b.createdAt) { return -1 }
if (a.createdAt < b.createdAt) { return 1 }
return a.playlistName.localeCompare(b.playlistName, this.locale)
}
case SORT_BY_VALUES.EarliestCreatedFirst: {
if (a.createdAt < b.createdAt) { return -1 }
if (a.createdAt > b.createdAt) { return 1 }
return a.playlistName.localeCompare(b.playlistName, this.locale)
}
case SORT_BY_VALUES.LatestUpdatedFirst: {
if (a.lastUpdatedAt > b.lastUpdatedAt) { return -1 }
if (a.lastUpdatedAt < b.lastUpdatedAt) { return 1 }
return a.playlistName.localeCompare(b.playlistName, this.locale)
}
case SORT_BY_VALUES.EarliestUpdatedFirst: {
if (a.lastUpdatedAt < b.lastUpdatedAt) { return -1 }
if (a.lastUpdatedAt > b.lastUpdatedAt) { return 1 }
return a.playlistName.localeCompare(b.playlistName, this.locale)
}
default:
console.error(`Unknown sortBy: ${this.sortBy}`)
return 0
}
})
},
allPlaylistsLength() {
return this.allPlaylists.length
},
selectedPlaylistCount: function () {
return this.selectedPlaylistIdList.length
},
toBeAddedToPlaylistVideoCount: function () {
return this.toBeAddedToPlaylistVideoList.length
},
showAddToPlaylistPrompt: function () {
return this.$store.getters.getShowAddToPlaylistPrompt
},
toBeAddedToPlaylistVideoList: function () {
return this.$store.getters.getToBeAddedToPlaylistVideoList
},
newPlaylistDefaultProperties: function () {
return this.$store.getters.getNewPlaylistDefaultProperties
},
processedQuery: function() {
return this.query.trim().toLowerCase()
},
activePlaylists: function() {
// Very rare that a playlist name only has 1 char
if (this.processedQuery.length === 0) { return this.allPlaylists }
return this.allPlaylists.filter((playlist) => {
if (typeof (playlist.playlistName) !== 'string') { return false }
return playlist.playlistName.toLowerCase().includes(this.processedQuery)
})
},
sortBySelectNames() {
return Object.values(SORT_BY_VALUES).map((k) => {
switch (k) {
case SORT_BY_VALUES.NameAscending:
return this.$t('User Playlists.Sort By.NameAscending')
case SORT_BY_VALUES.NameDescending:
return this.$t('User Playlists.Sort By.NameDescending')
case SORT_BY_VALUES.LatestCreatedFirst:
return this.$t('User Playlists.Sort By.LatestCreatedFirst')
case SORT_BY_VALUES.EarliestCreatedFirst:
return this.$t('User Playlists.Sort By.EarliestCreatedFirst')
case SORT_BY_VALUES.LatestUpdatedFirst:
return this.$t('User Playlists.Sort By.LatestUpdatedFirst')
case SORT_BY_VALUES.EarliestUpdatedFirst:
return this.$t('User Playlists.Sort By.EarliestUpdatedFirst')
default:
console.error(`Unknown sortBy: ${k}`)
return k
}
})
},
sortBySelectValues() {
return Object.values(SORT_BY_VALUES)
},
},
watch: {
allPlaylistsLength(val, oldVal) {
const allPlaylistIds = []
// Add new playlists to selected
this.allPlaylists.forEach((playlist) => {
allPlaylistIds.push(playlist._id)
// Old playlists don't have `createdAt`
if (playlist.createdAt == null) { return }
// Only playlists created after this prompt shown should be considered
if (playlist.createdAt < this.lastShownAt) { return }
// Only playlists not auto added to selected yet should be considered
if (this.createdSincePromptShownPlaylistIdList.includes(playlist._id)) { return }
// Add newly created playlists to selected ONCE
this.createdSincePromptShownPlaylistIdList.push(playlist._id)
this.selectedPlaylistIdList.push(playlist._id)
})
// Remove deleted playlist from deleted
this.selectedPlaylistIdList = this.selectedPlaylistIdList.filter(playlistId => {
return allPlaylistIds.includes(playlistId)
})
if (val > oldVal) {
// Focus back to search input only when playlist added
// Allow search and easier deselecting new created playlist
this.$refs.searchBar.focus()
}
},
showingCreatePlaylistPrompt(val) {
if (val) { return }
// Only care when CreatePlaylistPrompt hidden
// Shift focus from button to prevent unwanted click event
// due to enter key press in CreatePlaylistPrompt
this.$refs.searchBar.focus()
},
},
mounted: function () {
this.lastActiveElement = document.activeElement
this.updateQueryDebounce = debounce(this.updateQuery, 500)
// User might want to search first if they have many playlists
this.$refs.searchBar.focus()
},
beforeDestroy() {
this.lastActiveElement?.focus()
},
methods: {
hide: function () {
this.hideAddToPlaylistPrompt()
},
countSelected: function (playlistId) {
const index = this.selectedPlaylistIdList.indexOf(playlistId)
if (index !== -1) {
this.selectedPlaylistIdList.splice(index, 1)
} else {
this.selectedPlaylistIdList.push(playlistId)
}
},
addSelectedToPlaylists: function () {
const addedPlaylistIds = new Set()
if (this.selectedPlaylistIdList.length === 0) {
showToast(this.$t('User Playlists.AddVideoPrompt.Toast["You haven\'t selected any playlist yet."]'))
return
}
this.selectedPlaylistIdList.forEach((selectedPlaylistId) => {
const playlist = this.allPlaylists.find((list) => list._id === selectedPlaylistId)
if (playlist == null) { return }
this.addVideos({
_id: playlist._id,
// Use [].concat to avoid `do not mutate vuex store state outside mutation handlers`
videos: [].concat(this.toBeAddedToPlaylistVideoList),
})
addedPlaylistIds.add(playlist._id)
// Update playlist's `lastUpdatedAt`
this.updatePlaylist({ _id: playlist._id })
})
const translationEntryKey = addedPlaylistIds.size === 1
? 'User Playlists.AddVideoPrompt.Toast.{videoCount} video(s) added to 1 playlist'
: 'User Playlists.AddVideoPrompt.Toast.{videoCount} video(s) added to {playlistCount} playlists'
showToast(this.$tc(translationEntryKey, this.toBeAddedToPlaylistVideoCount, {
videoCount: this.toBeAddedToPlaylistVideoCount,
playlistCount: addedPlaylistIds.size,
}))
this.hide()
},
openCreatePlaylistPrompt: function () {
this.showCreatePlaylistPrompt({
title: this.newPlaylistDefaultProperties.title || '',
})
},
updateQuery: function(query) {
this.query = query
},
...mapActions([
'addVideos',
'updatePlaylist',
'hideAddToPlaylistPrompt',
'showCreatePlaylistPrompt',
])
}
})

View File

@ -0,0 +1,69 @@
<template>
<ft-prompt
@click="hide"
>
<h2 class="heading">
{{ $tc('User Playlists.AddVideoPrompt.Select a playlist to add your N videos to', toBeAddedToPlaylistVideoCount, {
videoCount: toBeAddedToPlaylistVideoCount,
}) }}
</h2>
<p class="selected-count">
{{ $tc('User Playlists.AddVideoPrompt.N playlists selected', selectedPlaylistCount, {
playlistCount: selectedPlaylistCount,
}) }}
</p>
<ft-input
ref="searchBar"
:placeholder="$t('User Playlists.AddVideoPrompt.Search in Playlists')"
:show-clear-text-button="true"
:show-action-button="false"
@input="(input) => updateQueryDebounce(input)"
@clear="updateQueryDebounce('')"
/>
<ft-select
v-if="allPlaylists.length > 1"
class="sortSelect"
:value="sortBy"
:select-names="sortBySelectNames"
:select-values="sortBySelectValues"
:placeholder="$t('User Playlists.Sort By.Sort By')"
@change="sortBy = $event"
/>
<div class="playlists-container">
<ft-flex-box>
<div
v-for="(playlist, index) in activePlaylists"
:key="playlist._id"
class="playlist-selector-container"
>
<ft-playlist-selector
tabindex="0"
:data="playlist"
:index="index"
:selected="selectedPlaylistIdList.includes(playlist._id)"
@selected="countSelected(playlist._id)"
/>
</div>
</ft-flex-box>
</div>
<div class="actions-container">
<ft-flex-box>
<ft-button
:label="$t('User Playlists.Create New Playlist')"
@click="openCreatePlaylistPrompt"
/>
<ft-button
:label="$t('User Playlists.AddVideoPrompt.Save')"
@click="addSelectedToPlaylists"
/>
<ft-button
:label="$t('User Playlists.Cancel')"
@click="hide"
/>
</ft-flex-box>
</div>
</ft-prompt>
</template>
<script src="./ft-playlist-add-video-prompt.js" />
<style scoped src="./ft-playlist-add-video-prompt.css" />

View File

@ -0,0 +1,75 @@
import { defineComponent } from 'vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
import { mapActions } from 'vuex'
export default defineComponent({
name: 'FtPlaylistSelector',
components: {
'ft-icon-button': FtIconButton
},
props: {
data: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
appearance: {
type: String,
default: 'grid',
},
selected: {
type: Boolean,
required: true,
},
},
data: function () {
return {
title: '',
thumbnail: require('../../assets/img/thumbnail_placeholder.svg'),
videoCount: 0,
}
},
computed: {
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
titleForDisplay: function () {
if (typeof this.title !== 'string') { return '' }
if (this.title.length <= 255) { return this.title }
return `${this.title.substring(0, 255)}...`
},
},
created: function () {
this.parseUserData()
},
methods: {
parseUserData: function () {
this.title = this.data.playlistName
if (this.data.videos.length > 0) {
const thumbnailURL = `https://i.ytimg.com/vi/${this.data.videos[0].videoId}/mqdefault.jpg`
if (this.backendPreference === 'invidious') {
this.thumbnail = thumbnailURL.replace('https://i.ytimg.com', this.currentInvidiousInstance)
} else {
this.thumbnail = thumbnailURL
}
}
this.videoCount = this.data.videos.length
},
toggleSelection: function () {
this.$emit('selected', this.index)
},
...mapActions([
'openInExternalPlayer'
])
}
})

View File

@ -0,0 +1,89 @@
.ft-playlist-selector {
padding: 6px;
&:hover ,
&.selected {
background-color: var(--bg-color);
.thumbnailImage {
opacity: 0.3;
}
}
.thumbnail {
position: relative;
.videoCountContainer {
position: absolute;
inset-inline-end: 0;
inset-block: 0;
inline-size: 60px;
font-size: 20px;
.background,
.inner {
position: absolute;
inset: 0;
}
.background {
background-color: var(--bg-color);
opacity: 0.9;
}
.inner {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--primary-text-color);
}
}
}
.info {
flex: 1;
position: relative;
.title {
font-size: 20px;
text-decoration: none;
word-wrap: break-word;
word-break: break-word;
}
}
&.grid {
display: flex;
flex-direction: column;
inline-size: 245px;
min-block-size: 230px;
padding-block-end: 20px;
.thumbnail {
margin-block-end: 12px;
.thumbnailImage {
inline-size: 100%;
// Ensure placeholder image displayed at same aspect ratio as most other images
aspect-ratio: 16/9;
}
}
.title {
font-size: 22px;
}
}
}
.selectedIcon {
position: absolute;
inset-block-start: calc(50% - 25px);
inset-inline-start: calc(50% - 25px);
font-size: 50px;
}

View File

@ -0,0 +1,43 @@
<template>
<div
class="ft-playlist-selector grid"
:class="{ selected: selected }"
@click="toggleSelection"
@keydown.enter.prevent="toggleSelection"
@keydown.space.prevent="toggleSelection"
>
<div
class="thumbnail"
>
<font-awesome-icon
v-if="selected"
class="selectedIcon"
:icon="['fas', 'check']"
/>
<img
alt=""
:src="thumbnail"
class="thumbnailImage"
>
<div
class="videoCountContainer"
>
<div class="background" />
<div class="inner">
<div>{{ videoCount }}</div>
<div><font-awesome-icon :icon="['fas', 'list']" /></div>
</div>
</div>
</div>
<div class="info">
<span
class="title"
>
{{ titleForDisplay }}
</span>
</div>
</div>
</template>
<script src="./ft-playlist-selector.js" />
<style scoped lang="scss" src="./ft-playlist-selector.scss" />

View File

@ -1,11 +1,12 @@
.prompt {
position: fixed;
inset-block-start: 0px;
inset-inline-start: 0px;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 100%;
block-size: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 10;
/* Higher than components like playlist info */
z-index: 200;
padding: 15px;
box-sizing: border-box;
display: flex;

View File

@ -40,7 +40,8 @@ export default defineComponent({
},
data: function () {
return {
promptButtons: []
promptButtons: [],
lastActiveElement: null,
}
},
computed: {
@ -50,8 +51,11 @@ export default defineComponent({
},
beforeDestroy: function () {
document.removeEventListener('keydown', this.closeEventFunction, true)
this.lastActiveElement?.focus()
},
mounted: function () {
this.lastActiveElement = document.activeElement
document.addEventListener('keydown', this.closeEventFunction, true)
document.querySelector('.prompt').addEventListener('keydown', this.arrowKeys, true)
this.promptButtons = Array.from(

View File

@ -38,7 +38,7 @@
v-if="showClose"
:id="'prompt-' + sanitizedLabel + '-close'"
:label="$t('Close')"
tabindex="0"
:tabindex="0"
text-color="'var(--accent-color)'"
background-color="'var(--text-with-accent-color)'"
@click="hide"

View File

@ -24,8 +24,6 @@
.select {
position: relative;
inline-size: 200px;
padding-block: 0 10px;
padding-inline: 0 10px;
margin-block-start: 30px;
}
@ -37,13 +35,13 @@
.select-text {
position: relative;
font-family: inherit;
background-color: transparent;
background-color: var(--search-bar-color);
color: var(--primary-text-color);
inline-size: 240px;
padding-block: 10px;
padding-inline: 0 10px;
font-size: 18px;
border-radius: 0;
inline-size: 100%;
block-size: 45px;
padding-inline-start: 1rem;
font-size: 16px;
border-radius: 5px;
border: none;
}
@ -62,13 +60,13 @@
appearance: none;
-webkit-appearance:none;
text-overflow: ellipsis;
padding-inline-end: 1.1rem;
padding-inline-end: 1.5rem;
}
.iconSelect {
position: absolute;
inset-block-start: 10px;
inset-inline-end: 15px;
inset-inline-end: 10px;
/* Styling the down arrow */
padding: 0;
content: '';
@ -80,8 +78,8 @@
.selectTooltip {
position: absolute;
inset-block-start: -20px;
inset-inline-end: 17px;
inset-block-start: -22px;
inset-inline-end: 13px;
}
@ -108,7 +106,6 @@
.select-bar {
position: relative;
display: block;
inline-size: 200px;
}
.select-bar:before, .select-bar:after {
@ -145,25 +142,8 @@
opacity: 0.5;
}
@media only screen and (max-width: 1000px) {
.select .select-text {
max-inline-size: 240px;
}
}
@media only screen and (max-width: 800px) {
.select {
inline-size: 100%;
}
.select .select-text {
display:block;
max-inline-size: 95%;
}
}
@media only screen and (max-width: 680px) {
.select {
padding: 0px;
margin-inline-end: -15px;
}
}
}

View File

@ -62,6 +62,18 @@ export default defineComponent({
return this.$store.getters.getCurrentInvidiousInstance
},
selectedUserPlaylist: function () {
if (this.playlistId == null || this.playlistId === '') { return null }
return this.$store.getters.getPlaylist(this.playlistId)
},
playlistSharable() {
// `playlistId` can be undefined
// User playlist ID should not be shared
return this.playlistId && this.playlistId.length !== 0 && this.selectedUserPlaylist == null
},
invidiousURL() {
if (this.isChannel) {
return `${this.currentInvidiousInstance}/channel/${this.id}`
@ -71,7 +83,7 @@ export default defineComponent({
}
let videoUrl = `${this.currentInvidiousInstance}/watch?v=${this.id}`
// `playlistId` can be undefined
if (this.playlistId && this.playlistId.length !== 0) {
if (this.playlistSharable) {
// `index` seems can be ignored
videoUrl += `&list=${this.playlistId}`
}
@ -101,8 +113,7 @@ export default defineComponent({
return this.youtubePlaylistUrl
}
let videoUrl = `https://www.youtube.com/watch?v=${this.id}`
// `playlistId` can be undefined
if (this.playlistId && this.playlistId.length !== 0) {
if (this.playlistSharable) {
// `index` seems can be ignored
videoUrl += `&list=${this.playlistId}`
}
@ -116,12 +127,12 @@ export default defineComponent({
if (this.isPlaylist) {
return this.youtubePlaylistUrl
}
// `playlistId` can be undefined
if (this.playlistId && this.playlistId.length !== 0) {
const videoUrl = `https://youtu.be/${this.id}`
if (this.playlistSharable) {
// `index` seems can be ignored
return `https://www.youtube.com/watch?v=${this.id}&list=${this.playlistId}`
return `${videoUrl}?list=${this.playlistId}`
}
return `https://youtu.be/${this.id}`
return videoUrl
},
youtubeEmbedURL() {
@ -129,7 +140,7 @@ export default defineComponent({
return `https://www.youtube-nocookie.com/embed/videoseries?list=${this.id}`
}
return `https://www.youtube-nocookie.com/embed/${this.id}`
}
},
},
mounted() {
// Prevents to instantiate a ft-share-button for a video without a get-timestamp function

View File

@ -3,7 +3,8 @@
inset-inline-start: 50vw;
transform: translate(calc(-50% * var(--horizontal-directionality-coefficient)), 0);
inset-block-end: 50px;
z-index: 1;
/* Higher than any prompt */
z-index: 300;
display: flex;
flex-direction: column;
align-items: center;

View File

@ -30,7 +30,11 @@ export default defineComponent({
tooltipPosition: {
type: String,
default: 'bottom-left'
}
},
tooltipAllowNewlines: {
type: Boolean,
default: false,
},
},
data: function () {
return {

View File

@ -29,6 +29,7 @@
class="selectTooltip"
:position="tooltipPosition"
:tooltip="tooltip"
:allow-newlines="tooltipAllowNewlines"
/>
</label>
</div>

View File

@ -92,6 +92,16 @@
transform: translate(calc(-50% * var(--horizontal-directionality-coefficient)), 1em);
}
.text.allowNewlines {
white-space: pre-wrap;
text-align: start;
inline-size: 55vw;
}
@media only screen and (max-width: 1100px) {
inline-size: 40vw;
}
.tooltip {
display: inline-block;
position: relative;

View File

@ -13,7 +13,11 @@ export default defineComponent({
tooltip: {
type: String,
required: true
}
},
allowNewlines: {
type: Boolean,
default: false,
},
},
data() {
const id = `ft-tooltip-${++idCounter}`

View File

@ -10,11 +10,13 @@
<p
:id="id"
class="text"
:class="position"
:class="{
[position]: true,
allowNewlines,
}"
role="tooltip"
>
{{ tooltip }}
</p>
v-text="tooltip"
/>
</div>
</template>

View File

@ -321,6 +321,10 @@ export default defineComponent({
return playbackRates
},
enableSubtitlesByDefault: function () {
return this.$store.getters.getEnableSubtitlesByDefault
},
enableScreenshot: function () {
return this.$store.getters.getEnableScreenshot
},
@ -1406,6 +1410,14 @@ export default defineComponent({
const trackIndex = this.useDash ? 1 : 0
const tracks = this.player.textTracks()
// visually and semantically disable any other enabled tracks
for (let i = 0; i < tracks.length; ++i) {
if (i !== trackIndex && tracks[i].mode === 'showing') {
tracks[i].mode = 'disabled'
}
}
if (tracks.length > trackIndex) {
if (tracks[trackIndex].mode === 'showing') {
tracks[trackIndex].mode = 'disabled'
@ -1820,6 +1832,8 @@ export default defineComponent({
const bCode = captionB.language_code.split('-')
const aName = (captionA.label) // ex: english (auto-generated)
const bName = (captionB.label)
const aIsAutotranslated = captionA.is_autotranslated
const bIsAutotranslated = captionB.is_autotranslated
const userLocale = this.currentLocale.split('-') // ex. [en,US]
if (aCode[0] === userLocale[0]) { // caption a has same language as user's locale
if (bCode[0] === userLocale[0]) { // caption b has same language as user's locale
@ -1829,6 +1843,12 @@ export default defineComponent({
} else if (aName.search('auto') !== -1) {
// prefer caption b: a is auto-generated captions
return 1
} else if (bIsAutotranslated) {
// prefer caption a: b is auto-translated captions
return -1
} else if (aIsAutotranslated) {
// prefer caption b: a is auto-translated captions
return 1
} else if (aCode[1] === userLocale[1]) {
// prefer caption a: caption a has same county code as user's locale
return -1
@ -1864,15 +1884,16 @@ export default defineComponent({
captionList = this.captionHybridList
}
for (const caption of this.sortCaptions(captionList)) {
this.sortCaptions(captionList).forEach((caption, i) =>
this.player.addRemoteTextTrack({
kind: 'subtitles',
src: caption.url,
srclang: caption.language_code,
label: caption.label,
type: caption.type
type: caption.type,
default: i === 0 && this.enableSubtitlesByDefault
}, true)
}
)
},
toggleFullWindow: function () {

View File

@ -3,8 +3,7 @@
inline-size: auto;
}
/* https://vue-loader.vuejs.org/guide/scoped-css.html#deep-selectors */
.select:deep(.select-text) {
min-inline-size: 240px;
inline-size: auto;
}
.switchGrid {
gap: 10px;
margin-block-end: 12px;
}

View File

@ -80,8 +80,8 @@ export default defineComponent({
return this.$store.getters.getPlayNextVideo
},
enableSubtitles: function () {
return this.$store.getters.getEnableSubtitles
enableSubtitlesByDefault: function () {
return this.$store.getters.getEnableSubtitlesByDefault
},
forceLocalBackendForLegacy: function () {
@ -295,7 +295,7 @@ export default defineComponent({
'updateAutoplayVideos',
'updateAutoplayPlaylists',
'updatePlayNextVideo',
'updateEnableSubtitles',
'updateEnableSubtitlesByDefault',
'updateForceLocalBackendForLegacy',
'updateProxyVideos',
'updateDefaultTheatreMode',

View File

@ -4,13 +4,6 @@
>
<div class="switchColumnGrid">
<div class="switchColumn">
<ft-toggle-switch
v-if="false"
label="Enable Subtitles by Default"
:compact="true"
:default-value="enableSubtitles"
@change="updateEnableSubtitles"
/>
<ft-toggle-switch
:label="$t('Settings.Player Settings.Force Local Backend for Legacy Formats')"
:compact="true"
@ -26,6 +19,12 @@
:tooltip="$t('Tooltips.Player Settings.Proxy Videos Through Invidious')"
@change="updateProxyVideos"
/>
<ft-toggle-switch
:label="$t('Settings.Player Settings.Turn on Subtitles by Default')"
:compact="true"
:default-value="enableSubtitlesByDefault"
@change="updateEnableSubtitlesByDefault"
/>
<ft-toggle-switch
:label="$t('Settings.Player Settings.Enable Theatre Mode by Default')"
:compact="true"
@ -123,7 +122,7 @@
:min-value="0.25"
:max-value="8"
:step="0.25"
value-extension="×"
value-extension="x"
@change="updateDefaultPlayback"
/>
<ft-slider

View File

@ -1,36 +1,101 @@
import { defineComponent } from 'vue'
import { defineComponent, nextTick } from 'vue'
import { mapActions } from 'vuex'
import FtShareButton from '../ft-share-button/ft-share-button.vue'
import { copyToClipboard, formatNumber, openExternalLink } from '../../helpers/utils'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
import FtInput from '../ft-input/ft-input.vue'
import FtPrompt from '../ft-prompt/ft-prompt.vue'
import {
formatNumber,
showToast,
} from '../../helpers/utils'
export default defineComponent({
name: 'PlaylistInfo',
components: {
'ft-share-button': FtShareButton
'ft-share-button': FtShareButton,
'ft-flex-box': FtFlexBox,
'ft-icon-button': FtIconButton,
'ft-input': FtInput,
'ft-prompt': FtPrompt,
},
props: {
data: {
type: Object,
id: {
type: String,
required: true,
},
firstVideoId: {
type: String,
required: true,
},
firstVideoPlaylistItemId: {
type: String,
required: true,
},
playlistThumbnail: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
channelThumbnail: {
type: String,
required: true,
},
channelName: {
type: String,
required: true,
},
channelId: {
type: String,
default: null,
},
videoCount: {
type: Number,
required: true,
},
videos: {
type: Array,
required: true
}
},
viewCount: {
type: Number,
required: true,
},
lastUpdated: {
type: String,
default: undefined,
},
description: {
type: String,
required: true,
},
infoSource: {
type: String,
required: true,
},
moreVideoDataAvailable: {
type: Boolean,
required: true,
},
},
data: function () {
return {
id: '',
firstVideoId: '',
playlistThumbnail: '',
title: '',
channelThumbnail: '',
channelName: '',
channelId: null,
videoCount: 0,
viewCount: 0,
lastUpdated: '',
description: '',
infoSource: ''
editMode: false,
showDeletePlaylistPrompt: false,
showRemoveVideosOnWatchPrompt: false,
newTitle: '',
newDescription: '',
deletePlaylistPromptValues: [
'yes',
'no'
],
}
},
computed: {
hideSharingActions: function() {
hideSharingActions: function () {
return this.$store.getters.getHideSharingActions
},
@ -38,6 +103,10 @@ export default defineComponent({
return this.$store.getters.getCurrentInvidiousInstance
},
historyCacheById: function () {
return this.$store.getters.getHistoryCacheById
},
thumbnailPreference: function () {
return this.$store.getters.getThumbnailPreference
},
@ -58,15 +127,44 @@ export default defineComponent({
return this.$store.getters.getHideVideoViews
},
showPlaylists: function () {
return !this.$store.getters.getHidePlaylists
},
selectedUserPlaylist: function () {
return this.$store.getters.getPlaylist(this.id)
},
deletePlaylistPromptNames: function () {
return [
this.$t('Yes'),
this.$t('No')
]
},
firstVideoIdExists() {
return this.firstVideoId !== ''
},
parsedViewCount() {
return formatNumber(this.viewCount)
},
parsedVideoCount() {
return formatNumber(this.videoCount)
},
thumbnail: function () {
if (this.thumbnailPreference === 'hidden') {
if (this.thumbnailPreference === 'hidden' || !this.firstVideoIdExists) {
return require('../../assets/img/thumbnail_placeholder.svg')
}
let baseUrl
let baseUrl = 'https://i.ytimg.com'
if (this.backendPreference === 'invidious') {
baseUrl = this.currentInvidiousInstance
} else {
return this.data.playlistThumbnail
} else if (typeof this.playlistThumbnail === 'string' && this.playlistThumbnail.length > 0) {
// Use playlist thumbnail provided by YT when available
return this.playlistThumbnail
}
switch (this.thumbnailPreference) {
@ -79,49 +177,207 @@ export default defineComponent({
default:
return `${baseUrl}/vi/${this.firstVideoId}/mqdefault.jpg`
}
}
},
isUserPlaylist() {
return this.infoSource === 'user'
},
videoPlaylistType() {
return this.isUserPlaylist ? 'user' : ''
},
deletePlaylistButtonVisible: function() {
if (!this.isUserPlaylist) { return false }
// Cannot delete during edit
if (this.editMode) { return false }
// Cannot delete protected playlist
return !this.selectedUserPlaylist.protected
},
sharePlaylistButtonVisible: function() {
// Only online playlists can be shared
if (this.isUserPlaylist) { return false }
// Cannot delete protected playlist
return !this.hideSharingActions
},
quickBookmarkPlaylistId() {
return this.$store.getters.getQuickBookmarkTargetPlaylistId
},
quickBookmarkPlaylist() {
return this.$store.getters.getPlaylist(this.quickBookmarkPlaylistId)
},
quickBookmarkEnabled() {
return this.quickBookmarkPlaylist != null
},
markedAsQuickBookmarkTarget() {
// Only user playlists can be target
if (this.selectedUserPlaylist == null) { return false }
if (this.quickBookmarkPlaylist == null) { return false }
return this.quickBookmarkPlaylist._id === this.selectedUserPlaylist._id
},
},
mounted: function () {
this.id = this.data.id
this.firstVideoId = this.data.firstVideoId
this.title = this.data.title
this.channelName = this.data.channelName
this.channelThumbnail = this.data.channelThumbnail
this.channelId = this.data.channelId
this.uploadedTime = this.data.uploaded_at
this.description = this.data.description
this.infoSource = this.data.infoSource
// Causes errors if not put inside of a check
if (typeof (this.data.viewCount) !== 'undefined' && !isNaN(this.data.viewCount)) {
this.viewCount = this.hideViews ? null : formatNumber(this.data.viewCount)
}
if (typeof (this.data.videoCount) !== 'undefined' && !isNaN(this.data.videoCount)) {
this.videoCount = formatNumber(this.data.videoCount)
}
this.lastUpdated = this.data.lastUpdated
watch: {
showDeletePlaylistPrompt(shown) {
this.$emit(shown ? 'prompt-open' : 'prompt-close')
},
showRemoveVideosOnWatchPrompt(shown) {
this.$emit(shown ? 'prompt-open' : 'prompt-close')
},
},
created: function () {
this.newTitle = this.title
this.newDescription = this.description
},
methods: {
sharePlaylist: function (method) {
const youtubeUrl = `https://youtube.com/playlist?list=${this.id}`
const invidiousUrl = `${this.currentInvidiousInstance}/playlist?list=${this.id}`
switch (method) {
case 'copyYoutube':
copyToClipboard(youtubeUrl, { messageOnSuccess: this.$t('Share.YouTube URL copied to clipboard') })
break
case 'openYoutube':
openExternalLink(youtubeUrl)
break
case 'copyInvidious':
copyToClipboard(invidiousUrl, { messageOnSuccess: this.$t('Share.Invidious URL copied to clipboard') })
break
case 'openInvidious':
openExternalLink(invidiousUrl)
break
toggleCopyVideosPrompt: function (force = false) {
if (this.moreVideoDataAvailable && !this.isUserPlaylist && !force) {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["Some videos in the playlist are not loaded yet. Click here to copy anyway."]'), 5000, () => {
this.toggleCopyVideosPrompt(true)
})
return
}
}
}
this.showAddToPlaylistPromptForManyVideos({
videos: this.videos,
newPlaylistDefaultProperties: { title: this.title },
})
},
savePlaylistInfo: function () {
if (this.newTitle === '') {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["Playlist name cannot be empty. Please input a name."]'))
return
}
const playlist = {
playlistName: this.newTitle,
protected: this.selectedUserPlaylist.protected,
description: this.newDescription,
videos: this.selectedUserPlaylist.videos,
_id: this.id,
}
try {
this.updatePlaylist(playlist)
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["Playlist has been updated."]'))
} catch (e) {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["There was an issue with updating this playlist."]'))
console.error(e)
} finally {
this.exitEditMode()
}
},
enterEditMode: function () {
this.newTitle = this.title
this.newDescription = this.description
this.editMode = true
this.$emit('enter-edit-mode')
nextTick(() => {
// Some elements only present after rendering update
this.$refs.playlistTitleInput.focus()
})
},
exitEditMode: function () {
this.editMode = false
this.$emit('exit-edit-mode')
},
handleRemoveVideosOnWatchPromptAnswer: function (option) {
if (option === 'yes') {
const videosToWatch = this.selectedUserPlaylist.videos.filter((video) => {
return this.historyCacheById[video.videoId] == null
})
const removedVideosCount = this.selectedUserPlaylist.videos.length - videosToWatch.length
if (removedVideosCount === 0) {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["There were no videos to remove."]'))
this.showRemoveVideosOnWatchPrompt = false
return
}
const playlist = {
playlistName: this.title,
protected: this.selectedUserPlaylist.protected,
description: this.description,
videos: videosToWatch,
_id: this.id
}
try {
this.updatePlaylist(playlist)
showToast(this.$tc('User Playlists.SinglePlaylistView.Toast.{videoCount} video(s) have been removed', removedVideosCount, {
videoCount: removedVideosCount,
}))
} catch (e) {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["There was an issue with updating this playlist."]'))
console.error(e)
}
}
this.showRemoveVideosOnWatchPrompt = false
},
handleDeletePlaylistPromptAnswer: function (option) {
if (option === 'yes') {
if (this.selectedUserPlaylist.protected) {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["This playlist is protected and cannot be removed."]'))
} else {
this.removePlaylist(this.id)
this.$router.push(
{
path: '/userPlaylists'
}
)
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["Playlist {playlistName} has been deleted."]', {
playlistName: this.title,
}))
}
}
this.showDeletePlaylistPrompt = false
},
enableQuickBookmarkForThisPlaylist() {
const currentQuickBookmarkTargetPlaylist = this.quickBookmarkPlaylist
this.updateQuickBookmarkTargetPlaylistId(this.id)
if (currentQuickBookmarkTargetPlaylist != null) {
showToast(
this.$t('User Playlists.SinglePlaylistView.Toast["This playlist is now used for quick bookmark instead of {oldPlaylistName}. Click here to undo"]', {
oldPlaylistName: currentQuickBookmarkTargetPlaylist.playlistName,
}),
5000,
() => {
this.updateQuickBookmarkTargetPlaylistId(currentQuickBookmarkTargetPlaylist._id)
showToast(
this.$t('User Playlists.SinglePlaylistView.Toast["Reverted to use {oldPlaylistName} for quick bookmark"]', {
oldPlaylistName: currentQuickBookmarkTargetPlaylist.playlistName,
}),
5000,
)
},
)
} else {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast.This playlist is now used for quick bookmark'))
}
},
disableQuickBookmark() {
this.updateQuickBookmarkTargetPlaylistId(null)
showToast(this.$t('User Playlists.SinglePlaylistView.Toast.Quick bookmark disabled'))
},
...mapActions([
'showAddToPlaylistPromptForManyVideos',
'updatePlaylist',
'removePlaylist',
'updateQuickBookmarkTargetPlaylistId',
]),
},
})

View File

@ -2,9 +2,14 @@
inline-size: 100%;
}
.playlistThumbnail img {
.playlistThumbnail .firstVideoLink {
cursor: pointer;
}
.playlistThumbnail img {
inline-size: 100%;
// Ensure placeholder image displayed at same aspect ratio as most other images
aspect-ratio: 16/9;
@media only screen and (max-width: 800px) {
display: none;
@ -58,5 +63,12 @@
.channelShareWrapper {
column-gap: 8px;
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: auto minmax(min-content, 1fr);
}
.playlistOptions {
display: grid;
grid-auto-flow: column;
column-gap: 8px;
justify-content: flex-end;
}

View File

@ -4,9 +4,15 @@
class="playlistThumbnail"
>
<router-link
v-if="firstVideoIdExists"
class="firstVideoLink"
:to="{
path: `/watch/${firstVideoId}`,
query: { playlistId: id }
query: {
playlistId: id,
playlistType: videoPlaylistType,
playlistItemId: firstVideoPlaylistItemId,
},
}"
tabindex="-1"
>
@ -16,14 +22,36 @@
:style="{filter: blurThumbnailsStyle}"
>
</router-link>
<img
v-else
:src="thumbnail"
alt=""
:style="{filter: blurThumbnailsStyle}"
>
</div>
<div class="playlistStats">
<h2 class="playlistTitle">
<ft-input
v-if="editMode"
ref="playlistTitleInput"
:placeholder="$t('User Playlists.Playlist Name')"
:show-action-button="false"
:show-label="false"
:value="newTitle"
@input="(input) => (newTitle = input)"
/>
<h2
v-else
class="playlistTitle"
>
{{ title }}
</h2>
<p>
{{ videoCount }} {{ $t("Playlist.Videos") }} - <span v-if="!hideViews">{{ viewCount }} {{ $t("Playlist.Views") }} -</span>
{{ $tc('Global.Counts.Video Count', videoCount, {count: parsedVideoCount}) }}
<span v-if="!hideViews && !isUserPlaylist">
- {{ $tc('Global.Counts.View Count', viewCount, {count: parsedViewCount}) }}
</span>
<span>- </span>
<span v-if="infoSource !== 'local'">
{{ $t("Playlist.Last Updated On") }}
</span>
@ -31,7 +59,16 @@
</p>
</div>
<ft-input
v-if="editMode"
:placeholder="$t('User Playlists.Playlist Description')"
:show-action-button="false"
:show-label="false"
:value="newDescription"
@input="(input) => newDescription = input"
/>
<p
v-else
class="playlistDescription"
v-text="description"
/>
@ -42,7 +79,7 @@
class="channelShareWrapper"
>
<router-link
v-if="channelId"
v-if="!isUserPlaylist && channelId"
class="playlistChannel"
:to="`/channel/${channelId}`"
>
@ -68,11 +105,85 @@
</h3>
</div>
<ft-share-button
v-if="!hideSharingActions"
:id="id"
:dropdown-position-y="description ? 'top' : 'bottom'"
share-target-type="Playlist"
<div class="playlistOptions">
<ft-icon-button
v-if="editMode"
:title="$t('User Playlists.Save Changes')"
:icon="['fas', 'save']"
theme="secondary"
@click="savePlaylistInfo"
/>
<ft-icon-button
v-if="editMode"
:title="$t('User Playlists.Cancel')"
:icon="['fas', 'times']"
theme="secondary"
@click="exitEditMode"
/>
<ft-icon-button
v-if="!editMode && isUserPlaylist"
:title="$t('User Playlists.Edit Playlist Info')"
:icon="['fas', 'edit']"
theme="secondary"
@click="enterEditMode"
/>
<ft-icon-button
v-if="videoCount > 0 && showPlaylists && !editMode"
:title="$t('User Playlists.Copy Playlist')"
:icon="['fas', 'copy']"
theme="secondary"
@click="toggleCopyVideosPrompt"
/>
<ft-icon-button
v-if="!editMode && isUserPlaylist && !markedAsQuickBookmarkTarget"
:title="$t('User Playlists.Enable Quick Bookmark With This Playlist')"
:icon="['fas', 'link']"
theme="secondary"
@click="enableQuickBookmarkForThisPlaylist"
/>
<ft-icon-button
v-if="!editMode && isUserPlaylist && markedAsQuickBookmarkTarget"
:title="$t('User Playlists.Disable Quick Bookmark')"
:icon="['fas', 'link-slash']"
theme="secondary"
@click="disableQuickBookmark"
/>
<ft-icon-button
v-if="!editMode && isUserPlaylist && videoCount > 0"
:title="$t('User Playlists.Remove Watched Videos')"
:icon="['fas', 'eye-slash']"
theme="primary"
@click="showRemoveVideosOnWatchPrompt = true"
/>
<ft-icon-button
v-if="deletePlaylistButtonVisible"
:title="$t('User Playlists.Delete Playlist')"
:icon="['fas', 'trash']"
theme="primary"
@click="showDeletePlaylistPrompt = true"
/>
<ft-share-button
v-if="sharePlaylistButtonVisible"
:id="id"
:dropdown-position-y="description ? 'top' : 'bottom'"
share-target-type="Playlist"
/>
</div>
<ft-prompt
v-if="showDeletePlaylistPrompt"
:label="$t('User Playlists.Are you sure you want to delete this playlist? This cannot be undone')"
:option-names="deletePlaylistPromptNames"
:option-values="deletePlaylistPromptValues"
@click="handleDeletePlaylistPromptAnswer"
/>
<ft-prompt
v-if="showRemoveVideosOnWatchPrompt"
:label="$t('User Playlists.Are you sure you want to remove all watched videos from this playlist? This cannot be undone')"
:option-names="deletePlaylistPromptNames"
:option-values="deletePlaylistPromptValues"
@click="handleRemoveVideosOnWatchPromptAnswer"
/>
</div>
</div>

View File

@ -22,6 +22,7 @@ export default defineComponent({
showSearchCachePrompt: false,
showRemoveHistoryPrompt: false,
showRemoveSubscriptionsPrompt: false,
showRemovePlaylistsPrompt: false,
promptValues: [
'yes',
'no'
@ -114,6 +115,15 @@ export default defineComponent({
this.clearSubscriptionsCache()
},
handleRemovePlaylists: function (option) {
this.showRemovePlaylistsPrompt = false
if (option !== 'yes') { return }
this.removeAllPlaylists()
this.updateQuickBookmarkTargetPlaylistId('favorites')
showToast(this.$t('Settings.Privacy Settings.All playlists have been removed'))
},
...mapActions([
'updateRememberHistory',
'updateRemoveVideoMetaFiles',
@ -125,6 +135,10 @@ export default defineComponent({
'removeProfile',
'updateActiveProfile',
'clearSubscriptionsCache',
'updateAllSubscriptionsList',
'updateProfileSubscriptions',
'removeAllPlaylists',
'updateQuickBookmarkTargetPlaylistId',
])
}
})

View File

@ -59,6 +59,12 @@
background-color="var(--primary-color)"
@click="showRemoveSubscriptionsPrompt = true"
/>
<ft-button
:label="$t('Settings.Privacy Settings.Remove All Playlists')"
text-color="var(--text-with-main-color)"
background-color="var(--primary-color)"
@click="showRemovePlaylistsPrompt = true"
/>
</ft-flex-box>
<ft-prompt
v-if="showSearchCachePrompt"
@ -81,6 +87,13 @@
:option-values="promptValues"
@click="handleRemoveSubscriptions"
/>
<ft-prompt
v-if="showRemovePlaylistsPrompt"
:label="$t('Settings.Privacy Settings.Are you sure you want to remove all your playlists?')"
:option-names="promptNames"
:option-values="promptValues"
@click="handleRemovePlaylists"
/>
</ft-settings-section>
</template>

View File

@ -0,0 +1,3 @@
.protocol-dropdown {
margin-block-end: 1rem;
}

View File

@ -18,6 +18,7 @@
:value="proxyProtocol"
:select-names="protocolNames"
:select-values="protocolValues"
class="protocol-dropdown"
@change="handleUpdateProxyProtocol"
/>
</ft-flex-box>
@ -77,3 +78,4 @@
</template>
<script src="./proxy-settings.js" />
<style scoped src="./proxy-settings.css" />

View File

@ -42,7 +42,13 @@ export default defineComponent({
useDeArrowTitles: function () {
return this.$store.getters.getUseDeArrowTitles
}
},
useDeArrowThumbnails: function () {
return this.$store.getters.getUseDeArrowThumbnails
},
deArrowThumbnailGeneratorUrl: function () {
return this.$store.getters.getDeArrowThumbnailGeneratorUrl
},
},
methods: {
handleUpdateSponsorBlock: function (value) {
@ -53,12 +59,22 @@ export default defineComponent({
this.updateUseDeArrowTitles(value)
},
handleUpdateUseDeArrowThumbnails: function (value) {
this.updateUseDeArrowThumbnails(value)
},
handleUpdateSponsorBlockUrl: function (value) {
const sponsorBlockUrlWithoutTrailingSlash = value.replace(/\/$/, '')
const sponsorBlockUrlWithoutApiSuffix = sponsorBlockUrlWithoutTrailingSlash.replace(/\/api$/, '')
this.updateSponsorBlockUrl(sponsorBlockUrlWithoutApiSuffix)
},
handleUpdateDeArrowThumbnailGeneratorUrl: function (value) {
const urlWithoutTrailingSlash = value.replace(/\/$/, '')
const urlWithoutApiSuffix = urlWithoutTrailingSlash.replace(/\/api$/, '')
this.updateDeArrowThumbnailGeneratorUrl(urlWithoutApiSuffix)
},
handleUpdateSponsorBlockShowSkippedToast: function (value) {
this.updateSponsorBlockShowSkippedToast(value)
},
@ -67,7 +83,9 @@ export default defineComponent({
'updateUseSponsorBlock',
'updateSponsorBlockUrl',
'updateSponsorBlockShowSkippedToast',
'updateUseDeArrowTitles'
'updateUseDeArrowTitles',
'updateUseDeArrowThumbnails',
'updateDeArrowThumbnailGeneratorUrl'
])
}
})

View File

@ -14,9 +14,15 @@
:tooltip="$t('Tooltips.SponsorBlock Settings.UseDeArrowTitles')"
@change="handleUpdateUseDeArrowTitles"
/>
<ft-toggle-switch
:label="$t('Settings.SponsorBlock Settings.UseDeArrowThumbnails')"
:default-value="useDeArrowThumbnails"
:tooltip="$t('Tooltips.SponsorBlock Settings.UseDeArrowThumbnails')"
@change="handleUpdateUseDeArrowThumbnails"
/>
</ft-flex-box>
<template
v-if="useSponsorBlock || useDeArrowTitles"
v-if="useSponsorBlock || useDeArrowTitles || useDeArrowThumbnails"
>
<ft-flex-box
v-if="useSponsorBlock"
@ -37,6 +43,19 @@
@input="handleUpdateSponsorBlockUrl"
/>
</ft-flex-box>
<ft-flex-box
v-if="useDeArrowThumbnails"
>
<ft-input
v-if="useDeArrowThumbnails"
:placeholder="$t('Settings.SponsorBlock Settings[\'DeArrow Thumbnail Generator API Url (Default is https://dearrow-thumb.ajay.app)\']')"
:show-action-button="false"
:show-label="true"
:value="deArrowThumbnailGeneratorUrl"
@input="handleUpdateDeArrowThumbnailGeneratorUrl"
/>
</ft-flex-box>
<ft-flex-box
v-if="useSponsorBlock"
>

View File

@ -108,7 +108,7 @@ export default defineComponent({
case 'r':
case 'R':
case 'F5':
if (!this.isLoading) {
if (!this.isLoading && this.activeSubscriptionList.length > 0) {
this.$emit('refresh')
}
break

View File

@ -56,7 +56,7 @@
/>
</ft-flex-box>
<ft-icon-button
v-if="!isLoading"
v-if="!isLoading && activeSubscriptionList.length > 0"
:icon="['fas', 'sync']"
class="floatingTopButton"
:title="$t('Subscriptions.Refresh Subscriptions')"

View File

@ -37,7 +37,8 @@ export default defineComponent({
'dracula',
'catppuccinMocha',
'pastelPink',
'hotPink'
'hotPink',
'nordic'
]
}
},
@ -102,7 +103,8 @@ export default defineComponent({
this.$t('Settings.Theme Settings.Base Theme.Dracula'),
this.$t('Settings.Theme Settings.Base Theme.Catppuccin Mocha'),
this.$t('Settings.Theme Settings.Base Theme.Pastel Pink'),
this.$t('Settings.Theme Settings.Base Theme.Hot Pink')
this.$t('Settings.Theme Settings.Base Theme.Hot Pink'),
this.$t('Settings.Theme Settings.Base Theme.Nordic')
]
},

View File

@ -31,17 +31,15 @@
float: var(--float-right-ltr-rtl-value);
}
@media only screen and (max-width: 800px) {
@media only screen and (max-width: 1000px) {
.commentSort {
float: none;
inline-size: fit-content;
}
}
.comment {
padding: 15px;
position: relative;
}
.hideComments {

View File

@ -125,20 +125,8 @@ export default defineComponent({
return this.$store.getters.getHideVideoViews
},
favoritesPlaylist: function () {
return this.$store.getters.getFavorites
},
inFavoritesPlaylist: function () {
const index = this.favoritesPlaylist.videos.findIndex((video) => {
return video.videoId === this.id
})
return index !== -1
},
favoriteIconTheme: function () {
return this.inFavoritesPlaylist ? 'base favorite' : 'base'
showPlaylists: function () {
return !this.$store.getters.getHidePlaylists
},
downloadLinkOptions: function () {
@ -226,7 +214,37 @@ export default defineComponent({
defaultPlayback: function () {
return this.$store.getters.getDefaultPlayback
}
},
quickBookmarkPlaylistId() {
return this.$store.getters.getQuickBookmarkTargetPlaylistId
},
quickBookmarkPlaylist() {
return this.$store.getters.getPlaylist(this.quickBookmarkPlaylistId)
},
isQuickBookmarkEnabled() {
return this.quickBookmarkPlaylist != null
},
isInQuickBookmarkPlaylist: function () {
if (!this.isQuickBookmarkEnabled) { return false }
return this.quickBookmarkPlaylist.videos.some((video) => {
return video.videoId === this.id
})
},
quickBookmarkIconText: function () {
if (!this.isQuickBookmarkEnabled) { return false }
const translationProperties = {
playlistName: this.quickBookmarkPlaylist.playlistName,
}
return this.isInQuickBookmarkPlaylist
? this.$t('User Playlists.Remove from Favorites', translationProperties)
: this.$t('User Playlists.Add to Favorites', translationProperties)
},
quickBookmarkIconTheme: function () {
return this.isInQuickBookmarkPlaylist ? 'base favorite' : 'base'
},
},
mounted: function () {
if ('mediaSession' in navigator) {
@ -259,7 +277,7 @@ export default defineComponent({
handleExternalPlayer: function () {
this.$emit('pause-player')
this.openInExternalPlayer({
const payload = {
watchProgress: this.getTimestamp(),
playbackRate: this.defaultPlayback,
videoId: this.id,
@ -268,16 +286,19 @@ export default defineComponent({
playlistIndex: this.getPlaylistIndex(),
playlistReverse: this.getPlaylistReverse(),
playlistShuffle: this.getPlaylistShuffle(),
playlistLoop: this.getPlaylistLoop()
})
},
toggleSave: function () {
if (this.inFavoritesPlaylist) {
this.removeFromPlaylist()
} else {
this.addToPlaylist()
playlistLoop: this.getPlaylistLoop(),
}
// Only play video in non playlist mode when user playlist detected
if (this.inUserPlaylist) {
Object.assign(payload, {
playlistId: null,
playlistIndex: null,
playlistReverse: null,
playlistShuffle: null,
playlistLoop: null,
})
}
this.openInExternalPlayer(payload)
},
handleDownload: function (index) {
@ -306,47 +327,73 @@ export default defineComponent({
return group[1]
},
addToPlaylist: function () {
togglePlaylistPrompt: function () {
const videoData = {
videoId: this.id,
title: this.title,
author: this.channelName,
authorId: this.channelId,
published: '',
description: this.description,
viewCount: this.viewCount,
lengthSeconds: this.lengthSeconds,
timeAdded: new Date().getTime(),
isLive: false,
type: 'video',
}
const payload = {
playlistName: 'Favorites',
videoData: videoData
}
this.addVideo(payload)
showToast(this.$t('Video.Video has been saved'))
this.showAddToPlaylistPromptForManyVideos({ videos: [videoData] })
},
removeFromPlaylist: function () {
const payload = {
playlistName: 'Favorites',
videoId: this.id
toggleQuickBookmarked() {
if (!this.isQuickBookmarkEnabled) {
// This should be prevented by UI
return
}
this.removeVideo(payload)
if (this.isInQuickBookmarkPlaylist) {
this.removeFromQuickBookmarkPlaylist()
} else {
this.addToQuickBookmarkPlaylist()
}
},
addToQuickBookmarkPlaylist() {
const videoData = {
videoId: this.id,
title: this.title,
author: this.channelName,
authorId: this.channelId,
description: this.description,
viewCount: this.viewCount,
lengthSeconds: this.lengthSeconds,
}
this.addVideos({
_id: this.quickBookmarkPlaylist._id,
videos: [videoData],
})
// Update playlist's `lastUpdatedAt`
this.updatePlaylist({ _id: this.quickBookmarkPlaylist._id })
// TODO: Maybe show playlist name
showToast(this.$t('Video.Video has been saved'))
},
removeFromQuickBookmarkPlaylist() {
this.removeVideo({
_id: this.quickBookmarkPlaylist._id,
// Remove all playlist items with same videoId
videoId: this.id,
})
// Update playlist's `lastUpdatedAt`
this.updatePlaylist({ _id: this.quickBookmarkPlaylist._id })
// TODO: Maybe show playlist name
showToast(this.$t('Video.Video has been removed from your saved list'))
},
...mapActions([
'openInExternalPlayer',
'addVideo',
'downloadMedia',
'showAddToPlaylistPromptForManyVideos',
'addVideos',
'updatePlaylist',
'removeVideo',
'downloadMedia'
])
}
})

View File

@ -1,27 +1,30 @@
.watchVideoInfo {
column-gap: 15px;
display: grid;
grid-template-columns: auto minmax(min-content, 1fr);
display: flex;
flex-direction: column;
padding: 16px;
@media screen and (max-width: 680px) {
grid-template-columns: auto;
}
gap: 8px;
}
.videoTitle {
display: block;
font-size: 22px;
font-weight: normal;
margin-block: 0 24px;
margin-block: 0;
margin-inline: 0;
margin-block-end: 1em;
word-break: break-word;
}
.channelInformation {
.videoMetrics, .videoButtons {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.videoButtons {
.profileRow {
display: flex;
align-items: center;
}
.channelThumbnail {
@ -36,6 +39,7 @@
cursor: pointer;
display: block;
margin-inline-start: 6px;
margin-block-end: 3px;
position: relative;
text-decoration: inherit;
inset-block-start: -2px;
@ -44,41 +48,33 @@
.subscribeButton {
font-size: 14px;
margin-inline-start: 6px;
margin-block-start: 6px;
margin-block: 0;
padding: 6px;
}
}
.viewCount,
.datePublished {
color: var(--secondary-text-color);
font-size: 15px;
text-align: end;
.videoOptions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
@media screen and (max-width: 680px) {
text-align: start;
@media screen and (max-width: 680px) {
:deep(.iconDropdown) {
inset-inline-start: auto;
inset-inline-end: calc(50% - 20px);
}
}
}
}
.viewCount {
margin-block: 18px 0;
margin-inline: 0;
}
.videoMetrics {
font-size: 14px;
color: var(--tertiary-text-color);
.datePublished {
margin-block: 4px 0;
margin-inline: 0;
@media screen and (max-width: 680px) {
margin-block-start: 16px;
}
}
.likeSection {
.likeSection {
color: var(--tertiary-text-color);
display: flex;
flex-direction: column;
font-size: 16px;
margin-inline-start: auto;
margin-block-start: 4px;
max-inline-size: 210px;
@ -97,24 +93,8 @@
.likeCount {
margin-inline-end: 0;
display: flex;
gap: 3px;
}
}
.videoOptions {
display: flex;
justify-content: flex-end;
margin-block-start: 16px;
.option:not(:first-child) {
margin-inline-start: 4px;
}
@media screen and (max-width: 680px) {
justify-content: flex-start;
:deep(.iconDropdown) {
inset-inline-start: calc(50% - 20px);
inset-inline-end: auto;
}
}
}

View File

@ -6,47 +6,10 @@
>
{{ title }}
</h1>
<div
class="channelInformation"
>
<div
class="profileRow"
>
<div>
<router-link
:to="`/channel/${channelId}`"
>
<img
:src="channelThumbnail"
class="channelThumbnail"
alt=""
>
</router-link>
</div>
<div>
<router-link
:to="`/channel/${channelId}`"
class="channelName"
>
{{ channelName }}
</router-link>
<ft-subscribe-button
v-if="!hideUnsubscribeButton"
:channel-id="channelId"
:channel-name="channelName"
:channel-thumbnail="channelThumbnail"
:subscription-count-text="subscriptionCountText"
/>
</div>
</div>
</div>
</div>
<div>
<div class="datePublished">
{{ publishedString }} {{ dateString }}
</div>
<div class="viewCount">
{{ parsedViewCount }}
<div class="videoMetrics">
<div class="datePublishedAndViewCount">
{{ publishedString }} {{ dateString }} {{ parsedViewCount }}
</div>
<div
v-if="!hideVideoLikesAndDislikes"
@ -55,9 +18,7 @@
<div
class="likeSection"
>
<div>
<span class="likeCount"><font-awesome-icon :icon="['fas', 'thumbs-up']" /> {{ parsedLikeCount }}</span>
</div>
<span class="likeCount"><font-awesome-icon :icon="['fas', 'thumbs-up']" /> {{ parsedLikeCount }}</span>
</div>
</div>
<!--
@ -80,14 +41,57 @@
</div>
</div>
-->
</div>
<div class="videoButtons">
<div
class="profileRow"
>
<div>
<router-link
:to="`/channel/${channelId}`"
>
<img
:src="channelThumbnail"
class="channelThumbnail"
alt=""
>
</router-link>
</div>
<div>
<router-link
:to="`/channel/${channelId}`"
class="channelName"
>
{{ channelName }}
</router-link>
<ft-subscribe-button
v-if="!hideUnsubscribeButton"
:channel-id="channelId"
:channel-name="channelName"
:channel-thumbnail="channelThumbnail"
:subscription-count-text="subscriptionCountText"
/>
</div>
</div>
<div class="videoOptions">
<ft-icon-button
v-if="!isUpcoming"
:title="$t('Video.Save Video')"
:icon="['fas', 'star']"
v-if="showPlaylists && !isUpcoming"
:title="$t('User Playlists.Add to Playlist')"
:icon="['fas', 'plus']"
class="option"
:theme="favoriteIconTheme"
@click="toggleSave"
theme="base"
@click="togglePlaylistPrompt"
/>
<ft-icon-button
v-if="isQuickBookmarkEnabled"
:title="quickBookmarkIconText"
:icon="['fas', 'star']"
class="quickBookmarkVideoIcon"
:class="{
bookmarked: isInQuickBookmarkPlaylist,
}"
:theme="quickBookmarkIconTheme"
@click="toggleQuickBookmarked"
/>
<ft-icon-button
v-if="externalPlayer !== ''"

View File

@ -85,19 +85,6 @@
transition: background 0.2s ease-in;
}
.videoIndexContainer {
text-align: center;
}
.videoIndex {
color: var(--tertiary-text-color);
}
.videoIndexIcon {
font-size: 14px;
color: var(--tertiary-text-color);
}
.videoInfo {
margin-inline-start: 30px;
position: relative;

View File

@ -2,7 +2,7 @@ import { defineComponent, nextTick } from 'vue'
import { mapMutations } from 'vuex'
import FtLoader from '../ft-loader/ft-loader.vue'
import FtCard from '../ft-card/ft-card.vue'
import FtListVideoLazy from '../ft-list-video-lazy/ft-list-video-lazy.vue'
import FtListVideoNumbered from '../ft-list-video-numbered/ft-list-video-numbered.vue'
import { copyToClipboard, showToast } from '../../helpers/utils'
import {
getLocalPlaylist,
@ -16,25 +16,33 @@ export default defineComponent({
components: {
'ft-loader': FtLoader,
'ft-card': FtCard,
'ft-list-video-lazy': FtListVideoLazy,
'ft-list-video-numbered': FtListVideoNumbered
},
props: {
playlistId: {
type: String,
required: true
required: true,
},
playlistType: {
type: String,
default: null
},
videoId: {
type: String,
required: true
required: true,
},
playlistItemId: {
type: String,
default: null,
},
watchViewLoading: {
type: Boolean,
required: true
required: true,
},
},
data: function () {
return {
isLoading: false,
isLoading: true,
shuffleEnabled: false,
loopEnabled: false,
reversePlaylist: false,
@ -44,6 +52,8 @@ export default defineComponent({
playlistTitle: '',
playlistItems: [],
randomizedPlaylistItems: [],
getPlaylistInfoRun: false,
}
},
computed: {
@ -55,16 +65,40 @@ export default defineComponent({
return this.$store.getters.getBackendFallback
},
currentVideoIndex: function () {
const index = this.playlistItems.findIndex((item) => {
if (typeof item.videoId !== 'undefined') {
isUserPlaylist: function () {
return this.playlistType === 'user'
},
userPlaylistsReady: function () {
return this.$store.getters.getPlaylistsReady
},
selectedUserPlaylist: function () {
if (this.playlistId == null || this.playlistId === '') { return null }
return this.$store.getters.getPlaylist(this.playlistId)
},
selectedUserPlaylistVideoCount () {
return this.selectedUserPlaylist?.videos?.length
},
selectedUserPlaylistLastUpdatedAt () {
return this.selectedUserPlaylist?.lastUpdatedAt
},
currentVideoIndexZeroBased: function () {
return this.playlistItems.findIndex((item) => {
if (item.playlistItemId != null && this.playlistItemId != null) {
return item.playlistItemId === this.playlistItemId
} else if (item.videoId != null) {
return item.videoId === this.videoId
} else {
return item.id === this.videoId
}
})
return index + 1
},
currentVideoIndexOneBased: function () {
return this.currentVideoIndexZeroBased + 1
},
currentVideo: function () {
return this.playlistItems[this.currentVideoIndexZeroBased]
},
playlistVideoCount: function () {
@ -87,27 +121,67 @@ export default defineComponent({
},
videoIndexInPlaylistItems: function () {
if (this.shuffleEnabled) {
return this.randomizedPlaylistItems.findIndex((item) => {
return item === this.videoId
})
} else {
return this.playlistItems.findIndex((item) => {
return (item.id ?? item.videoId) === this.videoId
})
const playlistItems = this.shuffleEnabled ? this.randomizedPlaylistItems : this.playlistItems
return playlistItems.findIndex((item) => {
if (item.playlistItemId != null && this.playlistItemId != null) {
return item.playlistItemId === this.playlistItemId
} else if (item.videoId != null) {
return item.videoId === this.videoId
} else {
return item.id === this.videoId
}
})
},
videoIsFirstPlaylistItem: function () {
return this.videoIndexInPlaylistItems === 0
},
videoIsLastPlaylistItem: function () {
return this.videoIndexInPlaylistItems === (this.playlistItems.length - 1)
},
videoIsNotPlaylistItem: function () {
return this.videoIndexInPlaylistItems === -1
},
playlistPageLinkTo() {
// For `router-link` attribute `to`
return {
path: `/playlist/${this.playlistId}`,
query: {
playlistType: this.isUserPlaylist ? 'user' : '',
},
}
},
},
watch: {
userPlaylistsReady: function() {
this.getPlaylistInfoWithDelay()
},
selectedUserPlaylistVideoCount () {
// Re-fetch from local store when current user playlist updated
this.parseUserPlaylist(this.selectedUserPlaylist, { allowPlayingVideoRemoval: true })
this.shufflePlaylistItems()
},
selectedUserPlaylistLastUpdatedAt () {
// Re-fetch from local store when current user playlist updated
this.parseUserPlaylist(this.selectedUserPlaylist, { allowPlayingVideoRemoval: true })
},
playlistItemId (newId, _oldId) {
// Playing online video
if (newId == null) { return }
// Re-fetch from local store when different item played
this.parseUserPlaylist(this.selectedUserPlaylist, { allowPlayingVideoRemoval: true })
},
videoId: function (newId, oldId) {
// Check if next video is from the shuffled list or if the user clicked a different video
if (this.shuffleEnabled) {
const newVideoIndex = this.randomizedPlaylistItems.findIndex((item) => {
return item === newId
return item.videoId === newId
})
const oldVideoIndex = this.randomizedPlaylistItems.findIndex((item) => {
return item === oldId
return item.videoId === oldId
})
if ((newVideoIndex - 1) !== oldVideoIndex) {
@ -142,17 +216,14 @@ export default defineComponent({
this.getPlaylistInformationLocal()
}
}
}
},
},
mounted: function () {
const cachedPlaylist = this.$store.getters.getCachedPlaylist
if (cachedPlaylist?.id === this.playlistId) {
this.loadCachedPlaylistInformation(cachedPlaylist)
} else if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getPlaylistInformationInvidious()
} else {
this.getPlaylistInformationLocal()
this.getPlaylistInfoWithDelay()
}
if ('mediaSession' in navigator) {
@ -167,6 +238,24 @@ export default defineComponent({
}
},
methods: {
getPlaylistInfoWithDelay: function () {
if (this.getPlaylistInfoRun) { return }
this.isLoading = true
// `selectedUserPlaylist` result accuracy relies on data being ready
if (this.isUserPlaylist && !this.userPlaylistsReady) { return }
this.getPlaylistInfoRun = true
if (this.selectedUserPlaylist != null) {
this.parseUserPlaylist(this.selectedUserPlaylist)
} else if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getPlaylistInformationInvidious()
} else {
this.getPlaylistInformationLocal()
}
},
toggleLoop: function () {
if (this.loopEnabled) {
this.loopEnabled = false
@ -193,7 +282,9 @@ export default defineComponent({
showToast(this.$t('The playlist has been reversed'))
this.reversePlaylist = !this.reversePlaylist
this.playlistItems = this.playlistItems.reverse()
// Create a new array to avoid changing array in data store state
// it could be user playlist or cache playlist
this.playlistItems = [].concat(this.playlistItems).reverse()
setTimeout(() => {
this.isLoading = false
}, 1)
@ -211,117 +302,84 @@ export default defineComponent({
playNextVideo: function () {
const playlistInfo = {
playlistId: this.playlistId
playlistId: this.playlistId,
playlistType: this.playlistType,
}
const videoIndex = this.videoIndexInPlaylistItems
const targetVideoIndex = (this.videoIsNotPlaylistItem || this.videoIsLastPlaylistItem) ? 0 : videoIndex + 1
if (this.shuffleEnabled) {
const videoIndex = this.randomizedPlaylistItems.findIndex((item) => {
return item === this.videoId
})
if (videoIndex === this.randomizedPlaylistItems.length - 1) {
if (this.loopEnabled) {
this.$router.push(
{
path: `/watch/${this.randomizedPlaylistItems[0]}`,
query: playlistInfo
}
)
showToast(this.$t('Playing Next Video'))
this.shufflePlaylistItems()
} else {
showToast(this.$t('The playlist has ended. Enable loop to continue playing'))
}
} else {
this.$router.push(
{
path: `/watch/${this.randomizedPlaylistItems[videoIndex + 1]}`,
query: playlistInfo
}
)
showToast(this.$t('Playing Next Video'))
let doShufflePlaylistItems = false
if (this.videoIsLastPlaylistItem && !this.loopEnabled) {
showToast(this.$t('The playlist has ended. Enable loop to continue playing'))
return
}
// loopEnabled = true
if (this.videoIsLastPlaylistItem || this.videoIsNotPlaylistItem) { doShufflePlaylistItems = true }
const targetPlaylistItem = this.randomizedPlaylistItems[targetVideoIndex]
this.$router.push(
{
path: `/watch/${targetPlaylistItem.videoId}`,
query: Object.assign(playlistInfo, { playlistItemId: targetPlaylistItem.playlistItemId }),
}
)
showToast(this.$t('Playing Next Video'))
if (doShufflePlaylistItems) { this.shufflePlaylistItems() }
} else {
const videoIndex = this.playlistItems.findIndex((item) => {
return (item.id ?? item.videoId) === this.videoId
})
const targetPlaylistItem = this.playlistItems[targetVideoIndex]
if (videoIndex === this.playlistItems.length - 1) {
if (this.loopEnabled) {
this.$router.push(
{
path: `/watch/${this.playlistItems[0].id ?? this.playlistItems[0].videoId}`,
query: playlistInfo
}
)
showToast(this.$t('Playing Next Video'))
} else {
showToast(this.$t('The playlist has ended. Enable loop to continue playing'))
}
} else {
this.$router.push(
{
path: `/watch/${this.playlistItems[videoIndex + 1].id ?? this.playlistItems[videoIndex + 1].videoId}`,
query: playlistInfo
}
)
showToast(this.$t('Playing Next Video'))
const stopDueToLoopDisabled = this.videoIsLastPlaylistItem && !this.loopEnabled
if (stopDueToLoopDisabled) {
showToast(this.$t('The playlist has ended. Enable loop to continue playing'))
return
}
this.$router.push(
{
path: `/watch/${targetPlaylistItem.videoId}`,
query: Object.assign(playlistInfo, { playlistItemId: targetPlaylistItem.playlistItemId }),
}
)
showToast(this.$t('Playing Next Video'))
}
},
playPreviousVideo: function () {
showToast('Playing previous video')
showToast(this.$t('Playing Previous Video'))
const playlistInfo = {
playlistId: this.playlistId
playlistId: this.playlistId,
playlistType: this.playlistType,
}
const videoIndex = this.videoIndexInPlaylistItems
const targetVideoIndex = (this.videoIsFirstPlaylistItem || this.videoIsNotPlaylistItem) ? this.playlistItems.length - 1 : videoIndex - 1
if (this.shuffleEnabled) {
const videoIndex = this.randomizedPlaylistItems.findIndex((item) => {
return item === this.videoId
})
const targetPlaylistItem = this.randomizedPlaylistItems[targetVideoIndex]
if (videoIndex === 0) {
this.$router.push(
{
path: `/watch/${this.randomizedPlaylistItems[this.randomizedPlaylistItems.length - 1]}`,
query: playlistInfo
}
)
} else {
this.$router.push(
{
path: `/watch/${this.randomizedPlaylistItems[videoIndex - 1]}`,
query: playlistInfo
}
)
}
this.$router.push(
{
path: `/watch/${targetPlaylistItem.videoId}`,
query: Object.assign(playlistInfo, { playlistItemId: targetPlaylistItem.playlistItemId }),
}
)
} else {
const videoIndex = this.playlistItems.findIndex((item) => {
return (item.id ?? item.videoId) === this.videoId
})
const targetPlaylistItem = this.playlistItems[targetVideoIndex]
if (videoIndex === 0) {
this.$router.push(
{
path: `/watch/${this.playlistItems[this.randomizedPlaylistItems.length - 1].id ?? this.playlistItems[this.randomizedPlaylistItems.length - 1].videoId}`,
query: playlistInfo
}
)
} else {
this.$router.push(
{
path: `/watch/${this.playlistItems[videoIndex - 1].id ?? this.playlistItems[videoIndex - 1].videoId}`,
query: playlistInfo
}
)
}
this.$router.push(
{
path: `/watch/${targetPlaylistItem.videoId}`,
query: Object.assign(playlistInfo, { playlistItemId: targetPlaylistItem.playlistItemId }),
}
)
}
},
loadCachedPlaylistInformation: async function (cachedPlaylist) {
this.isLoading = true
this.getPlaylistInfoRun = true
this.setCachedPlaylist(null)
this.playlistTitle = cachedPlaylist.title
@ -412,22 +470,39 @@ export default defineComponent({
})
},
parseUserPlaylist: function (playlist, { allowPlayingVideoRemoval = true } = {}) {
this.playlistTitle = playlist.playlistName
this.channelName = ''
this.channelId = ''
if (this.playlistItems.length === 0 || allowPlayingVideoRemoval) {
this.playlistItems = playlist.videos
} else {
// `this.currentVideo` relies on `playlistItems`
const latestPlaylistContainsCurrentVideo = playlist.videos.some(v => v.playlistItemId === this.playlistItemId)
// Only update list of videos if latest video list still contains currently playing video
if (latestPlaylistContainsCurrentVideo) {
this.playlistItems = playlist.videos
}
}
this.isLoading = false
},
shufflePlaylistItems: function () {
// Prevents the array from affecting the original object
const remainingItems = [].concat(this.playlistItems)
const items = []
items.push(this.videoId)
items.push(this.currentVideo)
remainingItems.splice(this.currentVideoIndexZeroBased, 1)
this.playlistItems.forEach((item) => {
while (remainingItems.length > 0) {
const randomInt = Math.floor(Math.random() * remainingItems.length)
if ((remainingItems[randomInt].id ?? remainingItems[randomInt].videoId) !== this.videoId) {
items.push(remainingItems[randomInt].id ?? remainingItems[randomInt].videoId)
}
items.push(remainingItems[randomInt])
remainingItems.splice(randomInt, 1)
})
}
this.randomizedPlaylistItems = items
},
@ -437,7 +512,7 @@ export default defineComponent({
const currentVideoItem = (this.$refs.currentVideoItem || [])[0]
if (container != null && currentVideoItem != null) {
// Watch view can be ready sooner than this component
container.scrollTop = currentVideoItem.offsetTop - container.offsetTop
container.scrollTop = currentVideoItem.$el.offsetTop - container.offsetTop
}
},

View File

@ -12,32 +12,39 @@
>
<router-link
class="playlistTitleLink"
:to="`/playlist/${playlistId}`"
:to="playlistPageLinkTo"
>
{{ playlistTitle }}
</router-link>
</h3>
<router-link
v-if="channelId"
class="channelName"
:to="`/channel/${channelId}`"
<template
v-if="channelName !== ''"
>
{{ channelName }}
</router-link>
<span
v-else
class="channelName"
>
{{ channelName }}
</span>
<router-link
v-if="channelId"
class="channelName"
:to="`/channel/${channelId}`"
>
{{ channelName }} -
</router-link>
<span
v-else
class="channelName"
>
{{ channelName }} -
</span>
</template>
<span
class="playlistIndex"
>
- {{ currentVideoIndex }} / {{ playlistVideoCount }}
<label for="playlistProgressBar">
{{ currentVideoIndexOneBased }} / {{ playlistVideoCount }}
</label>
<progress
v-if="!shuffleEnabled && !reversePlaylist"
id="playlistProgressBar"
class="playlistProgressBar"
:value="currentVideoIndex"
:value="currentVideoIndexOneBased"
:max="playlistVideoCount"
/>
</span>
@ -112,38 +119,25 @@
ref="playlistItems"
class="playlistItems"
>
<div
<ft-list-video-numbered
v-for="(item, index) in playlistItems"
:key="index"
:ref="currentVideoIndex === (index + 1) ? 'currentVideoItem' : null"
:key="item.playlistItemId || item.videoId"
:ref="currentVideoIndexZeroBased === index ? 'currentVideoItem' : null"
class="playlistItem"
>
<div class="videoIndexContainer">
<font-awesome-icon
v-if="currentVideoIndex === (index + 1)"
class="videoIndexIcon"
:icon="['fas', 'play']"
/>
<p
v-else
class="videoIndex"
>
{{ index + 1 }}
</p>
</div>
<ft-list-video-lazy
:data="item"
:playlist-id="playlistId"
:playlist-index="reversePlaylist ? playlistItems.length - index - 1 : index"
:playlist-reverse="reversePlaylist"
:playlist-shuffle="shuffleEnabled"
:playlist-loop="loopEnabled"
appearance="watchPlaylistItem"
force-list-type="list"
:initial-visible-state="index < ((currentVideoIndex - 1) + 4) && index > ((currentVideoIndex - 1) - 4)"
@pause-player="$emit('pause-player')"
/>
</div>
:data="item"
:playlist-id="playlistId"
:playlist-type="playlistType"
:playlist-index="reversePlaylist ? playlistItems.length - index - 1 : index"
:playlist-item-id="item.playlistItemId"
:playlist-reverse="reversePlaylist"
:playlist-shuffle="shuffleEnabled"
:playlist-loop="loopEnabled"
:video-index="index"
:is-current-video="currentVideoIndexZeroBased === index"
appearance="watchPlaylistItem"
:initial-visible-state="index < (currentVideoIndexZeroBased + 4) && index > (currentVideoIndexZeroBased - 4)"
@pause-player="$emit('pause-player')"
/>
</div>
</div>
</ft-card>

View File

@ -290,7 +290,9 @@ export async function getLocalChannelVideos(id) {
// if the channel doesn't have a videos tab, YouTube returns the home tab instead
// so we need to check that we got the right tab
if (videosTab.current_tab?.endpoint.metadata.url?.endsWith('/videos')) {
return parseLocalChannelVideos(videosTab.videos, videosTab.header.author)
const { id: channelId = id, name } = parseLocalChannelHeader(videosTab)
return parseLocalChannelVideos(videosTab.videos, channelId, name)
} else {
return []
}
@ -320,7 +322,9 @@ export async function getLocalChannelLiveStreams(id) {
// if the channel doesn't have a live tab, YouTube returns the home tab instead
// so we need to check that we got the right tab
if (liveStreamsTab.current_tab?.endpoint.metadata.url?.endsWith('/streams')) {
return parseLocalChannelVideos(liveStreamsTab.videos, liveStreamsTab.header.author)
const { id: channelId = id, name } = parseLocalChannelHeader(liveStreamsTab)
return parseLocalChannelVideos(liveStreamsTab.videos, channelId, name)
} else {
return []
}
@ -365,16 +369,158 @@ export async function getLocalChannelCommunity(id) {
}
/**
* @param {import('youtubei.js').YTNodes.Video[]} videos
* @param {Misc.Author} author
* @param {YT.Channel} channel
*/
export function parseLocalChannelVideos(videos, author) {
export function parseLocalChannelHeader(channel) {
/** @type {string=} */
let id
/** @type {string} */
let name
/** @type {string=} */
let thumbnailUrl
/** @type {string=} */
let bannerUrl
/** @type {string=} */
let subscriberText
/** @type {string[]} */
const tags = []
switch (channel.header.type) {
case 'C4TabbedHeader': {
// example: Linus Tech Tips
// https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
/**
* @type {import('youtubei.js').YTNodes.C4TabbedHeader}
*/
const header = channel.header
id = header.author.id
name = header.author.name
thumbnailUrl = header.author.best_thumbnail.url
bannerUrl = header.banner?.[0]?.url
subscriberText = header.subscribers?.text
break
}
case 'CarouselHeader': {
// examples: Music and YouTube Gaming
// https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
/**
* @type {import('youtubei.js').YTNodes.CarouselHeader}
*/
const header = channel.header
/**
* @type {import('youtubei.js').YTNodes.TopicChannelDetails}
*/
const topicChannelDetails = header.contents.find(node => node.type === 'TopicChannelDetails')
name = topicChannelDetails.title.text
subscriberText = topicChannelDetails.subtitle.text
thumbnailUrl = topicChannelDetails.avatar[0].url
if (channel.metadata.external_id) {
id = channel.metadata.external_id
} else {
id = topicChannelDetails.subscribe_button.channel_id
}
break
}
case 'InteractiveTabbedHeader': {
// example: Minecraft - Topic
// https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg
/**
* @type {import('youtubei.js').YTNodes.InteractiveTabbedHeader}
*/
const header = channel.header
name = header.title.text
thumbnailUrl = header.box_art.at(-1).url
bannerUrl = header.banner[0]?.url
const badges = header.badges.map(badge => badge.label).filter(tag => tag)
tags.push(...badges)
id = channel.current_tab?.endpoint.payload.browseId
break
}
case 'PageHeader': {
// example: YouTube Gaming
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
// User channels (an A/B test at the time of writing)
/**
* @type {import('youtubei.js').YTNodes.PageHeader}
*/
const header = channel.header
name = header.content.title.text.text
if (header.content.image) {
if (header.content.image.type === 'ContentPreviewImageView') {
/** @type {import('youtubei.js').YTNodes.ContentPreviewImageView} */
const image = header.content.image
thumbnailUrl = image.image[0].url
} else {
/** @type {import('youtubei.js').YTNodes.DecoratedAvatarView} */
const image = header.content.image
thumbnailUrl = image.avatar?.image[0].url
}
}
if (!thumbnailUrl && channel.metadata.thumbnail) {
thumbnailUrl = channel.metadata.thumbnail[0].url
}
if (header.content.banner) {
bannerUrl = header.content.banner.image[0]?.url
}
if (header.content.actions) {
const modal = header.content.actions.actions_rows[0].actions[0].on_tap.modal
if (modal && modal.type === 'ModalWithTitleAndButton') {
/** @type {import('youtubei.js').YTNodes.ModalWithTitleAndButton} */
const typedModal = modal
id = typedModal.button.endpoint.next_endpoint?.payload.browseId
}
} else if (channel.metadata.external_id) {
id = channel.metadata.external_id
}
if (header.content.metadata) {
subscriberText = header.content.metadata.metadata_rows[0].metadata_parts[1].text.text
}
break
}
}
return {
id,
name,
thumbnailUrl,
bannerUrl,
subscriberText,
tags
}
}
/**
* @param {import('youtubei.js').YTNodes.Video[]} videos
* @param {string} channelId
* @param {string} channelName
*/
export function parseLocalChannelVideos(videos, channelId, channelName) {
const parsedVideos = videos.map(parseLocalListVideo)
// fix empty author info
parsedVideos.forEach(video => {
video.author = author.name
video.authorId = author.id
video.author = channelName
video.authorId = channelId
})
return parsedVideos
@ -382,81 +528,23 @@ export function parseLocalChannelVideos(videos, author) {
/**
* @param {import('youtubei.js').YTNodes.ReelItem[]} shorts
* @param {Misc.Author} author
* @param {string} channelId
* @param {string} channelName
*/
export function parseLocalChannelShorts(shorts, author) {
export function parseLocalChannelShorts(shorts, channelId, channelName) {
return shorts.map(short => {
// unfortunately the only place with the duration is the accesibility string
const duration = parseShortDuration(short.accessibility_label, short.id)
return {
type: 'video',
videoId: short.id,
title: short.title.text,
author: author.name,
authorId: author.id,
author: channelName,
authorId: channelId,
viewCount: parseLocalSubscriberCount(short.views.text),
lengthSeconds: isNaN(duration) ? '' : duration
lengthSeconds: ''
}
})
}
/**
* Shorts can only be up to 60 seconds long, so we only need to handle seconds and minutes
* Of course this is YouTube, so are edge cases that don't match the docs, like example 3 taken from LTT
*
* https://support.google.com/youtube/answer/10059070?hl=en
*
* Example input strings:
* - These mice keep getting WEIRDER... - 59 seconds - play video
* - How Low Can Our Resolution Go? - 1 minute - play video
* - I just found out about Elon. #SHORTS - 1 minute, 1 second - play video
* @param {string} accessibilityLabel
* @param {string} videoId only used for error logging
*/
function parseShortDuration(accessibilityLabel, videoId) {
// we want to count from the end of the array,
// as it's possible that the title could contain a `-` too
const timeString = accessibilityLabel.split('-').at(-2)
if (typeof timeString === 'undefined') {
console.error(`Failed to parse local API short duration from accessibility label. video ID: ${videoId}, text: "${accessibilityLabel}"`)
return NaN
}
let duration = 0
const matches = timeString.matchAll(/(\d+) (second|minute)s?/g)
// matchAll returns an iterator, which doesn't have a length property
// so we need to check if it's empty this way instead
let validDuration = false
for (const match of matches) {
let number = parseInt(match[1])
if (isNaN(number) || match[2].length === 0) {
validDuration = false
break
}
validDuration = true
if (match[2] === 'minute') {
number *= 60
}
duration += number
}
if (!validDuration) {
console.error(`Failed to parse local API short duration from accessibility label. video ID: ${videoId}, text: "${accessibilityLabel}"`)
return NaN
}
return duration
}
/**
* @typedef {import('youtubei.js').YTNodes.Playlist} Playlist
* @typedef {import('youtubei.js').YTNodes.GridPlaylist} GridPlaylist
@ -464,40 +552,43 @@ function parseShortDuration(accessibilityLabel, videoId) {
/**
* @param {Playlist|GridPlaylist} playlist
* @param {Misc.Author} author
* @param {string} channelId
* @param {string} chanelName
*/
export function parseLocalListPlaylist(playlist, author = undefined) {
let channelName
let channelId = null
/** @type {import('youtubei.js').YTNodes.PlaylistVideoThumbnail} */
const thumbnailRenderer = playlist.thumbnail_renderer
export function parseLocalListPlaylist(playlist, channelId = undefined, channelName = undefined) {
let internalChannelName
let internalChannelId = null
if (playlist.author && playlist.author.id !== 'N/A') {
if (playlist.author instanceof Misc.Text) {
channelName = playlist.author.text
internalChannelName = playlist.author.text
if (author) {
channelId = author.id
if (channelId) {
internalChannelId = channelId
}
} else {
channelName = playlist.author.name
channelId = playlist.author.id
internalChannelName = playlist.author.name
internalChannelId = playlist.author.id
}
} else if (author) {
channelName = author.name
channelId = author.id
} else if (channelId || channelName) {
internalChannelName = channelName
internalChannelId = channelId
} else if (playlist.author?.name) {
// auto-generated album playlists don't have an author
// so in search results, the author text is "Playlist" and doesn't have a link or channel ID
channelName = playlist.author.name
internalChannelName = playlist.author.name
}
/** @type {import('youtubei.js').YTNodes.PlaylistVideoThumbnail} */
const thumbnailRenderer = playlist.thumbnail_renderer
return {
type: 'playlist',
dataSource: 'local',
title: playlist.title.text,
thumbnail: thumbnailRenderer ? thumbnailRenderer.thumbnail[0].url : playlist.thumbnails[0].url,
channelName,
channelId,
channelName: internalChannelName,
channelId: internalChannelId,
playlistId: playlist.id,
videoCount: extractNumberFromString(playlist.video_count.text)
}
@ -516,7 +607,7 @@ function handleSearchResponse(response) {
const results = response.results
.filter((item) => {
return item.type === 'Video' || item.type === 'Channel' || item.type === 'Playlist' || item.type === 'HashtagTile'
return item.type === 'Video' || item.type === 'Channel' || item.type === 'Playlist' || item.type === 'HashtagTile' || item.type === 'Movie'
})
.map((item) => parseListItem(item))
@ -535,15 +626,12 @@ export function parseLocalPlaylistVideo(video) {
/** @type {import('youtubei.js').YTNodes.ReelItem} */
const short = video
// unfortunately the only place with the duration is the accesibility string
const duration = parseShortDuration(video.accessibility_label, short.id)
return {
type: 'video',
videoId: short.id,
title: short.title.text,
viewCount: parseLocalSubscriberCount(short.views.text),
lengthSeconds: isNaN(duration) ? '' : duration
lengthSeconds: ''
}
} else {
/** @type {import('youtubei.js').YTNodes.PlaylistVideo} */
@ -599,22 +687,41 @@ export function parseLocalPlaylistVideo(video) {
}
/**
* @param {import('youtubei.js').YTNodes.Video} video
* @param {import('youtubei.js').YTNodes.Video | import('youtubei.js').YTNodes.Movie} item
*/
export function parseLocalListVideo(video) {
return {
type: 'video',
videoId: video.id,
title: video.title.text,
author: video.author.name,
authorId: video.author.id,
description: video.description,
viewCount: extractNumberFromString(video.view_count.text),
publishedText: video.published.isEmpty() ? null : video.published.text,
lengthSeconds: isNaN(video.duration.seconds) ? '' : video.duration.seconds,
liveNow: video.is_live,
isUpcoming: video.is_upcoming || video.is_premiere,
premiereDate: video.upcoming
export function parseLocalListVideo(item) {
if (item.type === 'Movie') {
/** @type {import('youtubei.js').YTNodes.Movie} */
const movie = item
return {
type: 'video',
videoId: movie.id,
title: movie.title.text,
author: movie.author.name,
authorId: movie.author.id !== 'N/A' ? movie.author.id : null,
description: movie.description_snippet?.text,
lengthSeconds: isNaN(movie.duration.seconds) ? '' : movie.duration.seconds,
liveNow: false,
isUpcoming: false,
}
} else {
/** @type {import('youtubei.js').YTNodes.Video} */
const video = item
return {
type: 'video',
videoId: video.id,
title: video.title.text,
author: video.author.name,
authorId: video.author.id,
description: video.description,
viewCount: video.view_count == null ? null : extractNumberFromString(video.view_count.text),
publishedText: (video.published == null || video.published.isEmpty()) ? null : video.published.text,
lengthSeconds: isNaN(video.duration.seconds) ? '' : video.duration.seconds,
liveNow: video.is_live,
isUpcoming: video.is_upcoming || video.is_premiere,
premiereDate: video.upcoming
}
}
}
@ -623,6 +730,7 @@ export function parseLocalListVideo(video) {
*/
function parseListItem(item) {
switch (item.type) {
case 'Movie':
case 'Video':
return parseLocalListVideo(item)
case 'Channel': {
@ -689,8 +797,8 @@ export function parseLocalWatchNextVideo(video) {
title: video.title.text,
author: video.author.name,
authorId: video.author.id,
viewCount: extractNumberFromString(video.view_count.text),
publishedText: video.published.isEmpty() ? null : video.published.text,
viewCount: video.view_count == null ? null : extractNumberFromString(video.view_count.text),
publishedText: (video.published == null || video.published.isEmpty()) ? null : video.published.text,
lengthSeconds: isNaN(video.duration.seconds) ? '' : video.duration.seconds,
liveNow: video.is_live,
isUpcoming: video.is_premiere
@ -784,7 +892,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16, options = { looseChanne
case 'WEB_PAGE_TYPE_CHANNEL': {
const trimmedText = text.trim()
// In comments, mention can be `@Channel Name` (not handle, but name)
if (CHANNEL_HANDLE_REGEX.test(trimmedText) || (options.looseChannelNameDetection && trimmedText.startsWith('@'))) {
if (CHANNEL_HANDLE_REGEX.test(trimmedText) || options.looseChannelNameDetection) {
// Note that in regex `\s` must be used since the text contain non-default space (the half-width space char when we press spacebar)
const spacesBefore = (spacesBeforeRegex.exec(text) || [''])[0]
const spacesAfter = (spacesAfterRegex.exec(text) || [''])[0]

View File

@ -56,3 +56,31 @@ export async function deArrowData(videoId) {
throw error
}
}
export async function deArrowThumbnail(videoId, timestamp) {
let requestUrl = `${store.getters.getDeArrowThumbnailGeneratorUrl}/api/v1/getThumbnail?videoID=` + videoId
if (timestamp != null) {
requestUrl += `&time=${timestamp}`
}
try {
const response = await fetch(requestUrl)
// 404 means that there are no thumbnails found for the video
if (response.status === 404) {
return undefined
}
if (response.ok) {
return response.url
}
// this usually means that a thumbnail was not generated on the server yet so we'll log the error but otherwise ignore it.
const json = await response.json()
console.error(json)
return undefined
} catch (error) {
console.error('failed to fetch DeArrow data', requestUrl, error)
throw error
}
}

View File

@ -572,6 +572,7 @@ export function getVideoParamsFromUrl(url) {
function () {
if (urlObject.host === 'youtu.be' && /^\/[\w-]+$/.test(urlObject.pathname)) {
extractParams(urlObject.pathname.slice(1))
paramsObject.playlistId = urlObject.searchParams.get('list')
return paramsObject
}
},

View File

@ -16,6 +16,7 @@ import {
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowUp,
faBars,
faBookmark,
faCheck,
@ -26,6 +27,7 @@ import {
faCommentDots,
faCopy,
faDownload,
faEdit,
faEllipsisH,
faEllipsisV,
faEnvelope,
@ -44,15 +46,19 @@ import {
faHistory,
faInfoCircle,
faLanguage,
faLink,
faLinkSlash,
faList,
faNewspaper,
faPause,
faPlay,
faPlus,
faQuestionCircle,
faRandom,
faRetweet,
faRss,
faSatelliteDish,
faSave,
faSearch,
faShareAlt,
faSlidersH,
@ -66,6 +72,7 @@ import {
faThumbtack,
faTimes,
faTimesCircle,
faTrash,
faUsers,
} from '@fortawesome/free-solid-svg-icons'
import {
@ -89,6 +96,7 @@ library.add(
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowUp,
faBars,
faBookmark,
faCheck,
@ -99,6 +107,7 @@ library.add(
faCommentDots,
faCopy,
faDownload,
faEdit,
faEllipsisH,
faEllipsisV,
faEnvelope,
@ -117,15 +126,19 @@ library.add(
faHistory,
faInfoCircle,
faLanguage,
faLink,
faLinkSlash,
faList,
faNewspaper,
faPause,
faPlay,
faPlus,
faQuestionCircle,
faRandom,
faRetweet,
faRss,
faSatelliteDish,
faSave,
faSearch,
faShareAlt,
faSlidersH,
@ -139,6 +152,7 @@ library.add(
faThumbtack,
faTimes,
faTimesCircle,
faTrash,
faUsers,
// brand icons

View File

@ -78,7 +78,7 @@ $watched-transition-duration: 0.5s;
.videoWatched,
.videoDuration,
.externalPlayerIcon,
.favoritesIcon,
.playlistIcons,
.watchedProgressBar,
.videoCountContainer,
.background,
@ -148,13 +148,23 @@ $watched-transition-duration: 0.5s;
margin-inline-start: 4px;
}
.favoritesIcon {
font-size: 17px;
.playlistIcons {
justify-self: end;
margin-inline-end: 3px;
margin-block-start: 3px;
display: grid;
grid-auto-flow: column;
justify-content: flex-end;
block-size: fit-content;
}
.quickBookmarkVideoIcon,
.addToPlaylistIcon,
.trashIcon,
.upArrowIcon,
.downArrowIcon {
font-size: 17px;
}
.watchedProgressBar {
align-self: flex-end;
@ -321,9 +331,15 @@ $watched-transition-duration: 0.5s;
.videoThumbnail,
.channelThumbnail {
margin-block-end: 12px;
.thumbnailImage {
// Ensure placeholder image displayed at same aspect ratio as most other images
aspect-ratio: 16/9;
}
}
.thumbnailImage, .channelThumbnail {
.thumbnailImage,
.channelThumbnail {
inline-size: 100%;
}
@ -337,31 +353,31 @@ $watched-transition-duration: 0.5s;
}
}
.favoritesIcon,
.playlistIcons,
.externalPlayerIcon {
opacity: $thumbnail-overlay-opacity;
}
@media (hover: hover) {
.favoritesIcon.favorited,
&:hover .favoritesIcon,
&:hover .quickBookmarkVideoIcon:not(.alwaysVisible),
.quickBookmarkVideoIcon.bookmarked:not(.alwaysVisible),
&:hover .addToPlaylistIcon:not(.alwaysVisible),
&:hover .externalPlayerIcon,
&:focus-within .favoritesIcon,
&:focus-within .externalPlayerIcon {
visibility: visible;
&:has(:focus-visible) .addToPlaylistIcon:not(.alwaysVisible),
&:has(:focus-visible) .quickBookmarkVideoIcon:not(.alwaysVisible),
&:has(:focus-visible) .externalPlayerIcon {
opacity: $thumbnail-overlay-opacity;
}
&:hover .optionsButton,
&:focus-within .optionsButton {
visibility: visible;
&:has(:focus-visible) .optionsButton {
opacity: 1;
}
.favoritesIcon,
.quickBookmarkVideoIcon:not(.alwaysVisible),
.addToPlaylistIcon:not(.alwaysVisible),
.externalPlayerIcon,
.optionsButton {
visibility: none;
opacity: 0;
transition: visibility 0s, opacity 0.2s linear;
}

View File

@ -73,14 +73,14 @@ const actions = {
}
},
async updateLastViewedPlaylist({ commit }, { videoId, lastViewedPlaylistId }) {
async updateLastViewedPlaylist({ commit }, { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId }) {
try {
await DBHistoryHandlers.updateLastViewedPlaylist(videoId, lastViewedPlaylistId)
commit('updateRecordLastViewedPlaylistIdInHistoryCache', { videoId, lastViewedPlaylistId })
await DBHistoryHandlers.updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId)
commit('updateRecordLastViewedPlaylistIdInHistoryCache', { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId })
} catch (errMessage) {
console.error(errMessage)
}
}
},
}
const mutations = {
@ -118,13 +118,15 @@ const mutations = {
vueSet(state.historyCacheById, videoId, targetRecord)
},
updateRecordLastViewedPlaylistIdInHistoryCache(state, { videoId, lastViewedPlaylistId }) {
updateRecordLastViewedPlaylistIdInHistoryCache(state, { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId }) {
const i = state.historyCacheSorted.findIndex((currentRecord) => {
return currentRecord.videoId === videoId
})
const targetRecord = Object.assign({}, state.historyCacheSorted[i])
targetRecord.lastViewedPlaylistId = lastViewedPlaylistId
targetRecord.lastViewedPlaylistType = lastViewedPlaylistType
targetRecord.lastViewedPlaylistItemId = lastViewedPlaylistItemId
state.historyCacheSorted.splice(i, 1, targetRecord)
vueSet(state.historyCacheById, videoId, targetRecord)
},

View File

@ -1,5 +1,4 @@
import fs from 'fs/promises'
import { pathExists } from '../../helpers/filesystem'
import { createWebURL, fetchWithTimeout } from '../../helpers/utils'
const state = {
@ -47,7 +46,7 @@ const actions = {
/* eslint-disable-next-line n/no-path-concat */
const fileLocation = process.env.NODE_ENV === 'development' ? './static/' : `${__dirname}/static/`
const filePath = `${fileLocation}${fileName}`
if (!process.env.IS_ELECTRON || await pathExists(filePath)) {
if (!process.env.IS_ELECTRON) {
console.warn('reading static file for invidious instances')
const fileData = process.env.IS_ELECTRON ? await fs.readFile(filePath, 'utf8') : await (await fetch(createWebURL(filePath))).text()
instances = JSON.parse(fileData).filter(e => {

View File

@ -1,32 +1,84 @@
import { DBPlaylistHandlers } from '../../../datastores/handlers/index'
function generateRandomPlaylistId() {
return `ft-playlist--${generateRandomUniqueId()}`
}
function generateRandomPlaylistName() {
return `Playlist ${new Date().toISOString()}-${Math.floor(Math.random() * 10000)}`
}
function generateRandomUniqueId() {
// To avoid importing `crypto` from NodeJS
return crypto.randomUUID ? crypto.randomUUID() : `id-${Date.now()}-${Math.floor(Math.random() * 10000)}`
}
const state = {
playlists: [
// Playlist loading takes time on app load (new windows)
// This is necessary to let components to know when to start data loading
// which depends on playlist data being ready
playlistsReady: false,
playlists: [],
defaultPlaylists: [
{
playlistName: 'Favorites',
protected: true,
videos: []
protected: false,
description: 'Your favorite videos',
videos: [],
_id: 'favorites',
},
{
playlistName: 'WatchLater',
protected: true,
removeOnWatched: true,
videos: []
}
]
playlistName: 'Watch Later',
protected: false,
description: 'Videos to watch later',
videos: [],
_id: 'watchLater',
},
],
}
const getters = {
getPlaylistsReady: () => state.playlistsReady,
getAllPlaylists: () => state.playlists,
getFavorites: () => state.playlists[0],
getPlaylist: (playlistId) => state.playlists.find(playlist => playlist._id === playlistId),
getWatchLater: () => state.playlists[1]
getPlaylist: (state) => (playlistId) => {
return state.playlists.find(playlist => playlist._id === playlistId)
},
}
const actions = {
async addPlaylist({ commit }, payload) {
// In case internal id is forgotten, generate one (instead of relying on caller and have a chance to cause data corruption)
if (payload._id == null) {
// {Time now in unix time}-{0-9999}
payload._id = generateRandomPlaylistId()
}
// Ensure playlist name trimmed
if (typeof payload.playlistName === 'string') {
payload.playlistName = payload.playlistName.trim()
}
// Ensure playlist description trimmed
if (typeof payload.description === 'string') {
payload.description = payload.description.trim()
}
payload.createdAt = Date.now()
payload.lastUpdatedAt = Date.now()
// Ensure all videos has required attributes
const currentTime = new Date().getTime()
if (Array.isArray(payload.videos)) {
payload.videos.forEach(videoData => {
if (videoData.timeAdded == null) {
videoData.timeAdded = currentTime
}
if (videoData.playlistItemId == null) {
videoData.playlistItemId = generateRandomUniqueId()
}
})
}
try {
await DBPlaylistHandlers.create(payload)
await DBPlaylistHandlers.create([payload])
commit('addPlaylist', payload)
} catch (errMessage) {
console.error(errMessage)
@ -42,10 +94,53 @@ const actions = {
}
},
async updatePlaylist({ commit }, playlist) {
// Ensure playlist name trimmed
if (typeof playlist.playlistName === 'string') {
playlist.playlistName = playlist.playlistName.trim()
}
// Ensure playlist description trimmed
if (typeof playlist.description === 'string') {
playlist.description = playlist.description.trim()
}
// Caller no need to assign last updated time
playlist.lastUpdatedAt = Date.now()
try {
await DBPlaylistHandlers.upsert(playlist)
commit('upsertPlaylistToList', playlist)
} catch (errMessage) {
console.error(errMessage)
}
},
async updatePlaylistLastPlayedAt({ commit }, playlist) {
// This action does NOT update `lastUpdatedAt` on purpose
// Only `lastPlayedAt` should be updated
playlist.lastPlayedAt = Date.now()
try {
await DBPlaylistHandlers.upsert(playlist)
commit('upsertPlaylistToList', playlist)
} catch (errMessage) {
console.error(errMessage)
}
},
async addVideo({ commit }, payload) {
try {
const { playlistName, videoData } = payload
await DBPlaylistHandlers.upsertVideoByPlaylistName(playlistName, videoData)
const { _id, videoData } = payload
if (videoData.timeAdded == null) {
videoData.timeAdded = new Date().getTime()
}
if (videoData.playlistItemId == null) {
videoData.playlistItemId = generateRandomUniqueId()
}
// For backward compatibility
if (videoData.type == null) {
videoData.type = 'video'
}
await DBPlaylistHandlers.upsertVideoByPlaylistId(_id, videoData)
commit('addVideo', payload)
} catch (errMessage) {
console.error(errMessage)
@ -53,10 +148,38 @@ const actions = {
},
async addVideos({ commit }, payload) {
// Assumes videos are added NOT from export
// Since this action will ensure uniqueness of `playlistItemId` of added video entries
try {
const { playlistId, videoIds } = payload
await DBPlaylistHandlers.upsertVideoIdsByPlaylistId(playlistId, videoIds)
commit('addVideos', payload)
const { _id, videos } = payload
const currentTime = new Date().getTime()
const newVideoObjects = videos.map((video) => {
// Create a new object to prevent changing existing values outside
const videoData = Object.assign({}, video)
if (videoData.timeAdded == null) {
videoData.timeAdded = currentTime
}
videoData.playlistItemId = generateRandomUniqueId()
// For backward compatibility
if (videoData.type == null) {
videoData.type = 'video'
}
// Undesired attributes, even with `null` values
[
'description',
'viewCount',
].forEach(attrName => {
if (typeof videoData[attrName] !== 'undefined') {
delete videoData[attrName]
}
})
return videoData
})
await DBPlaylistHandlers.upsertVideosByPlaylistId(_id, newVideoObjects)
commit('addVideos', { _id, videos: newVideoObjects })
} catch (errMessage) {
console.error(errMessage)
}
@ -64,13 +187,130 @@ const actions = {
async grabAllPlaylists({ commit, dispatch, state }) {
try {
const payload = await DBPlaylistHandlers.find()
const payload = (await DBPlaylistHandlers.find()).filter((e) => e != null)
if (payload.length === 0) {
commit('setAllPlaylists', state.playlists)
dispatch('addPlaylists', payload)
// Not using `addPlaylists` to ensure required attributes with dynamic values added
state.defaultPlaylists.forEach(playlist => {
dispatch('addPlaylist', playlist)
})
} else {
const dateNow = Date.now()
const currentTime = new Date().getTime()
payload.forEach((playlist) => {
let anythingUpdated = false
// Assign generated playlist ID in case DB data corrupted
if (playlist._id == null) {
// {Time now in unix time}-{0-9999}
playlist._id = generateRandomPlaylistId()
anythingUpdated = true
}
// Ensure all videos has `playlistName` property
if (playlist.playlistName == null) {
// Time now in unix time, in ms
playlist.playlistName = generateRandomPlaylistName()
anythingUpdated = true
}
// Assign current time as created time in case DB data corrupted
if (playlist.createdAt == null) {
// Time now in unix time, in ms
playlist.createdAt = dateNow
anythingUpdated = true
}
// Assign current time as last updated time in case DB data corrupted
if (playlist.lastUpdatedAt == null) {
// Time now in unix time, in ms
playlist.lastUpdatedAt = dateNow
anythingUpdated = true
}
playlist.videos.forEach((v) => {
// Ensure all videos has `timeAdded` property
if (v.timeAdded == null) {
v.timeAdded = currentTime
anythingUpdated = true
}
// Ensure all videos has `playlistItemId` property
if (v.playlistItemId == null) {
v.playlistItemId = generateRandomUniqueId()
anythingUpdated = true
}
// For backward compatibility
if (v.type == null) {
v.type = 'video'
anythingUpdated = true
}
// Undesired attributes, even with `null` values
[
'description',
'viewCount',
].forEach(attrName => {
if (typeof v[attrName] !== 'undefined') {
delete v[attrName]
anythingUpdated = true
}
})
})
// Save updated playlist object
if (anythingUpdated) {
DBPlaylistHandlers.upsert(playlist)
}
})
const favoritesPlaylist = payload.find((playlist) => {
return playlist.playlistName === 'Favorites' || playlist._id === 'favorites'
})
const watchLaterPlaylist = payload.find((playlist) => {
return playlist.playlistName === 'Watch Later' || playlist._id === 'watchLater'
})
if (favoritesPlaylist != null) {
const defaultFavoritesPlaylist = state.defaultPlaylists.find((e) => e._id === 'favorites')
// Update existing matching playlist only if it exists
if (favoritesPlaylist._id !== defaultFavoritesPlaylist._id || favoritesPlaylist.protected !== defaultFavoritesPlaylist.protected) {
const oldId = favoritesPlaylist._id
favoritesPlaylist._id = defaultFavoritesPlaylist._id
favoritesPlaylist.protected = defaultFavoritesPlaylist.protected
if (oldId === defaultFavoritesPlaylist._id) {
// Update playlist if ID already the same
DBPlaylistHandlers.upsert(favoritesPlaylist)
} else {
dispatch('removePlaylist', oldId)
// DO NOT use dispatch('addPlaylist', ...)
// Which causes duplicate displayed playlist in window (But DB is fine)
// Due to the object is already in `payload`
DBPlaylistHandlers.create(favoritesPlaylist)
}
}
}
if (watchLaterPlaylist != null) {
const defaultWatchLaterPlaylist = state.defaultPlaylists.find((e) => e._id === 'watchLater')
// Update existing matching playlist only if it exists
if (watchLaterPlaylist._id !== defaultWatchLaterPlaylist._id || watchLaterPlaylist.protected !== defaultWatchLaterPlaylist.protected) {
const oldId = watchLaterPlaylist._id
watchLaterPlaylist._id = defaultWatchLaterPlaylist._id
watchLaterPlaylist.protected = defaultWatchLaterPlaylist.protected
if (oldId === defaultWatchLaterPlaylist._id) {
// Update playlist if ID already the same
DBPlaylistHandlers.upsert(watchLaterPlaylist)
} else {
dispatch('removePlaylist', oldId)
// DO NOT use dispatch('addPlaylist', ...)
// Which causes duplicate displayed playlist in window (But DB is fine)
// Due to the object is already in `payload`
DBPlaylistHandlers.create(watchLaterPlaylist)
}
}
}
commit('setAllPlaylists', payload)
}
commit('setPlaylistsReady', true)
} catch (errMessage) {
console.error(errMessage)
}
@ -85,10 +325,10 @@ const actions = {
}
},
async removeAllVideos({ commit }, playlistName) {
async removeAllVideos({ commit }, _id) {
try {
await DBPlaylistHandlers.deleteAllVideosByPlaylistName(playlistName)
commit('removeAllVideos', playlistName)
await DBPlaylistHandlers.deleteAllVideosByPlaylistId(_id)
commit('removeAllVideos', _id)
} catch (errMessage) {
console.error(errMessage)
}
@ -114,8 +354,8 @@ const actions = {
async removeVideo({ commit }, payload) {
try {
const { playlistName, videoId } = payload
await DBPlaylistHandlers.deleteVideoIdByPlaylistName(playlistName, videoId)
const { _id, videoId, playlistItemId } = payload
await DBPlaylistHandlers.deleteVideoIdByPlaylistId({ _id, videoId, playlistItemId })
commit('removeVideo', payload)
} catch (errMessage) {
console.error(errMessage)
@ -124,13 +364,13 @@ const actions = {
async removeVideos({ commit }, payload) {
try {
const { playlistName, videoIds } = payload
await DBPlaylistHandlers.deleteVideoIdsByPlaylistName(playlistName, videoIds)
const { _id, videoIds } = payload
await DBPlaylistHandlers.deleteVideoIdsByPlaylistId(_id, videoIds)
commit('removeVideos', payload)
} catch (errMessage) {
console.error(errMessage)
}
}
},
}
const mutations = {
@ -142,42 +382,59 @@ const mutations = {
state.playlists = state.playlists.concat(payload)
},
upsertPlaylistToList(state, updatedPlaylist) {
const i = state.playlists.findIndex((p) => {
return p._id === updatedPlaylist._id
})
if (i === -1) {
state.playlists.push(updatedPlaylist)
} else {
const foundPlaylist = state.playlists[i]
state.playlists.splice(i, 1, Object.assign(foundPlaylist, updatedPlaylist))
}
},
addVideo(state, payload) {
const playlist = state.playlists.find(playlist => playlist.playlistName === payload.playlistName)
const playlist = state.playlists.find(playlist => playlist._id === payload._id)
if (playlist) {
playlist.videos.push(payload.videoData)
}
},
addVideos(state, payload) {
const playlist = state.playlists.find(playlist => playlist._id === payload.playlistId)
const playlist = state.playlists.find(playlist => playlist._id === payload._id)
if (playlist) {
playlist.videos = playlist.videos.concat(payload.playlistIds)
playlist.videos = [].concat(playlist.videos, payload.videos)
}
},
removeAllPlaylists(state) {
state.playlists = state.playlists.filter(playlist => playlist.protected !== true)
state.playlists = []
},
removeAllVideos(state, playlistName) {
const playlist = state.playlists.find(playlist => playlist.playlistName === playlistName)
removeAllVideos(state, playlistId) {
const playlist = state.playlists.find(playlist => playlist._id === playlistId)
if (playlist) {
playlist.videos = []
}
},
removeVideo(state, payload) {
const playlist = state.playlists.findIndex(playlist => playlist.playlistName === payload.playlistName)
if (playlist !== -1) {
state.playlists[playlist].videos = state.playlists[playlist].videos.filter(video => video.videoId !== payload.videoId)
removeVideo(state, { _id, videoId, playlistItemId }) {
const playlist = state.playlists.find(playlist => playlist._id === _id)
if (playlist) {
if (playlistItemId != null) {
playlist.videos = playlist.videos.filter(video => video.playlistItemId !== playlistItemId)
} else if (videoId != null) {
playlist.videos = playlist.videos.filter(video => video.videoId !== videoId)
}
}
},
removeVideos(state, payload) {
const playlist = state.playlists.findIndex(playlist => playlist._id === payload.playlistId)
if (playlist !== -1) {
playlist.videos = playlist.videos.filter(video => payload.videoId.indexOf(video) === -1)
removeVideos(state, { _id, videoId }) {
const playlist = state.playlists.find(playlist => playlist._id === _id)
if (playlist) {
playlist.videos = playlist.videos.filter(video => videoId.indexOf(video) === -1)
}
},
@ -187,7 +444,11 @@ const mutations = {
setAllPlaylists(state, payload) {
state.playlists = payload
}
},
setPlaylistsReady(state, payload) {
state.playlistsReady = payload
},
}
export default {

View File

@ -184,12 +184,13 @@ const state = {
disableSmoothScrolling: false,
displayVideoPlayButton: true,
enableSearchSuggestions: true,
enableSubtitles: true,
enableSubtitlesByDefault: false,
enterFullscreenOnDisplayRotate: false,
externalLinkHandling: '',
externalPlayer: '',
externalPlayerExecutable: '',
externalPlayerIgnoreWarnings: false,
externalPlayerIgnoreDefaultArgs: false,
externalPlayerCustomArgs: '',
expandSideBar: false,
forceLocalBackendForLegacy: false,
@ -205,6 +206,7 @@ const state = {
hideComments: false,
hideFeaturedChannels: false,
channelsHidden: '[]',
forbiddenTitles: '[]',
hideVideoDescription: false,
hideLiveChat: false,
hideLiveStreams: false,
@ -299,6 +301,11 @@ const state = {
allowDashAv1Formats: false,
commentAutoLoadEnabled: false,
useDeArrowTitles: false,
useDeArrowThumbnails: false,
deArrowThumbnailGeneratorUrl: 'https://dearrow-thumb.ajay.app',
// This makes the `favorites` playlist uses as quick bookmark target
// If the playlist is removed quick bookmark is disabled
quickBookmarkTargetPlaylistId: 'favorites',
}
const stateWithSideEffects = {
@ -310,18 +317,20 @@ const stateWithSideEffects = {
let targetLocale = value
if (value === 'system') {
const systemLocaleName = (await getSystemLocale()).replace('-', '_') // ex: en_US
const systemLocaleLang = systemLocaleName.split('_')[0] // ex: en
const targetLocaleOptions = allLocales.filter((locale) => { // filter out other languages
const systemLocaleSplit = systemLocaleName.split('_') // ex: en
const targetLocaleOptions = allLocales.filter((locale) => {
// filter out other languages
const localeLang = locale.replace('-', '_').split('_')[0]
return localeLang.includes(systemLocaleLang)
return localeLang.includes(systemLocaleSplit[0])
}).sort((a, b) => {
const aLocaleName = a.replace('-', '_')
const bLocaleName = b.replace('-', '_')
const aLocale = aLocaleName.split('_') // ex: [en, US]
const bLocale = bLocaleName.split('_')
if (aLocale.includes(systemLocaleName)) { // country & language match, prefer a
if (aLocaleName === systemLocaleName) { // country & language match, prefer a
return -1
} else if (bLocale.includes(systemLocaleName)) { // country & language match, prefer b
} else if (bLocaleName === systemLocaleName) { // country & language match, prefer b
return 1
} else if (aLocale.length === 1) { // no country code for a, prefer a
return -1
@ -331,12 +340,11 @@ const stateWithSideEffects = {
return aLocaleName.localeCompare(bLocaleName)
}
})
if (targetLocaleOptions.length > 0) {
targetLocale = targetLocaleOptions[0]
}
// Go back to default value if locale is unavailable
if (!targetLocale) {
} else {
// Go back to default value if locale is unavailable
targetLocale = defaultLocale
// Translating this string isn't necessary
// because the user will always see it in the default locale
@ -504,10 +512,26 @@ const customActions = {
ipcRenderer.on(IpcChannels.SYNC_PLAYLISTS, (_, { event, data }) => {
switch (event) {
case SyncEvents.GENERAL.CREATE:
commit('addPlaylists', data)
break
case SyncEvents.GENERAL.DELETE:
commit('removePlaylist', data)
break
case SyncEvents.GENERAL.UPSERT:
commit('upsertPlaylistToList', data)
break
case SyncEvents.PLAYLISTS.UPSERT_VIDEO:
commit('addVideo', data)
break
case SyncEvents.PLAYLISTS.UPSERT_VIDEOS:
commit('addVideos', data)
break
case SyncEvents.PLAYLISTS.DELETE_VIDEO:
commit('removeVideo', data)
break

View File

@ -31,7 +31,12 @@ const state = {
cachedPlaylist: null,
deArrowCache: {},
showProgressBar: false,
showAddToPlaylistPrompt: false,
showCreatePlaylistPrompt: false,
progressBarPercentage: 0,
toBeAddedToPlaylistVideoList: [],
newPlaylistDefaultProperties: {},
newPlaylistVideoObject: [],
regionNames: [],
regionValues: [],
recentBlogPosts: [],
@ -84,6 +89,26 @@ const getters = {
return state.searchSettings
},
getShowAddToPlaylistPrompt () {
return state.showAddToPlaylistPrompt
},
getShowCreatePlaylistPrompt () {
return state.showCreatePlaylistPrompt
},
getToBeAddedToPlaylistVideoList () {
return state.toBeAddedToPlaylistVideoList
},
getNewPlaylistDefaultProperties () {
return state.newPlaylistDefaultProperties
},
getNewPlaylistVideoObject () {
return state.newPlaylistVideoObject
},
getShowProgressBar () {
return state.showProgressBar
},
@ -256,19 +281,87 @@ const actions = {
})
},
showAddToPlaylistPromptForManyVideos ({ commit }, { videos: videoObjectArray, newPlaylistDefaultProperties }) {
let videoDataValid = true
if (!Array.isArray(videoObjectArray)) {
videoDataValid = false
}
let missingKeys = []
if (videoDataValid) {
const requiredVideoKeys = [
'videoId',
'title',
'author',
'authorId',
'lengthSeconds',
// `timeAdded` should be generated when videos are added
// Not when a prompt is displayed
// 'timeAdded',
// `playlistItemId` should be generated anyway
// 'playlistItemId',
// `type` should be added in action anyway
// 'type',
]
// Using `every` to loop and `return false` to break
videoObjectArray.every((video) => {
const videoPropertyKeys = Object.keys(video)
const missingKeysHere = requiredVideoKeys.filter(x => !videoPropertyKeys.includes(x))
if (missingKeysHere.length > 0) {
videoDataValid = false
missingKeys = missingKeysHere
return false
}
// Return true to continue loop
return true
})
}
if (!videoDataValid) {
// Print error and abort
const errorMsgText = 'Incorrect videos data passed when opening playlist prompt'
console.error(errorMsgText)
console.error({
videoObjectArray,
missingKeys,
})
throw new Error(errorMsgText)
}
commit('setShowAddToPlaylistPrompt', true)
commit('setToBeAddedToPlaylistVideoList', videoObjectArray)
if (newPlaylistDefaultProperties != null) {
commit('setNewPlaylistDefaultProperties', newPlaylistDefaultProperties)
}
},
hideAddToPlaylistPrompt ({ commit }) {
commit('setShowAddToPlaylistPrompt', false)
// The default value properties are only valid until prompt is closed
commit('resetNewPlaylistDefaultProperties')
},
showCreatePlaylistPrompt ({ commit }, data) {
commit('setShowCreatePlaylistPrompt', true)
commit('setNewPlaylistVideoObject', data)
},
hideCreatePlaylistPrompt ({ commit }) {
commit('setShowCreatePlaylistPrompt', false)
},
updateShowProgressBar ({ commit }, value) {
commit('setShowProgressBar', value)
},
async getRegionData ({ commit }, { locale }) {
let localePathExists
const localePathExists = process.env.GEOLOCATION_NAMES.includes(locale)
// Exclude __dirname from path if not in electron
const fileLocation = `${process.env.IS_ELECTRON ? process.env.NODE_ENV === 'development' ? '.' : __dirname : ''}/static/geolocations/`
if (process.env.IS_ELECTRON) {
localePathExists = await pathExists(`${fileLocation}${locale}.json`)
} else {
localePathExists = process.env.GEOLOCATION_NAMES.includes(locale)
}
const pathName = `${fileLocation}${localePathExists ? locale : 'en-US'}.json`
const countries = process.env.IS_ELECTRON ? JSON.parse(await fs.readFile(pathName)) : await (await fetch(createWebURL(pathName))).json()
@ -483,7 +576,9 @@ const actions = {
urlType: 'channel',
channelId,
subPath,
url: url.toString()
// The original URL could be from Invidious.
// We need to make sure it starts with youtube.com, so that YouTube's resolve endpoint can recognise it
url: `https://www.youtube.com${url.pathname}`
}
}
@ -502,15 +597,10 @@ const actions = {
async getExternalPlayerCmdArgumentsData ({ commit }, payload) {
const fileName = 'external-player-map.json'
let fileData
/* eslint-disable-next-line n/no-path-concat */
const fileLocation = process.env.NODE_ENV === 'development' ? './static/' : `${__dirname}/static/`
if (await pathExists(`${fileLocation}${fileName}`)) {
fileData = await fs.readFile(`${fileLocation}${fileName}`)
} else {
fileData = '[{"name":"None","value":"","cmdArguments":null}]'
}
const fileData = await fs.readFile(`${fileLocation}${fileName}`)
const externalPlayerMap = JSON.parse(fileData).map((entry) => {
return { name: entry.name, nameTranslationKey: entry.nameTranslationKey, value: entry.value, cmdArguments: entry.cmdArguments }
@ -542,91 +632,100 @@ const actions = {
? rootState.settings.externalPlayerExecutable
: cmdArgs.defaultExecutable
const ignoreWarnings = rootState.settings.externalPlayerIgnoreWarnings
const ignoreDefaultArgs = rootState.settings.externalPlayerIgnoreDefaultArgs
const customArgs = rootState.settings.externalPlayerCustomArgs
// Append custom user-defined arguments,
// or use the default ones specified for the external player.
if (typeof customArgs === 'string' && customArgs !== '') {
const custom = customArgs.split(';')
args.push(...custom)
} else if (typeof cmdArgs.defaultCustomArguments === 'string' && cmdArgs.defaultCustomArguments !== '') {
const defaultCustomArguments = cmdArgs.defaultCustomArguments.split(';')
args.push(...defaultCustomArguments)
}
if (payload.watchProgress > 0 && payload.watchProgress < payload.videoLength - 10) {
if (typeof cmdArgs.startOffset === 'string') {
if (cmdArgs.defaultExecutable.startsWith('mpc')) {
// For mpc-hc and mpc-be, which require startOffset to be in milliseconds
args.push(cmdArgs.startOffset, (Math.trunc(payload.watchProgress) * 1000))
} else if (cmdArgs.startOffset.endsWith('=')) {
// For players using `=` in arguments
// e.g. vlc --start-time=xxxxx
args.push(`${cmdArgs.startOffset}${payload.watchProgress}`)
} else {
// For players using space in arguments
// e.g. smplayer -start xxxxx
args.push(cmdArgs.startOffset, Math.trunc(payload.watchProgress))
}
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'starting video at offset')
}
}
if (payload.playbackRate != null) {
if (typeof cmdArgs.playbackRate === 'string') {
args.push(`${cmdArgs.playbackRate}${payload.playbackRate}`)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'setting a playback rate')
}
}
// Check whether the video is in a playlist
if (typeof cmdArgs.playlistUrl === 'string' && payload.playlistId != null && payload.playlistId !== '') {
if (payload.playlistIndex != null) {
if (typeof cmdArgs.playlistIndex === 'string') {
args.push(`${cmdArgs.playlistIndex}${payload.playlistIndex}`)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'opening specific video in a playlist (falling back to opening the video)')
}
}
if (payload.playlistReverse) {
if (typeof cmdArgs.playlistReverse === 'string') {
args.push(cmdArgs.playlistReverse)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'reversing playlists')
}
}
if (payload.playlistShuffle) {
if (typeof cmdArgs.playlistShuffle === 'string') {
args.push(cmdArgs.playlistShuffle)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'shuffling playlists')
}
}
if (payload.playlistLoop) {
if (typeof cmdArgs.playlistLoop === 'string') {
args.push(cmdArgs.playlistLoop)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'looping playlists')
}
}
// If the player supports opening playlists but not indexes, send only the video URL if an index is specified
if (cmdArgs.playlistIndex == null && payload.playlistIndex != null && payload.playlistIndex !== '') {
args.push(`${cmdArgs.videoUrl}https://youtube.com/watch?v=${payload.videoId}`)
} else {
args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`)
if (ignoreDefaultArgs) {
if (typeof customArgs === 'string' && customArgs !== '') {
const custom = customArgs.split(';')
args.push(...custom)
}
if (payload.videoId != null) args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`)
} else {
if (payload.playlistId != null && payload.playlistId !== '' && !ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'opening playlists')
// Append custom user-defined arguments,
// or use the default ones specified for the external player.
if (typeof customArgs === 'string' && customArgs !== '') {
const custom = customArgs.split(';')
args.push(...custom)
} else if (typeof cmdArgs.defaultCustomArguments === 'string' && cmdArgs.defaultCustomArguments !== '') {
const defaultCustomArguments = cmdArgs.defaultCustomArguments.split(';')
args.push(...defaultCustomArguments)
}
if (payload.videoId != null) {
args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`)
if (payload.watchProgress > 0 && payload.watchProgress < payload.videoLength - 10) {
if (typeof cmdArgs.startOffset === 'string') {
if (cmdArgs.defaultExecutable.startsWith('mpc')) {
// For mpc-hc and mpc-be, which require startOffset to be in milliseconds
args.push(cmdArgs.startOffset, (Math.trunc(payload.watchProgress) * 1000))
} else if (cmdArgs.startOffset.endsWith('=')) {
// For players using `=` in arguments
// e.g. vlc --start-time=xxxxx
args.push(`${cmdArgs.startOffset}${payload.watchProgress}`)
} else {
// For players using space in arguments
// e.g. smplayer -start xxxxx
args.push(cmdArgs.startOffset, Math.trunc(payload.watchProgress))
}
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'starting video at offset')
}
}
if (payload.playbackRate != null) {
if (typeof cmdArgs.playbackRate === 'string') {
args.push(`${cmdArgs.playbackRate}${payload.playbackRate}`)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'setting a playback rate')
}
}
// Check whether the video is in a playlist
if (typeof cmdArgs.playlistUrl === 'string' && payload.playlistId != null && payload.playlistId !== '') {
if (payload.playlistIndex != null) {
if (typeof cmdArgs.playlistIndex === 'string') {
args.push(`${cmdArgs.playlistIndex}${payload.playlistIndex}`)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'opening specific video in a playlist (falling back to opening the video)')
}
}
if (payload.playlistReverse) {
if (typeof cmdArgs.playlistReverse === 'string') {
args.push(cmdArgs.playlistReverse)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'reversing playlists')
}
}
if (payload.playlistShuffle) {
if (typeof cmdArgs.playlistShuffle === 'string') {
args.push(cmdArgs.playlistShuffle)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'shuffling playlists')
}
}
if (payload.playlistLoop) {
if (typeof cmdArgs.playlistLoop === 'string') {
args.push(cmdArgs.playlistLoop)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'looping playlists')
}
}
// If the player supports opening playlists but not indexes, send only the video URL if an index is specified
if (cmdArgs.playlistIndex == null && payload.playlistIndex != null && payload.playlistIndex !== '') {
args.push(`${cmdArgs.videoUrl}https://youtube.com/watch?v=${payload.videoId}`)
} else {
args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`)
}
} else {
if (payload.playlistId != null && payload.playlistId !== '' && !ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'opening playlists')
}
if (payload.videoId != null) {
args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`)
}
}
}
@ -676,6 +775,10 @@ const mutations = {
}
},
addThumbnailToDeArrowCache (state, payload) {
vueSet(state.deArrowCache, payload.videoId, payload)
},
addToSessionSearchHistory (state, payload) {
const sameSearch = state.sessionSearchHistory.findIndex((search) => {
return search.query === payload.query && searchFiltersMatch(payload.searchSettings, search.searchSettings)
@ -695,6 +798,29 @@ const mutations = {
}
},
setShowAddToPlaylistPrompt (state, payload) {
state.showAddToPlaylistPrompt = payload
},
setShowCreatePlaylistPrompt (state, payload) {
state.showCreatePlaylistPrompt = payload
},
setToBeAddedToPlaylistVideoList (state, payload) {
state.toBeAddedToPlaylistVideoList = payload
},
setNewPlaylistDefaultProperties (state, payload) {
state.newPlaylistDefaultProperties = payload
},
resetNewPlaylistDefaultProperties (state) {
state.newPlaylistDefaultProperties = {}
},
setNewPlaylistVideoObject (state, payload) {
state.newPlaylistVideoObject = payload
},
setPopularCache (state, value) {
state.popularCache = value
},

Some files were not shown because too many files have changed in this diff Show More