mirror of https://github.com/FreeTubeApp/FreeTube
Merge branch 'FreeTubeApp:development' into development
This commit is contained in:
commit
7fddb9ea2c
|
@ -101,6 +101,7 @@ body:
|
|||
- PortableApps (Unofficial)
|
||||
- Scoop (Unofficial)
|
||||
- Snapcraft (Unofficial)
|
||||
- WAPT (Unofficial)
|
||||
- winget (Unofficial)
|
||||
- other
|
||||
validations:
|
||||
|
|
|
@ -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)'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
@ -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`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"vueCompilerOptions": {
|
||||
"target": 2.7
|
||||
},
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true
|
||||
}
|
||||
}
|
||||
|
|
68
package.json
68
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
|
|
|
@ -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 !== ''"
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
block-size: 50px;
|
||||
border-radius: 100%;
|
||||
-webkit-border-radius: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.selected {
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.playlistNameInput {
|
||||
inline-size: 80%;
|
||||
max-inline-size: 600px;
|
||||
}
|
|
@ -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',
|
||||
])
|
||||
}
|
||||
})
|
|
@ -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" />
|
|
@ -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 () {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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']"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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'
|
||||
])
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -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" />
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
])
|
||||
}
|
||||
})
|
|
@ -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" />
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
|
@ -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;
|
||||
}
|
|
@ -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" />
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -30,7 +30,11 @@ export default defineComponent({
|
|||
tooltipPosition: {
|
||||
type: String,
|
||||
default: 'bottom-left'
|
||||
}
|
||||
},
|
||||
tooltipAllowNewlines: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
class="selectTooltip"
|
||||
:position="tooltipPosition"
|
||||
:tooltip="tooltip"
|
||||
:allow-newlines="tooltipAllowNewlines"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -13,7 +13,11 @@ export default defineComponent({
|
|||
tooltip: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
allowNewlines: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const id = `ft-tooltip-${++idCounter}`
|
||||
|
|
|
@ -10,11 +10,13 @@
|
|||
<p
|
||||
:id="id"
|
||||
class="text"
|
||||
:class="position"
|
||||
:class="{
|
||||
[position]: true,
|
||||
allowNewlines,
|
||||
}"
|
||||
role="tooltip"
|
||||
>
|
||||
{{ tooltip }}
|
||||
</p>
|
||||
v-text="tooltip"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
]),
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
])
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.protocol-dropdown {
|
||||
margin-block-end: 1rem;
|
||||
}
|
|
@ -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" />
|
||||
|
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')"
|
||||
|
|
|
@ -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')
|
||||
]
|
||||
},
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 !== ''"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue