Merge branch 'v0.17.0-RC'

This commit is contained in:
PrestonN 2022-07-31 20:31:28 -04:00
commit 0bcf2d1ece
148 changed files with 9563 additions and 5904 deletions

View File

@ -4,15 +4,13 @@
"@babel/env", "@babel/env",
{ {
"targets": { "targets": {
"chrome": "73", "chrome": "96",
"node": 12 "node": 16
} }
} }
], ]
"@babel/typescript"
], ],
"plugins": [ "plugins": [
"@babel/proposal-class-properties", "@babel/proposal-class-properties"
"@babel/proposal-object-rest-spread"
] ]
} }

View File

@ -17,6 +17,8 @@ body:
required: true required: true
- label: I have searched the [issue tracker](https://github.com/FreeTubeApp/FreeTube/issues) for a bug report that matches the one I want to file, without success. - label: I have searched the [issue tracker](https://github.com/FreeTubeApp/FreeTube/issues) for a bug report that matches the one I want to file, without success.
required: true required: true
- label: I have searched the [documentation](https://docs.freetubeapp.io/) for information that matches the description of the bug I want to file, without success.
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Describe the bug label: Describe the bug
@ -113,4 +115,4 @@ body:
description: Please ensure you've completed the following, if applicable. description: Please ensure you've completed the following, if applicable.
options: options:
- label: I have encountered this bug in the latest [nightly build](https://docs.freetubeapp.io/development/nightly-builds). - label: I have encountered this bug in the latest [nightly build](https://docs.freetubeapp.io/development/nightly-builds).
required: false required: false

View File

@ -15,6 +15,8 @@ body:
options: options:
- label: I have searched the [issue tracker](https://github.com/FreeTubeApp/FreeTube/issues) for a feature request that matches the one I want to file, without success. - label: I have searched the [issue tracker](https://github.com/FreeTubeApp/FreeTube/issues) for a feature request that matches the one I want to file, without success.
required: true required: true
- label: I have searched the [documentation](https://docs.freetubeapp.io/) for information that matches the description of the feature request I want to file, without success.
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Problem Description label: Problem Description

View File

@ -0,0 +1,14 @@
name: "Label Duplicate"
on:
issue_comment:
types: [created]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: Amwam/issue-comment-action@v1.3.1
with:
keywords: '["duplicate of"]'
labels: '["U: duplicate"]'
github-token: "${{ secrets.GITHUB_TOKEN }}"

View File

@ -11,11 +11,11 @@ jobs:
build: build:
strategy: strategy:
matrix: matrix:
node-version: [14.x] node-version: [16.x]
runtime: [ linux-x64, linux-arm64, win-x64, osx-x64 ] runtime: [ linux-x64, linux-arm64, win-x64, osx-x64 ]
include: include:
- runtime: linux-x64 - runtime: linux-x64
os: ubuntu-latest os: ubuntu-18.04
- runtime: linux-arm64 - runtime: linux-arm64
os: ubuntu-latest os: ubuntu-latest

View File

@ -6,7 +6,7 @@ name: Linter
# events but only for the master branch # events but only for the master branch
on: on:
pull_request: pull_request:
branches: [ master, development ] branches: [ master, development, '**-RC' ]
# A workflow run is made up of one or more jobs that can run sequentially or in parallel # A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs: jobs:
@ -18,10 +18,10 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job # Steps represent a sequence of tasks that will be executed as part of the job
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Use Node.js 14.x - name: Use Node.js 16.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 14.x node-version: 16.x
cache: "yarn" cache: "yarn"
- run: npm run ci - run: npm run ci
- run: npm run lint - run: npm run lint

View File

@ -12,7 +12,7 @@ jobs:
build: build:
strategy: strategy:
matrix: matrix:
node-version: [14.x] node-version: [16.x]
runtime: [ linux-x64, linux-arm64, win-x64, osx-x64 ] runtime: [ linux-x64, linux-arm64, win-x64, osx-x64 ]
include: include:
- runtime: linux-x64 - runtime: linux-x64

View File

@ -0,0 +1,36 @@
name: Remove outdated labels
on:
# https://github.community/t/github-actions-are-severely-limited-on-prs/18179/15
pull_request_target:
types:
- closed
jobs:
remove-merged-pr-labels:
name: Remove merged pull request labels
if: github.event.pull_request.merged
runs-on: ubuntu-latest
steps:
- uses: mondeja/remove-labels-gh-action@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
labels: |
PR: waiting for review
PR: WIP
PR: changes requested
PR: merge conflicts / rebase needed
PR/Issue: dependent
remove-closed-pr-labels:
name: Remove closed pull request labels
if: github.event_name == 'pull_request_target' && (! github.event.pull_request.merged)
runs-on: ubuntu-latest
steps:
- uses: mondeja/remove-labels-gh-action@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
labels: |
PR: waiting for review
PR: WIP
PR: changes requested
PR: merge conflicts / rebase needed
PR/Issue: dependent

View File

@ -18,6 +18,8 @@ FreeTube is supported by the [Privacy Redirect](https://github.com/SimonBrazell/
Download Privacy Redirect for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/privacy-redirect/) or [Google Chrome](https://chrome.google.com/webstore/detail/privacy-redirect/pmcmeagblkinmogikoikkdjiligflglb). Download Privacy Redirect for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/privacy-redirect/) or [Google Chrome](https://chrome.google.com/webstore/detail/privacy-redirect/pmcmeagblkinmogikoikkdjiligflglb).
Disclaimer: Learn more about why a browser extension is bad for your [privacy](https://www.privacyguides.org/browsers/#extensions).
If you have issues with the extension working with FreeTube, please create an issue in this repository instead of the extension repository. If you have issues with the extension working with FreeTube, please create an issue in this repository instead of the extension repository.
## How does it work? ## How does it work?
@ -62,6 +64,8 @@ Chocolatey: [Download](https://chocolatey.org/packages/freetube/)
Windows Portable: [Download](https://github.com/rddim/FreeTubePortable/releases) [Source](https://github.com/rddim/FreeTubePortable) Windows Portable: [Download](https://github.com/rddim/FreeTubePortable/releases) [Source](https://github.com/rddim/FreeTubePortable)
Windows Package Manager (winget): [Usage](https://docs.microsoft.com/en-us/windows/package-manager/winget/)
### Automated Builds (Nightly / Weekly) ### Automated Builds (Nightly / Weekly)
Builds are automatically created from changes to our development branch via [GitHub Actions](https://github.com/FreeTubeApp/FreeTube/actions?query=workflow%3ABuild). Builds are automatically created from changes to our development branch via [GitHub Actions](https://github.com/FreeTubeApp/FreeTube/actions?query=workflow%3ABuild).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -7,13 +7,22 @@ const { name, productName } = require('../package.json')
const args = process.argv const args = process.argv
let targets let targets
var platform = os.platform() const platform = os.platform()
const cpus = os.cpus()
if (platform == 'darwin') { if (platform === 'darwin') {
targets = Platform.MAC.createTarget() let arch = Arch.x64
} else if (platform == 'win32') {
// Macbook Air 2020 with M1 = 'Apple M1'
// Macbook Pro 2021 with M1 Pro = 'Apple M1 Pro'
if (cpus[0].model.startsWith('Apple')) {
arch = Arch.arm64
}
targets = Platform.MAC.createTarget(['dmg'], arch)
} else if (platform === 'win32') {
targets = Platform.WINDOWS.createTarget() targets = Platform.WINDOWS.createTarget()
} else if (platform == 'linux') { } else if (platform === 'linux') {
let arch = Arch.x64 let arch = Arch.x64
if (args[2] === 'arm64') { if (args[2] === 'arm64') {

View File

@ -115,17 +115,20 @@ function startRenderer(callback) {
console.log(`\nWatching file changes for ${name} script...`) console.log(`\nWatching file changes for ${name} script...`)
}) })
const server = new WebpackDevServer(compiler, { const server = new WebpackDevServer({
static: { static: {
directory: path.join(process.cwd(), 'static'), directory: path.join(process.cwd(), 'static'),
watch: { watch: {
ignored: /(dashFiles|storyboards)\/*/ ignored: [
/(dashFiles|storyboards)\/*/,
'/**/.DS_Store',
]
} }
}, },
port port
}) }, compiler)
server.listen(port, '', err => { server.startCallback(err => {
if (err) console.error(err) if (err) console.error(err)
callback() callback()

View File

@ -49,7 +49,7 @@ const config = {
path: path.join(__dirname, '../dist'), path: path.join(__dirname, '../dist'),
}, },
resolve: { resolve: {
extensions: ['.ts', '.js', '.json'], extensions: ['.js', '.json'],
alias: { alias: {
'@': path.join(__dirname, '../src/'), '@': path.join(__dirname, '../src/'),
src: path.join(__dirname, '../src/'), src: path.join(__dirname, '../src/'),

View File

@ -138,7 +138,7 @@ const config = {
images: path.join(__dirname, '../src/renderer/assets/img/'), images: path.join(__dirname, '../src/renderer/assets/img/'),
static: path.join(__dirname, '../static/'), static: path.join(__dirname, '../static/'),
}, },
extensions: ['.ts', '.js', '.vue', '.json'], extensions: ['.js', '.vue', '.json'],
}, },
target: 'electron-renderer', target: 'electron-renderer',
} }

View File

@ -15,7 +15,7 @@ const config = {
mode: process.env.NODE_ENV, mode: process.env.NODE_ENV,
devtool: isDevMode ? 'eval-cheap-module-source-map' : false, devtool: isDevMode ? 'eval-cheap-module-source-map' : false,
entry: { entry: {
workerSample: path.join(__dirname, '../src/utilities/workerSample.ts'), workerSample: path.join(__dirname, '../src/utilities/workerSample.js'),
}, },
output: { output: {
libraryTarget: 'commonjs2', libraryTarget: 'commonjs2',
@ -52,7 +52,7 @@ const config = {
'@': path.join(__dirname, '../src/'), '@': path.join(__dirname, '../src/'),
src: path.join(__dirname, '../src/'), src: path.join(__dirname, '../src/'),
}, },
extensions: ['.ts', '.js', '.json'], extensions: ['.js', '.json'],
}, },
target: 'node', target: 'node',
} }

View File

@ -2,7 +2,7 @@
"name": "freetube", "name": "freetube",
"productName": "FreeTube", "productName": "FreeTube",
"description": "A private YouTube client", "description": "A private YouTube client",
"version": "0.16.0", "version": "0.17.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"main": "./dist/main.js", "main": "./dist/main.js",
"private": true, "private": true,
@ -30,23 +30,18 @@
"debug-runner": "node _scripts/dev-runner.js --remote-debug", "debug-runner": "node _scripts/dev-runner.js --remote-debug",
"dev": "run-s rebuild:electron dev-runner", "dev": "run-s rebuild:electron dev-runner",
"dev-runner": "node _scripts/dev-runner.js", "dev-runner": "node _scripts/dev-runner.js",
"jest": "jest", "lint-fix": "eslint --fix --ext .js,.vue ./",
"jest:coverage": "jest --collect-coverage", "lint": "eslint --ext .js,.vue ./",
"jest:watch": "jest --watch",
"lint-fix": "eslint --fix --ext .js,.ts,.vue ./",
"lint": "eslint --ext .js,.ts,.vue ./",
"pack": "run-p pack:main pack:renderer pack:workers", "pack": "run-p pack:main pack:renderer pack:workers",
"pack:main": "webpack --mode=production --node-env=production --config _scripts/webpack.main.config.js", "pack:main": "webpack --mode=production --node-env=production --config _scripts/webpack.main.config.js",
"pack:renderer": "webpack --mode=production --node-env=production --config _scripts/webpack.renderer.config.js", "pack:renderer": "webpack --mode=production --node-env=production --config _scripts/webpack.renderer.config.js",
"pack:web": "webpack --mode=production --node-env=production --config _scripts/webpack.web.config.js", "pack:web": "webpack --mode=production --node-env=production --config _scripts/webpack.web.config.js",
"pack:workers": "webpack --mode=production --node-env=production --config _scripts/webpack.workers.config.js", "pack:workers": "webpack --mode=production --node-env=production --config _scripts/webpack.workers.config.js",
"postinstall": "npm run rebuild:electron", "postinstall": "npm run rebuild:electron",
"prettier": "prettier --write \"{src,_scripts}/**/*.{js,ts,vue}\"", "prettier": "prettier --write \"{src,_scripts}/**/*.{js,vue}\"",
"rebuild:electron": "electron-builder install-app-deps", "rebuild:electron": "electron-builder install-app-deps",
"rebuild:node": "npm rebuild", "rebuild:node": "npm rebuild",
"release": "run-s test build", "release": "run-s test build",
"test": "run-s rebuild:node pack:workers jest",
"test:watch": "run-s rebuild:node pack:workers jest:watch",
"ci": "yarn install --frozen-lockfile" "ci": "yarn install --frozen-lockfile"
}, },
"dependencies": { "dependencies": {
@ -55,60 +50,48 @@
"@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^2.0.2", "@fortawesome/vue-fontawesome": "^2.0.2",
"@freetube/youtube-chat": "^1.1.2", "@freetube/youtube-chat": "^1.1.2",
"@freetube/yt-comment-scraper": "^6.0.0", "@freetube/yt-comment-scraper": "^6.1.0",
"@silvermine/videojs-quality-selector": "^1.2.5", "@silvermine/videojs-quality-selector": "^1.2.5",
"autolinker": "^3.14.3", "autolinker": "^3.15.0",
"bulma-pro": "^0.2.0", "electron-context-menu": "^3.1.2",
"dateformat": "^4.5.1",
"electron-context-menu": "^3.1.1",
"http-proxy-agent": "^4.0.1", "http-proxy-agent": "^4.0.1",
"https-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lodash.uniqwith": "^4.5.0", "marked": "^4.0.15",
"marked": "^4.0.10",
"material-design-icons": "^3.0.1",
"nedb-promises": "^5.0.1", "nedb-promises": "^5.0.1",
"node-forge": "^1.0.0",
"opml-to-json": "^1.0.1", "opml-to-json": "^1.0.1",
"rss-parser": "^3.12.0", "rss-parser": "^3.12.0",
"socks-proxy-agent": "^6.0.0", "socks-proxy-agent": "^6.0.0",
"video.js": "7.14.3", "video.js": "7.18.1",
"videojs-abloop": "^1.2.0",
"videojs-contrib-quality-levels": "^2.1.0", "videojs-contrib-quality-levels": "^2.1.0",
"videojs-http-source-selector": "^1.1.6", "videojs-http-source-selector": "^1.1.6",
"videojs-overlay": "^2.1.4", "videojs-overlay": "^2.1.4",
"videojs-replay": "^1.1.0",
"videojs-vtt-thumbnails-freetube": "0.0.15", "videojs-vtt-thumbnails-freetube": "0.0.15",
"vue": "^2.6.14", "vue": "^2.6.14",
"vue-electron": "^1.0.6",
"vue-i18n": "^8.25.0", "vue-i18n": "^8.25.0",
"vue-observe-visibility": "^1.0.0", "vue-observe-visibility": "^1.0.0",
"vue-router": "^3.5.2", "vue-router": "^3.5.2",
"vuex": "^3.6.2", "vuex": "^3.6.2",
"youtube-suggest": "^1.1.2", "youtube-suggest": "^1.1.2",
"yt-channel-info": "^2.2.0", "yt-channel-info": "^3.0.4",
"yt-dash-manifest-generator": "1.1.0", "yt-dash-manifest-generator": "1.1.0",
"yt-trending-scraper": "^2.0.1", "yt-trending-scraper": "^2.0.1",
"ytdl-core": "^4.10.1", "ytdl-core": "^4.11.0",
"ytpl": "^2.2.3", "ytpl": "^2.3.0",
"ytsr": "^3.5.3" "ytsr": "^3.8.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.15.0", "@babel/core": "^7.17.10",
"@babel/plugin-proposal-class-properties": "^7.14.5", "@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-object-rest-spread": "^7.14.7", "@babel/preset-env": "^7.17.10",
"@babel/preset-env": "^7.15.0",
"@babel/preset-typescript": "^7.15.0",
"@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-loader": "^8.2.2", "babel-loader": "^8.2.5",
"copy-webpack-plugin": "^9.0.1", "copy-webpack-plugin": "^9.0.1",
"css-loader": "5.2.6", "css-loader": "5.2.6",
"electron": "^16.0.8", "electron": "^16.2.7",
"electron-builder": "^22.11.7", "electron-builder": "^22.11.7",
"electron-builder-squirrel-windows": "^22.13.1", "electron-builder-squirrel-windows": "^22.13.1",
"electron-debug": "^3.2.0", "electron-debug": "^3.2.0",
@ -124,7 +107,6 @@
"fast-glob": "^3.2.7", "fast-glob": "^3.2.7",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"html-webpack-plugin": "^5.3.2", "html-webpack-plugin": "^5.3.2",
"jest": "^27.1.0",
"mini-css-extract-plugin": "^2.2.2", "mini-css-extract-plugin": "^2.2.2",
"node-abi": "^2.30.1", "node-abi": "^2.30.1",
"node-loader": "^2.0.0", "node-loader": "^2.0.0",
@ -135,7 +117,6 @@
"sass-loader": "^12.1.0", "sass-loader": "^12.1.0",
"style-loader": "^3.2.1", "style-loader": "^3.2.1",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"typescript": "^4.4.2",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"vue-devtools": "^5.1.4", "vue-devtools": "^5.1.4",
"vue-eslint-parser": "^7.10.0", "vue-eslint-parser": "^7.10.0",

View File

@ -6,12 +6,14 @@ const IpcChannels = {
GET_SYSTEM_LOCALE: 'get-system-locale', GET_SYSTEM_LOCALE: 'get-system-locale',
GET_USER_DATA_PATH: 'get-user-data-path', GET_USER_DATA_PATH: 'get-user-data-path',
GET_USER_DATA_PATH_SYNC: 'get-user-data-path-sync', GET_USER_DATA_PATH_SYNC: 'get-user-data-path-sync',
GET_PICTURES_PATH: 'get-pictures-path',
SHOW_OPEN_DIALOG: 'show-open-dialog', SHOW_OPEN_DIALOG: 'show-open-dialog',
SHOW_SAVE_DIALOG: 'show-save-dialog', SHOW_SAVE_DIALOG: 'show-save-dialog',
STOP_POWER_SAVE_BLOCKER: 'stop-power-save-blocker', STOP_POWER_SAVE_BLOCKER: 'stop-power-save-blocker',
START_POWER_SAVE_BLOCKER: 'start-power-save-blocker', START_POWER_SAVE_BLOCKER: 'start-power-save-blocker',
CREATE_NEW_WINDOW: 'create-new-window', CREATE_NEW_WINDOW: 'create-new-window',
OPEN_IN_EXTERNAL_PLAYER: 'open-in-external-player', OPEN_IN_EXTERNAL_PLAYER: 'open-in-external-player',
NATIVE_THEME_UPDATE: 'native-theme-update',
DB_SETTINGS: 'db-settings', DB_SETTINGS: 'db-settings',
DB_HISTORY: 'db-history', DB_HISTORY: 'db-history',
@ -36,8 +38,7 @@ const DBActions = {
}, },
HISTORY: { HISTORY: {
UPDATE_WATCH_PROGRESS: 'db-action-history-update-watch-progress', UPDATE_WATCH_PROGRESS: 'db-action-history-update-watch-progress'
SEARCH: 'db-action-history-search'
}, },
PLAYLISTS: { PLAYLISTS: {

View File

@ -27,6 +27,10 @@ class Settings {
return db.settings.findOne({ _id: 'bounds' }) return db.settings.findOne({ _id: 'bounds' })
} }
static _findTheme() {
return db.settings.findOne({ _id: 'baseTheme' })
}
static _updateBounds(value) { static _updateBounds(value) {
return db.settings.update({ _id: 'bounds' }, { _id: 'bounds', value }, { upsert: true }) return db.settings.update({ _id: 'bounds' }, { _id: 'bounds', value }, { upsert: true })
} }
@ -38,11 +42,6 @@ class History {
return db.history.find({}).sort({ timeWatched: -1 }) return db.history.find({}).sort({ timeWatched: -1 })
} }
static search(query) {
const re = new RegExp(query, 'i')
return db.history.find({ $or: [{ author: { $regex: re } }, { title: { $regex: re } }] }).sort({ timeWatched: -1 })
}
static upsert(record) { static upsert(record) {
return db.history.update({ videoId: record.videoId }, record, { upsert: true }) return db.history.update({ videoId: record.videoId }, record, { upsert: true })
} }

View File

@ -25,13 +25,6 @@ class History {
) )
} }
static search(query) {
return ipcRenderer.invoke(
IpcChannels.DB_HISTORY,
{ action: DBActions.HISTORY.SEARCH, data: query }
)
}
static upsert(record) { static upsert(record) {
return ipcRenderer.invoke( return ipcRenderer.invoke(
IpcChannels.DB_HISTORY, IpcChannels.DB_HISTORY,

View File

@ -25,10 +25,6 @@ class History {
return baseHandlers.history.find() return baseHandlers.history.find()
} }
static search(query) {
return baseHandlers.history.search(query)
}
static upsert(record) { static upsert(record) {
return baseHandlers.history.upsert(record) return baseHandlers.history.upsert(record)
} }

View File

@ -18,7 +18,4 @@ db.profiles = Datastore.create({ filename: dbPath('profiles'), autoload: true })
db.playlists = Datastore.create({ filename: dbPath('playlists'), autoload: true }) db.playlists = Datastore.create({ filename: dbPath('playlists'), autoload: true })
db.history = Datastore.create({ filename: dbPath('history'), autoload: true }) db.history = Datastore.create({ filename: dbPath('history'), autoload: true })
db.history.ensureIndex({ fieldName: 'author' })
db.history.ensureIndex({ fieldName: 'title' })
export default db export default db

View File

@ -16,7 +16,7 @@
<% } %> <% } %>
</head> </head>
<body class="dark mainRed secBlue"> <body>
<div id="app"></div> <div id="app"></div>
<!-- Set `__static` path to static files in production --> <!-- Set `__static` path to static files in production -->
<script> <script>

View File

@ -1,6 +1,6 @@
import { import {
app, BrowserWindow, dialog, Menu, ipcMain, app, BrowserWindow, dialog, Menu, ipcMain,
powerSaveBlocker, screen, session, shell powerSaveBlocker, screen, session, shell, nativeTheme
} from 'electron' } from 'electron'
import path from 'path' import path from 'path'
import cp from 'child_process' import cp from 'child_process'
@ -25,7 +25,7 @@ function runApp() {
label: 'Show Video Statistics', label: 'Show Video Statistics',
visible: parameters.mediaType === 'video', visible: parameters.mediaType === 'video',
click: () => { click: () => {
browserWindow.webContents.send('showVideoStatistics', 'show') browserWindow.webContents.send('showVideoStatistics')
} }
} }
] ]
@ -35,6 +35,7 @@ function runApp() {
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true'
const isDev = process.env.NODE_ENV === 'development' const isDev = process.env.NODE_ENV === 'development'
const isDebug = process.argv.includes('--debug') const isDebug = process.argv.includes('--debug')
let mainWindow let mainWindow
let startupUrl let startupUrl
@ -146,7 +147,8 @@ function runApp() {
session.defaultSession.cookies.set({ session.defaultSession.cookies.set({
url: url, url: url,
name: 'CONSENT', name: 'CONSENT',
value: 'YES+' value: 'YES+',
sameSite: 'no_restriction'
}) })
}) })
@ -172,11 +174,33 @@ function runApp() {
} }
async function createWindow({ replaceMainWindow = true, windowStartupUrl = null, showWindowNow = false } = { }) { async function createWindow({ replaceMainWindow = true, windowStartupUrl = null, showWindowNow = false } = { }) {
// Syncing new window background to theme choice.
const windowBackground = await baseHandlers.settings._findTheme().then(({ value }) => {
switch (value) {
case 'dark':
return '#212121'
case 'light':
return '#f1f1f1'
case 'black':
return '#000000'
case 'dracula':
return '#282a36'
case 'system':
default:
return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1'
}
}).catch((error) => {
console.log(error)
// Default to nativeTheme settings if nothing is found.
return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1'
})
/** /**
* Initial window options * Initial window options
*/ */
const commonBrowserWindowOptions = { const commonBrowserWindowOptions = {
backgroundColor: '#212121', backgroundColor: windowBackground,
darkTheme: nativeTheme.shouldUseDarkColors,
icon: isDev icon: isDev
? path.join(__dirname, '../../_icons/iconColor.png') ? path.join(__dirname, '../../_icons/iconColor.png')
/* eslint-disable-next-line */ /* eslint-disable-next-line */
@ -191,6 +215,7 @@ function runApp() {
contextIsolation: false contextIsolation: false
} }
} }
const newWindow = new BrowserWindow( const newWindow = new BrowserWindow(
Object.assign( Object.assign(
{ {
@ -241,6 +266,7 @@ function runApp() {
height: bounds.height height: bounds.height
}) })
} }
if (maximized) { if (maximized) {
newWindow.maximize() newWindow.maximize()
} }
@ -345,6 +371,14 @@ function runApp() {
app.quit() app.quit()
}) })
nativeTheme.on('updated', () => {
const allWindows = BrowserWindow.getAllWindows()
allWindows.forEach((window) => {
window.webContents.send(IpcChannels.NATIVE_THEME_UPDATE, nativeTheme.shouldUseDarkColors)
})
})
ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => { ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => {
console.log(url) console.log(url)
session.defaultSession.setProxy({ session.defaultSession.setProxy({
@ -372,11 +406,23 @@ function runApp() {
event.returnValue = app.getPath('userData') event.returnValue = app.getPath('userData')
}) })
ipcMain.handle(IpcChannels.GET_PICTURES_PATH, () => {
return app.getPath('pictures')
})
ipcMain.handle(IpcChannels.SHOW_OPEN_DIALOG, async (_, options) => { ipcMain.handle(IpcChannels.SHOW_OPEN_DIALOG, async (_, options) => {
return await dialog.showOpenDialog(options) return await dialog.showOpenDialog(options)
}) })
ipcMain.handle(IpcChannels.SHOW_SAVE_DIALOG, async (_, options) => { ipcMain.handle(IpcChannels.SHOW_SAVE_DIALOG, async (event, { options, useModal }) => {
if (useModal) {
const senderWindow = BrowserWindow.getAllWindows().find((window) => {
return window.webContents.id === event.sender.id
})
if (senderWindow) {
return await dialog.showSaveDialog(senderWindow, options)
}
}
return await dialog.showSaveDialog(options) return await dialog.showSaveDialog(options)
}) })
@ -388,10 +434,11 @@ function runApp() {
return powerSaveBlocker.start('prevent-display-sleep') return powerSaveBlocker.start('prevent-display-sleep')
}) })
ipcMain.on(IpcChannels.CREATE_NEW_WINDOW, () => { ipcMain.on(IpcChannels.CREATE_NEW_WINDOW, (_e, { windowStartupUrl = null } = { }) => {
createWindow({ createWindow({
replaceMainWindow: false, replaceMainWindow: false,
showWindowNow: true showWindowNow: true,
windowStartupUrl: windowStartupUrl
}) })
}) })
@ -456,9 +503,6 @@ function runApp() {
) )
return null return null
case DBActions.HISTORY.SEARCH:
return await baseHandlers.history.search(data)
case DBActions.GENERAL.DELETE: case DBActions.GENERAL.DELETE:
await baseHandlers.history.delete(data) await baseHandlers.history.delete(data)
syncOtherWindows( syncOtherWindows(
@ -726,7 +770,21 @@ function runApp() {
const template = [ const template = [
{ {
label: 'File', label: 'File',
submenu: [{ role: 'quit' }] submenu: [
{
label: 'New Window',
accelerator: 'CmdOrCtrl+N',
click: (_menuItem, _browserWindow, _event) => {
createWindow({
replaceMainWindow: false,
showWindowNow: true
})
},
type: 'normal'
},
{ type: 'separator' },
{ role: 'quit' }
]
}, },
{ {
label: 'Edit', label: 'Edit',

View File

@ -3,33 +3,29 @@
src: url(assets/font/Roboto-Regular.ttf); src: url(assets/font/Roboto-Regular.ttf);
} }
body {
min-height: 100vh;
}
#app { #app {
font-family: 'Roboto', sans-serif; display: flex;
flex-wrap: wrap;
font-family: 'Roboto', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
height: 100%;
} }
.routerView { .routerView {
margin-left: 200px; flex: 1 1 0%;
margin-top: 80px; margin: 18px 10px;
transition-property: margin;
transition-duration: 150ms;
transition-timing-function: ease-in-out;
}
.expand {
margin-left: 80px;
} }
.banner { .banner {
width: 85%; width: 85%;
margin: 20px auto 0;
}
.banner-wrapper {
margin: 0 10px;
} }
.flexBox { .flexBox {
margin-top: 60px; display: block;
margin-bottom: -75px;
} }
#changeLogText { #changeLogText {
@ -46,13 +42,13 @@ body {
} }
@media only screen and (max-width: 680px) { @media only screen and (max-width: 680px) {
.expand, .routerView { .routerView {
margin-left: 0px; margin: 68px 8px 68px;
margin-bottom: 80px;
} }
.banner { .banner {
width: 90%; width: 80%;
margin-top: 20px;
} }
.flexBox { .flexBox {

View File

@ -12,6 +12,7 @@ import FtProgressBar from './components/ft-progress-bar/ft-progress-bar.vue'
import $ from 'jquery' import $ from 'jquery'
import { marked } from 'marked' import { marked } from 'marked'
import Parser from 'rss-parser' import Parser from 'rss-parser'
import { IpcChannels } from '../constants'
let ipcRenderer = null let ipcRenderer = null
@ -101,6 +102,22 @@ export default Vue.extend({
return this.$store.getters.getDefaultInvidiousInstance return this.$store.getters.getDefaultInvidiousInstance
}, },
baseTheme: function () {
return this.$store.getters.getBaseTheme
},
mainColor: function () {
return this.$store.getters.getMainColor
},
secColor: function () {
return this.$store.getters.getSecColor
},
systemTheme: function () {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
},
externalLinkOpeningPromptNames: function () { externalLinkOpeningPromptNames: function () {
return [ return [
this.$t('Yes'), this.$t('Yes'),
@ -114,6 +131,13 @@ export default Vue.extend({
}, },
watch: { watch: {
windowTitle: 'setWindowTitle', windowTitle: 'setWindowTitle',
baseTheme: 'checkThemeSettings',
mainColor: 'checkThemeSettings',
secColor: 'checkThemeSettings',
$route () { $route () {
// react to route changes... // react to route changes...
// Hide top nav filter panel on page change // Hide top nav filter panel on page change
@ -126,6 +150,8 @@ export default Vue.extend({
}, },
mounted: function () { mounted: function () {
this.grabUserSettings().then(async () => { this.grabUserSettings().then(async () => {
this.checkThemeSettings()
await this.fetchInvidiousInstances({ isDev: this.isDev }) await this.fetchInvidiousInstances({ isDev: this.isDev })
if (this.defaultInvidiousInstance === '') { if (this.defaultInvidiousInstance === '') {
await this.setRandomCurrentInvidiousInstance() await this.setRandomCurrentInvidiousInstance()
@ -142,6 +168,7 @@ export default Vue.extend({
this.activateKeyboardShortcuts() this.activateKeyboardShortcuts()
this.openAllLinksExternally() this.openAllLinksExternally()
this.enableOpenUrl() this.enableOpenUrl()
this.watchSystemTheme()
await this.checkExternalPlayer() await this.checkExternalPlayer()
} }
@ -160,45 +187,27 @@ export default Vue.extend({
}, },
methods: { methods: {
checkThemeSettings: function () { checkThemeSettings: function () {
let baseTheme = localStorage.getItem('baseTheme')
let mainColor = localStorage.getItem('mainColor')
let secColor = localStorage.getItem('secColor')
if (baseTheme === null) {
baseTheme = 'dark'
}
if (mainColor === null) {
mainColor = 'mainRed'
}
if (secColor === null) {
secColor = 'secBlue'
}
const theme = { const theme = {
baseTheme: baseTheme, baseTheme: this.baseTheme || 'dark',
mainColor: mainColor, mainColor: this.mainColor || 'mainRed',
secColor: secColor secColor: this.secColor || 'secBlue'
} }
this.updateTheme(theme) this.updateTheme(theme)
}, },
updateTheme: function (theme) { updateTheme: function (theme) {
console.log(theme) console.group('updateTheme')
const className = `${theme.baseTheme} ${theme.mainColor} ${theme.secColor}` console.log('Theme: ', theme)
const body = document.getElementsByTagName('body')[0] document.body.className = `${theme.baseTheme} main${theme.mainColor} sec${theme.secColor}`
body.className = className document.body.dataset.systemTheme = this.systemTheme
localStorage.setItem('baseTheme', theme.baseTheme) console.groupEnd()
localStorage.setItem('mainColor', theme.mainColor)
localStorage.setItem('secColor', theme.secColor)
}, },
checkForNewUpdates: function () { checkForNewUpdates: function () {
if (this.checkForUpdates) { if (this.checkForUpdates) {
const { version } = require('../../package.json') const { version } = require('../../package.json')
const requestUrl = 'https://api.github.com/repos/freetubeapp/freetube/releases' const requestUrl = 'https://api.github.com/repos/freetubeapp/freetube/releases?per_page=1'
$.getJSON(requestUrl, (response) => { $.getJSON(requestUrl, (response) => {
const tagName = response[0].tag_name const tagName = response[0].tag_name
@ -301,12 +310,21 @@ export default Vue.extend({
case 'ArrowLeft': case 'ArrowLeft':
this.$refs.topNav.historyBack() this.$refs.topNav.historyBack()
break break
case 'KeyD':
this.$refs.topNav.focusSearch()
break
} }
} }
switch (event.code) { switch (event.code) {
case 'Tab': case 'Tab':
this.hideOutlines = false this.hideOutlines = false
break break
case 'KeyL':
if ((process.platform !== 'darwin' && event.ctrlKey) ||
(process.platform === 'darwin' && event.metaKey)) {
this.$refs.topNav.focusSearch()
}
break
} }
}, },
@ -316,14 +334,17 @@ export default Vue.extend({
}) })
$(document).on('auxclick', 'a[href^="http"]', (event) => { $(document).on('auxclick', 'a[href^="http"]', (event) => {
this.handleLinkClick(event) // auxclick fires for all clicks not performed with the primary button
// only handle the link click if it was the middle button,
// otherwise the context menu breaks
if (event.button === 1) {
this.handleLinkClick(event)
}
}) })
}, },
handleLinkClick: function (event) { handleLinkClick: function (event) {
const el = event.currentTarget const el = event.currentTarget
console.log(this.usingElectron)
console.log(el)
event.preventDefault() event.preventDefault()
// Check if it's a YouTube link // Check if it's a YouTube link
@ -331,7 +352,11 @@ export default Vue.extend({
const isYoutubeLink = youtubeUrlPattern.test(el.href) const isYoutubeLink = youtubeUrlPattern.test(el.href)
if (isYoutubeLink) { if (isYoutubeLink) {
this.handleYoutubeLink(el.href) // `auxclick` is the event type for non-left click
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event
this.handleYoutubeLink(el.href, {
doCreateNewWindow: event.type === 'auxclick'
})
} else if (this.externalLinkHandling === 'doNothing') { } else if (this.externalLinkHandling === 'doNothing') {
// Let user know opening external link is disabled via setting // Let user know opening external link is disabled via setting
this.showToast({ this.showToast({
@ -348,7 +373,7 @@ export default Vue.extend({
} }
}, },
handleYoutubeLink: function (href) { handleYoutubeLink: function (href, { doCreateNewWindow = false } = { }) {
this.getYoutubeUrlInfo(href).then((result) => { this.getYoutubeUrlInfo(href).then((result) => {
switch (result.urlType) { switch (result.urlType) {
case 'video': { case 'video': {
@ -361,9 +386,11 @@ export default Vue.extend({
if (playlistId && playlistId.length > 0) { if (playlistId && playlistId.length > 0) {
query.playlistId = playlistId query.playlistId = playlistId
} }
this.$router.push({ const path = `/watch/${videoId}`
path: `/watch/${videoId}`, this.openInternalPath({
query: query path,
query,
doCreateNewWindow
}) })
break break
} }
@ -371,9 +398,11 @@ export default Vue.extend({
case 'playlist': { case 'playlist': {
const { playlistId, query } = result const { playlistId, query } = result
this.$router.push({ const path = `/playlist/${playlistId}`
path: `/playlist/${playlistId}`, this.openInternalPath({
query path,
query,
doCreateNewWindow
}) })
break break
} }
@ -381,9 +410,11 @@ export default Vue.extend({
case 'search': { case 'search': {
const { searchQuery, query } = result const { searchQuery, query } = result
this.$router.push({ const path = `/search/${encodeURIComponent(searchQuery)}`
path: `/search/${encodeURIComponent(searchQuery)}`, this.openInternalPath({
query path,
query,
doCreateNewWindow
}) })
break break
} }
@ -404,8 +435,10 @@ export default Vue.extend({
case 'channel': { case 'channel': {
const { channelId, subPath } = result const { channelId, subPath } = result
this.$router.push({ const path = `/channel/${channelId}/${subPath}`
path: `/channel/${channelId}/${subPath}` this.openInternalPath({
path,
doCreateNewWindow
}) })
break break
} }
@ -430,6 +463,37 @@ export default Vue.extend({
}) })
}, },
/**
* Linux fix for dynamically updating theme preference, this works on
* all systems running the electron app.
*/
watchSystemTheme: function () {
ipcRenderer.on(IpcChannels.NATIVE_THEME_UPDATE, (event, shouldUseDarkColors) => {
document.body.dataset.systemTheme = shouldUseDarkColors ? 'dark' : 'light'
})
},
openInternalPath: function({ path, doCreateNewWindow, query = {} }) {
if (this.usingElectron && doCreateNewWindow) {
const { ipcRenderer } = require('electron')
// Combine current document path and new "hash" as new window startup URL
const newWindowStartupURL = [
window.location.href.split('#')[0],
`#${path}?${(new URLSearchParams(query)).toString()}`
].join('')
ipcRenderer.send(IpcChannels.CREATE_NEW_WINDOW, {
windowStartupUrl: newWindowStartupURL
})
} else {
// Web
this.$router.push({
path,
query
})
}
},
enableOpenUrl: function () { enableOpenUrl: function () {
ipcRenderer.on('openUrl', (event, url) => { ipcRenderer.on('openUrl', (event, url) => {
if (url) { if (url) {
@ -473,7 +537,10 @@ export default Vue.extend({
'getExternalPlayerCmdArgumentsData', 'getExternalPlayerCmdArgumentsData',
'fetchInvidiousInstances', 'fetchInvidiousInstances',
'setRandomCurrentInvidiousInstance', 'setRandomCurrentInvidiousInstance',
'setupListenersToSyncWindows' 'setupListenersToSyncWindows',
'updateBaseTheme',
'updateMainColor',
'updateSecColor'
]) ])
} }
}) })

View File

@ -1,5 +1,6 @@
<template> <template>
<div <div
v-if="dataReady"
id="app" id="app"
:class="{ :class="{
hideOutlines: hideOutlines, hideOutlines: hideOutlines,
@ -9,36 +10,39 @@
<top-nav ref="topNav" /> <top-nav ref="topNav" />
<side-nav ref="sideNav" /> <side-nav ref="sideNav" />
<ft-flex-box <ft-flex-box
v-if="showUpdatesBanner || showBlogBanner"
class="flexBox routerView" class="flexBox routerView"
:class="{ expand: !isOpen }"
> >
<ft-notification-banner <div
v-if="showUpdatesBanner" v-if="showUpdatesBanner || showBlogBanner"
class="banner" class="banner-wrapper"
:message="updateBannerMessage" >
@click="handleUpdateBannerClick" <ft-notification-banner
/> v-if="showUpdatesBanner"
<ft-notification-banner class="banner"
v-if="showBlogBanner" :message="updateBannerMessage"
class="banner" @click="handleUpdateBannerClick"
:message="blogBannerMessage" />
@click="handleNewBlogBannerClick" <ft-notification-banner
/> v-if="showBlogBanner"
</ft-flex-box> class="banner"
<transition :message="blogBannerMessage"
v-if="dataReady" @click="handleNewBlogBannerClick"
mode="out-in" />
name="fade" </div>
> <transition
<!-- <keep-alive> --> v-if="dataReady"
<RouterView mode="out-in"
ref="router" name="fade"
class="routerView" >
:class="{ expand: !isOpen }" <!-- <keep-alive> -->
/> <RouterView
ref="router"
class="routerView"
/>
<!-- </keep-alive> --> <!-- </keep-alive> -->
</transition> </transition>
</ft-flex-box>
<ft-prompt <ft-prompt
v-if="showReleaseNotes" v-if="showReleaseNotes"
@click="showReleaseNotes = !showReleaseNotes" @click="showReleaseNotes = !showReleaseNotes"

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="26" height="26" viewBox="0 0 24 24"
stroke-width="1.5"
stroke="#ffffff"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
<circle cx="12" cy="13" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -248,7 +248,9 @@ export default Vue.extend({
return return
} }
const textDecode = new TextDecoder('utf-8').decode(data) const textDecode = new TextDecoder('utf-8').decode(data)
const youtubeSubscriptions = textDecode.split('\n') const youtubeSubscriptions = textDecode.split('\n').filter(sub => {
return sub !== ''
})
const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0]))
const subscriptions = [] const subscriptions = []
@ -681,7 +683,7 @@ export default Vue.extend({
] ]
} }
const response = await this.showSaveDialog(options) const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') { if (response.canceled || response.filePath === '') {
// User canceled the save dialog // User canceled the save dialog
return return
@ -764,7 +766,7 @@ export default Vue.extend({
return object return object
}) })
const response = await this.showSaveDialog(options) const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') { if (response.canceled || response.filePath === '') {
// User canceled the save dialog // User canceled the save dialog
return return
@ -816,7 +818,7 @@ export default Vue.extend({
} }
}) })
const response = await this.showSaveDialog(options) const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') { if (response.canceled || response.filePath === '') {
// User canceled the save dialog // User canceled the save dialog
return return
@ -855,10 +857,14 @@ export default Vue.extend({
let exportText = 'Channel ID,Channel URL,Channel title\n' let exportText = 'Channel ID,Channel URL,Channel title\n'
this.profileList[0].subscriptions.forEach((channel) => { this.profileList[0].subscriptions.forEach((channel) => {
const channelUrl = `https://www.youtube.com/channel/${channel.id}` const channelUrl = `https://www.youtube.com/channel/${channel.id}`
exportText += `${channel.id},${channelUrl},${channel.name}\n` let channelName = channel.name
if (channelName.search(',') !== -1) { // add quotations if channel has comma in name
channelName = `"${channelName}"`
}
exportText += `${channel.id},${channelUrl},${channelName}\n`
}) })
exportText += '\n' exportText += '\n'
const response = await this.showSaveDialog(options) const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') { if (response.canceled || response.filePath === '') {
// User canceled the save dialog // User canceled the save dialog
return return
@ -911,7 +917,7 @@ export default Vue.extend({
newPipeObject.subscriptions.push(subscription) newPipeObject.subscriptions.push(subscription)
}) })
const response = await this.showSaveDialog(options) const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') { if (response.canceled || response.filePath === '') {
// User canceled the save dialog // User canceled the save dialog
return return
@ -1042,7 +1048,7 @@ export default Vue.extend({
] ]
} }
const response = await this.showSaveDialog(options) const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') { if (response.canceled || response.filePath === '') {
// User canceled the save dialog // User canceled the save dialog
return return
@ -1214,7 +1220,7 @@ export default Vue.extend({
] ]
} }
const response = await this.showSaveDialog(options) const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') { if (response.canceled || response.filePath === '') {
// User canceled the save dialog // User canceled the save dialog
return return
@ -1303,7 +1309,7 @@ export default Vue.extend({
getChannelInfoLocal: function (channelId) { getChannelInfoLocal: function (channelId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ytch.getChannelInfo(channelId, 'latest').then(async (response) => { ytch.getChannelInfo({ channelId: channelId }).then(async (response) => {
resolve(response) resolve(response)
}).catch((err) => { }).catch((err) => {
console.log(err) console.log(err)

View File

@ -1,6 +1,7 @@
import Vue from 'vue' import Vue from 'vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
import FtSelect from '../ft-select/ft-select.vue'
import FtButton from '../ft-button/ft-button.vue' import FtButton from '../ft-button/ft-button.vue'
import FtInput from '../ft-input/ft-input.vue' import FtInput from '../ft-input/ft-input.vue'
import { mapActions } from 'vuex' import { mapActions } from 'vuex'
@ -12,19 +13,36 @@ export default Vue.extend({
components: { components: {
'ft-toggle-switch': FtToggleSwitch, 'ft-toggle-switch': FtToggleSwitch,
'ft-flex-box': FtFlexBox, 'ft-flex-box': FtFlexBox,
'ft-select': FtSelect,
'ft-button': FtButton, 'ft-button': FtButton,
'ft-input': FtInput 'ft-input': FtInput
}, },
data: function () { data: function () {
return { return {
askForDownloadPath: this.$store.getters.getDownloadFolderPath === '' askForDownloadPath: false,
downloadBehaviorValues: [
'download',
'open'
]
} }
}, },
computed: { computed: {
downloadPath: function() { downloadPath: function() {
return this.$store.getters.getDownloadFolderPath return this.$store.getters.getDownloadFolderPath
},
downloadBehaviorNames: function () {
return [
this.$t('Settings.Download Settings.Download in app'),
this.$t('Settings.Download Settings.Open in web browser')
]
},
downloadBehavior: function () {
return this.$store.getters.getDownloadBehavior
} }
}, },
mounted: function () {
this.askForDownloadPath = this.downloadPath === ''
},
methods: { methods: {
handleDownloadingSettingChange: function (value) { handleDownloadingSettingChange: function (value) {
this.askForDownloadPath = value this.askForDownloadPath = value
@ -42,7 +60,8 @@ export default Vue.extend({
this.updateDownloadFolderPath(folder.filePaths[0]) this.updateDownloadFolderPath(folder.filePaths[0])
}, },
...mapActions([ ...mapActions([
'updateDownloadFolderPath' 'updateDownloadFolderPath',
'updateDownloadBehavior'
]) ])
} }

View File

@ -6,7 +6,19 @@
</h3> </h3>
</summary> </summary>
<hr> <hr>
<ft-flex-box class="downloadSettingsFlexBox"> <ft-flex-box>
<ft-select
:placeholder="$t('Settings.Download Settings.Download Behavior')"
:value="downloadBehavior"
:select-names="downloadBehaviorNames"
:select-values="downloadBehaviorValues"
@change="updateDownloadBehavior"
/>
</ft-flex-box>
<ft-flex-box
v-if="downloadBehavior === 'download'"
class="downloadSettingsFlexBox"
>
<ft-toggle-switch <ft-toggle-switch
:label="$t('Settings.Download Settings.Ask Download Path')" :label="$t('Settings.Download Settings.Ask Download Path')"
:default-value="askForDownloadPath" :default-value="askForDownloadPath"
@ -14,7 +26,7 @@
/> />
</ft-flex-box> </ft-flex-box>
<ft-flex-box <ft-flex-box
v-if="!askForDownloadPath" v-if="!askForDownloadPath && downloadBehavior === 'download'"
> >
<ft-input <ft-input
class="folderDisplay" class="folderDisplay"
@ -25,7 +37,7 @@
/> />
</ft-flex-box> </ft-flex-box>
<ft-flex-box <ft-flex-box
v-if="!askForDownloadPath" v-if="!askForDownloadPath && downloadBehavior === 'download'"
> >
<ft-button <ft-button
:label="$t('Settings.Download Settings.Choose Path')" :label="$t('Settings.Download Settings.Choose Path')"

View File

@ -16,11 +16,6 @@ export default Vue.extend({
required: true required: true
} }
}, },
data: function () {
return {
test: 'hello'
}
},
computed: { computed: {
listType: function () { listType: function () {
return this.$store.getters.getListType return this.$store.getters.getListType

View File

@ -44,11 +44,11 @@ export default Vue.extend({
type: String, type: String,
default: 'bottom' default: 'bottom'
}, },
dropdownNames: { dropdownOptions: {
type: Array, // Array of objects with these properties
default: () => { return [] } // - type: ('labelValue'|'divider', default to 'labelValue' for less typing)
}, // - label: String (if type == 'labelValue')
dropdownValues: { // - value: String (if type == 'labelValue')
type: Array, type: Array,
default: () => { return [] } default: () => { return [] }
} }
@ -107,18 +107,18 @@ export default Vue.extend({
}, },
handleIconClick: function () { handleIconClick: function () {
if (this.forceDropdown || (this.dropdownNames.length > 0 && this.dropdownValues.length > 0)) { if (this.forceDropdown || (this.dropdownOptions.length > 0)) {
this.toggleDropdown() this.toggleDropdown()
} else { } else {
this.$emit('click') this.$emit('click')
} }
}, },
handleDropdownClick: function (index) { handleDropdownClick: function ({ url, index }) {
if (this.returnIndex) { if (this.returnIndex) {
this.$emit('click', index) this.$emit('click', index)
} else { } else {
this.$emit('click', this.dropdownValues[index]) this.$emit('click', url)
} }
this.focusOut() this.focusOut()

View File

@ -83,7 +83,7 @@
list-style-type: none list-style-type: none
.listItem .listItem
padding: 10px padding: 8px 10px
margin: 0 margin: 0
white-space: nowrap white-space: nowrap
cursor: pointer cursor: pointer
@ -96,3 +96,11 @@
&:active &:active
background-color: var(--side-nav-active-color) background-color: var(--side-nav-active-color)
transition: background 0.1s ease-in transition: background 0.1s ease-in
.listItemDivider
width: 95%
margin: 1px auto
border-top: 1px solid var(--tertiary-text-color)
// Too "visible" with current color
opacity: 50%

View File

@ -28,16 +28,16 @@
> >
<slot> <slot>
<ul <ul
v-if="dropdownNames.length > 0" v-if="dropdownOptions.length > 0"
class="list" class="list"
> >
<li <li
v-for="(label, index) in dropdownNames" v-for="(option, index) in dropdownOptions"
:key="index" :key="index"
class="listItem" :class="option.type === 'divider' ? 'listItemDivider' : 'listItem'"
@click="handleDropdownClick(index)" @click="handleDropdownClick({url: option.value, index: index})"
> >
{{ label }} {{ option.type === 'divider' ? '' : option.label }}
</li> </li>
</ul> </ul>
</slot> </slot>

View File

@ -2,6 +2,35 @@
position: relative; position: relative;
} }
.ft-input-component.search.showClearTextButton {
padding-left: 30px;
}
.ft-input-component.search.clearTextButtonVisible,
.ft-input-component.search.showClearTextButton:focus-within {
padding-left: 0;
}
.ft-input-component.showClearTextButton:not(.search) .ft-input {
padding-left: 46px;
}
/* Main search input */
.clearTextButtonVisible.search .ft-input,
.ft-input-component.search.showClearTextButton:focus-within .ft-input {
padding-left: 46px;
}
.ft-input-component:focus-within .clearInputTextButton {
opacity: 0.5;
}
.clearTextButtonVisible .clearInputTextButton.visible,
.ft-input-component:focus-within .clearInputTextButton.visible {
cursor: pointer;
opacity: 1;
}
.disabled label, .disabled .ft-input{ .disabled label, .disabled .ft-input{
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
@ -9,33 +38,27 @@
.clearInputTextButton { .clearInputTextButton {
position: absolute; position: absolute;
/* horizontal intentionally reduced to keep "I-beam pointer" visible */ margin: 0 3px;
padding: 10px 8px; padding: 10px;
top: 5px; top: 5px;
left: 0; left: 0;
cursor: pointer; border-radius: 100%;
border-radius: 200px 200px 200px 200px;
color: var(--primary-text-color); color: var(--primary-text-color);
opacity: 0; opacity: 0;
-moz-transition: background 0.2s ease-in;
-moz-transition: background 0.2s ease-in, opacity 0.2s ease-in; -o-transition: background 0.2s ease-in;
-o-transition: background 0.2s ease-in, opacity 0.2s ease-in; transition: background 0.2s ease-in;
transition: background 0.2s ease-in, opacity 0.2s ease-in;
} }
.clearInputTextButton:hover { .clearInputTextButton.visible:hover {
background-color: var(--side-nav-hover-color); background-color: var(--side-nav-hover-color);
} }
.clearInputTextButton.visible {
opacity: 1;
}
.forceTextColor .clearInputTextButton:hover { .forceTextColor .clearInputTextButton:hover {
background-color: var(--primary-color-hover); background-color: var(--primary-color-hover);
} }
.clearInputTextButton:active { .clearInputTextButton.visible:active {
background-color: var(--tertiary-text-color); background-color: var(--tertiary-text-color);
-moz-transition: background 0.2s ease-in; -moz-transition: background 0.2s ease-in;
-o-transition: background 0.2s ease-in; -o-transition: background 0.2s ease-in;
@ -55,20 +78,20 @@
} }
.ft-input { .ft-input {
box-sizing: border-box; box-sizing: border-box;
-webkit-box-sizing: border-box; -webkit-box-sizing: border-box;
-moz-box-sizing: border-box; -moz-box-sizing: border-box;
outline: none; outline: none;
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
border: none; border: none;
background: transparent; background: transparent;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 16px; font-size: 16px;
height: 45px; height: 45px;
color: var(--secondary-text-color); color: var(--secondary-text-color);
border-radius: 5px; border-radius: 5px;
background-color: var(--search-bar-color); background-color: var(--search-bar-color);
} }
.ft-input-component ::-webkit-input-placeholder { .ft-input-component ::-webkit-input-placeholder {
@ -93,10 +116,11 @@
.inputAction { .inputAction {
position: absolute; position: absolute;
padding: 10px 8px; margin: 0 3px;
padding: 10px;
top: 5px; top: 5px;
right: 0; right: 0;
border-radius: 200px 200px 200px 200px; border-radius: 100%;
color: var(--primary-text-color); color: var(--primary-text-color);
/* this should look disabled by default */ /* this should look disabled by default */
opacity: 50%; opacity: 50%;
@ -125,7 +149,7 @@
With arrow present means With arrow present means
the text might get under the arrow with normal padding the text might get under the arrow with normal padding
*/ */
padding-right: 2em; padding-right: calc(36px + 6px);
} }
.inputAction.enabled:hover { .inputAction.enabled:hover {
@ -173,7 +197,3 @@
background-color: var(--scrollbar-color-hover); background-color: var(--scrollbar-color-hover);
/* color: white; */ /* color: white; */
} }
.showClearTextButton .ft-input {
padding-left: 2em;
}

View File

@ -87,28 +87,6 @@ export default Vue.extend({
return this.inputData.length > 0 return this.inputData.length > 0
} }
}, },
watch: {
inputDataPresent: function (newVal, oldVal) {
if (newVal) {
// The button needs to be visible **immediately**
// To allow user to see the transition
this.clearTextButtonExisting = true
// The transition is not rendered if this property is set right after
// It's visible
setTimeout(() => {
this.clearTextButtonVisible = true
}, 0)
} else {
// Hide the button with transition
this.clearTextButtonVisible = false
// Remove the button after the transition
// 0.2s in CSS = 200ms in JS
setTimeout(() => {
this.clearTextButtonExisting = false
}, 200)
}
}
},
mounted: function () { mounted: function () {
this.id = this._uid this.id = this._uid
this.inputData = this.value this.inputData = this.value
@ -136,14 +114,20 @@ export default Vue.extend({
}, },
handleClearTextClick: function () { handleClearTextClick: function () {
// No action if no input text
if (!this.inputDataPresent) { return }
this.inputData = '' this.inputData = ''
this.handleActionIconChange() this.handleActionIconChange()
this.updateVisibleDataList() this.updateVisibleDataList()
this.$emit('input', this.inputData)
const inputElement = document.getElementById(this.id)
inputElement.value = ''
// Focus on input element after text is clear for better UX // Focus on input element after text is clear for better UX
const inputElement = document.getElementById(this.id)
inputElement.focus() inputElement.focus()
this.$emit('clear')
}, },
handleActionIconChange: function() { handleActionIconChange: function() {
@ -200,7 +184,7 @@ export default Vue.extend({
if (inputElement !== null) { if (inputElement !== null) {
inputElement.addEventListener('keydown', (event) => { inputElement.addEventListener('keydown', (event) => {
if (event.keyCode === 13) { if (event.key === 'Enter') {
this.handleClick() this.handleClick()
} }
}) })
@ -214,14 +198,14 @@ export default Vue.extend({
this.handleClick() this.handleClick()
}, },
handleKeyDown: function (keyCode) { handleKeyDown: function (event) {
if (this.dataList.length === 0) { return } if (this.visibleDataList.length === 0) { return }
// Update selectedOption based on arrow key pressed // Update selectedOption based on arrow key pressed
if (keyCode === 40) { if (event.key === 'ArrowDown') {
this.searchState.selectedOption = (this.searchState.selectedOption + 1) % this.dataList.length this.searchState.selectedOption = (this.searchState.selectedOption + 1) % this.visibleDataList.length
} else if (keyCode === 38) { } else if (event.key === 'ArrowUp') {
if (this.searchState.selectedOption === -1) { if (this.searchState.selectedOption < 1) {
this.searchState.selectedOption = this.dataList.length - 1 this.searchState.selectedOption = this.visibleDataList.length - 1
} else { } else {
this.searchState.selectedOption-- this.searchState.selectedOption--
} }
@ -230,14 +214,13 @@ export default Vue.extend({
} }
// Key pressed isn't enter // Key pressed isn't enter
if (keyCode !== 13) { if (event.key !== 'Enter') {
this.searchState.showOptions = true this.searchState.showOptions = true
} }
// Update Input box value if arrow keys were pressed // Update Input box value if arrow keys were pressed
if ((keyCode === 40 || keyCode === 38) && this.searchState.selectedOption !== -1) { if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && this.searchState.selectedOption !== -1) {
event.preventDefault()
this.inputData = this.visibleDataList[this.searchState.selectedOption] this.inputData = this.visibleDataList[this.searchState.selectedOption]
} else {
this.updateVisibleDataList()
} }
}, },

View File

@ -6,6 +6,7 @@
forceTextColor: forceTextColor, forceTextColor: forceTextColor,
showActionButton: showActionButton, showActionButton: showActionButton,
showClearTextButton: showClearTextButton, showClearTextButton: showClearTextButton,
clearTextButtonVisible: inputDataPresent,
disabled: disabled disabled: disabled
}" }"
> >
@ -22,11 +23,11 @@
/> />
</label> </label>
<font-awesome-icon <font-awesome-icon
v-if="showClearTextButton && clearTextButtonExisting" v-if="showClearTextButton"
icon="times-circle" icon="times-circle"
class="clearInputTextButton" class="clearInputTextButton"
:class="{ :class="{
visible: clearTextButtonVisible visible: inputDataPresent
}" }"
tabindex="0" tabindex="0"
role="button" role="button"
@ -37,6 +38,7 @@
/> />
<input <input
:id="id" :id="id"
ref="input"
v-model="inputData" v-model="inputData"
:list="idDataList" :list="idDataList"
class="ft-input" class="ft-input"
@ -47,7 +49,7 @@
@input="e => handleInput(e.target.value)" @input="e => handleInput(e.target.value)"
@focus="handleFocus" @focus="handleFocus"
@blur="handleInputBlur" @blur="handleInputBlur"
@keydown="e => handleKeyDown(e.keyCode)" @keydown="handleKeyDown"
> >
<font-awesome-icon <font-awesome-icon
v-if="showActionButton" v-if="showActionButton"

View File

@ -52,8 +52,8 @@
padding-left: 15px; padding-left: 15px;
padding-right: 15px; padding-right: 15px;
cursor: pointer; cursor: pointer;
color: var(--tertiary-text-color); color: var(--secondary-text-color);
background-color: var(--secondary-card-bg-color); background-color: var(--side-nav-color);
-webkit-transition: background 0.2s ease-out; -webkit-transition: background 0.2s ease-out;
-moz-transition: background 0.2s ease-out; -moz-transition: background 0.2s ease-out;
-o-transition: background 0.2s ease-out; -o-transition: background 0.2s ease-out;
@ -61,7 +61,7 @@
} }
.buttonOption:hover { .buttonOption:hover {
background-color: var(--search-bar-color); background-color: var(--side-nav-hover-color);
-moz-transition: background 0.2s ease-in; -moz-transition: background 0.2s ease-in;
-o-transition: background 0.2s ease-in; -o-transition: background 0.2s ease-in;
transition: background 0.2s ease-in; transition: background 0.2s ease-in;

View File

@ -59,20 +59,7 @@ export default Vue.extend({
isFavorited: false, isFavorited: false,
isUpcoming: false, isUpcoming: false,
isPremium: false, isPremium: false,
hideViews: false, hideViews: false
optionsValues: [
'history',
'openYoutube',
'copyYoutube',
'openYoutubeEmbed',
'copyYoutubeEmbed',
'openInvidious',
'copyInvidious',
'openYoutubeChannel',
'copyYoutubeChannel',
'openInvidiousChannel',
'copyInvidiousChannel'
]
} }
}, },
computed: { computed: {
@ -130,27 +117,71 @@ export default Vue.extend({
return (this.watchProgress / this.data.lengthSeconds) * 100 return (this.watchProgress / this.data.lengthSeconds) * 100
}, },
optionsNames: function () { dropdownOptions: function () {
const names = [ const options = []
this.$t('Video.Open in YouTube'),
this.$t('Video.Copy YouTube Link'),
this.$t('Video.Open YouTube Embedded Player'),
this.$t('Video.Copy YouTube Embedded Player Link'),
this.$t('Video.Open in Invidious'),
this.$t('Video.Copy Invidious Link'),
this.$t('Video.Open Channel in YouTube'),
this.$t('Video.Copy YouTube Channel Link'),
this.$t('Video.Open Channel in Invidious'),
this.$t('Video.Copy Invidious Channel Link')
]
if (this.watched) { options.push(
names.unshift(this.$t('Video.Remove From History')) {
} else { label: this.watched
names.unshift(this.$t('Video.Mark As Watched')) ? this.$t('Video.Remove From History')
} : this.$t('Video.Mark As Watched'),
value: 'history'
},
{
type: 'divider'
},
{
label: this.$t('Video.Copy YouTube Link'),
value: 'copyYoutube'
},
{
label: this.$t('Video.Copy YouTube Embedded Player Link'),
value: 'copyYoutubeEmbed'
},
{
label: this.$t('Video.Copy Invidious Link'),
value: 'copyInvidious'
},
{
type: 'divider'
},
{
label: this.$t('Video.Open in YouTube'),
value: 'openYoutube'
},
{
label: this.$t('Video.Open YouTube Embedded Player'),
value: 'openYoutubeEmbed'
},
{
label: this.$t('Video.Open in Invidious'),
value: 'openInvidious'
},
{
type: 'divider'
},
{
label: this.$t('Video.Copy YouTube Channel Link'),
value: 'copyYoutubeChannel'
},
{
label: this.$t('Video.Copy Invidious Channel Link'),
value: 'copyInvidiousChannel'
},
{
type: 'divider'
},
{
label: this.$t('Video.Open Channel in YouTube'),
value: 'openYoutubeChannel'
},
{
label: this.$t('Video.Open Channel in Invidious'),
value: 'openInvidiousChannel'
}
)
return names return options
}, },
thumbnail: function () { thumbnail: function () {
@ -208,12 +239,6 @@ export default Vue.extend({
return this.$store.getters.getSaveWatchedProgress return this.$store.getters.getSaveWatchedProgress
} }
}, },
watch: {
data: function () {
this.parseVideoData()
this.checkIfWatched()
}
},
mounted: function () { mounted: function () {
this.parseVideoData() this.parseVideoData()
this.checkIfWatched() this.checkIfWatched()
@ -227,6 +252,7 @@ export default Vue.extend({
watchProgress: this.watchProgress, watchProgress: this.watchProgress,
playbackRate: this.defaultPlayback, playbackRate: this.defaultPlayback,
videoId: this.id, videoId: this.id,
videoLength: this.data.lengthSeconds,
playlistId: this.playlistId, playlistId: this.playlistId,
playlistIndex: this.playlistIndex, playlistIndex: this.playlistIndex,
playlistReverse: this.playlistReverse, playlistReverse: this.playlistReverse,
@ -445,6 +471,7 @@ export default Vue.extend({
}) })
this.watched = false this.watched = false
this.watchProgress = 0
}, },
addToPlaylist: function () { addToPlaylist: function () {

View File

@ -1 +1,4 @@
@use "../../sass-partials/_ft-list-item" @use "../../sass-partials/_ft-list-item"
.thumbnailLink:hover
outline: 3px solid var(--side-nav-hover-color)

View File

@ -66,13 +66,13 @@
<div class="info"> <div class="info">
<ft-icon-button <ft-icon-button
class="optionsButton" class="optionsButton"
icon="ellipsis-v"
title="More Options" title="More Options"
theme="base-no-default" theme="base-no-default"
:size="16" :size="16"
:use-shadow="false" :use-shadow="false"
dropdown-position-x="left" dropdown-position-x="left"
:dropdown-names="optionsNames" :dropdown-options="dropdownOptions"
:dropdown-values="optionsValues"
@click="handleOptionsClick" @click="handleOptionsClick"
/> />
<router-link <router-link

View File

@ -1,17 +1,22 @@
.colorOption { .colorOption {
width: 40px; width: 40px;
height: 40px; height: 40px;
margin: 10px;
cursor: pointer; cursor: pointer;
align-items: center;
display: flex;
justify-content: center;
border-radius: 50%; border-radius: 50%;
-webkit-border-radius: 50%; -webkit-border-radius: 50%;
} }
.colorOption:hover {
box-shadow: 0 0 0 2px var(--side-nav-hover-color);
}
.initial { .initial {
font-size: 20px; font-size: 20px;
line-height: 1em; line-height: 1em;
text-align: center; text-align: center;
padding: 10px 0;
} }
#profileList { #profileList {
@ -57,6 +62,7 @@
float: left; float: left;
position: relative; position: relative;
bottom: 5px; bottom: 5px;
margin: 10px;
} }
.profileName { .profileName {

View File

@ -29,10 +29,15 @@
width: auto width: auto
@at-root @at-root
.dark & .dark &, .system[data-system-theme*='dark'] &
filter: brightness(0.868) filter: brightness(0.868)
.light & .black &
filter: brightness(0.933)
/* no changes for the dracula theme */
.light &, .system[data-system-theme*='light'] &
filter: invert(0.87) filter: invert(0.87)
.invidious .invidious
@ -48,8 +53,11 @@
margin-right: 2px margin-right: 2px
@at-root @at-root
.dark & .dark &,
.black &,
.dracula &,
.system[data-system-theme*='dark'] &
background-image: url(~../../assets/img/invidious-logo-dark.svg) background-image: url(~../../assets/img/invidious-logo-dark.svg)
.light & .light &, .system[data-system-theme*='light'] &
background-image: url(~../../assets/img/invidious-logo-light.svg) background-image: url(~../../assets/img/invidious-logo-light.svg)

View File

@ -50,7 +50,7 @@
} }
.pure-material-slider > input:disabled + span { .pure-material-slider > input:disabled + span {
color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38); opacity: 0.38;
} }
/* Webkit | Track */ /* Webkit | Track */

View File

@ -26,6 +26,10 @@ export default Vue.extend({
valueExtension: { valueExtension: {
type: String, type: String,
default: null default: null
},
disabled: {
type: Boolean,
default: false
} }
}, },
data: function () { data: function () {

View File

@ -5,11 +5,12 @@
<input <input
:id="id" :id="id"
v-model.number="currentValue" v-model.number="currentValue"
:disabled="disabled"
type="range" type="range"
:min="minValue" :min="minValue"
:max="maxValue" :max="maxValue"
:step="step" :step="step"
@change="$emit('change', $event.target.value)" @change="$emit('change', currentValue)"
> >
<span> <span>
{{ label }}: {{ label }}:

View File

@ -0,0 +1,142 @@
import Vue from 'vue'
import { mapActions } from 'vuex'
import FtSelect from '../ft-select/ft-select.vue'
export default Vue.extend({
name: 'FtSponsorBlockCategory',
components: {
'ft-select': FtSelect
},
props: {
categoryName: {
type: String,
required: true
}
},
data: function () {
return {
categoryColor: '',
skipOption: '',
skipValues: [
'autoSkip',
// 'promptToSkip',
'showInSeekBar',
'doNothing'
]
}
},
computed: {
colorValues: function () {
return this.$store.getters.getColorNames
},
colorNames: function () {
return this.colorValues.map(colorVal => {
// add spaces before capital letters
const colorName = colorVal.replace(/([A-Z])/g, ' $1').trim()
return this.$t(`Settings.Theme Settings.Main Color Theme.${colorName}`)
})
},
sponsorBlockValues: function() {
let sponsorVal = ''
switch (this.categoryName.toLowerCase()) {
case 'sponsor':
sponsorVal = this.$store.getters.getSponsorBlockSponsor
break
case 'self-promotion':
sponsorVal = this.$store.getters.getSponsorBlockSelfPromo
break
case 'interaction':
sponsorVal = this.$store.getters.getSponsorBlockInteraction
break
case 'intro':
sponsorVal = this.$store.getters.getSponsorBlockIntro
break
case 'outro':
sponsorVal = this.$store.getters.getSponsorBlockOutro
break
case 'recap':
sponsorVal = this.$store.getters.getSponsorBlockRecap
break
case 'music offtopic':
sponsorVal = this.$store.getters.getSponsorBlockMusicOffTopic
break
case 'filler':
sponsorVal = this.$store.getters.getSponsorBlockFiller
break
}
return sponsorVal
},
skipNames: function() {
return [
this.$t('Settings.SponsorBlock Settings.Skip Options.Auto Skip'),
// this.$t('Settings.SponsorBlock Settings.Skip Options.Prompt To Skip'),
this.$t('Settings.SponsorBlock Settings.Skip Options.Show In Seek Bar'),
this.$t('Settings.SponsorBlock Settings.Skip Options.Do Nothing')
]
}
},
methods: {
updateColor: function (color) {
const payload = {
color: color,
skip: this.sponsorBlockValues.skip
}
this.updateSponsorCategory(payload)
},
updateSkipOption: function (skipOption) {
const payload = {
color: this.sponsorBlockValues.color,
skip: skipOption
}
this.updateSponsorCategory(payload)
},
updateSponsorCategory: function (payload) {
switch (this.categoryName.toLowerCase()) {
case 'sponsor':
this.updateSponsorBlockSponsor(payload)
break
case 'self-promotion':
this.updateSponsorBlockSelfPromo(payload)
break
case 'interaction':
this.updateSponsorBlockInteraction(payload)
break
case 'intro':
this.updateSponsorBlockIntro(payload)
break
case 'outro':
this.updateSponsorBlockOutro(payload)
break
case 'recap':
this.updateSponsorBlockRecap(payload)
break
case 'music offtopic':
this.updateSponsorBlockMusicOffTopic(payload)
break
case 'filler':
this.updateSponsorBlockFiller(payload)
break
}
},
...mapActions([
'showToast',
'openExternalLink',
'updateSponsorBlockSponsor',
'updateSponsorBlockSelfPromo',
'updateSponsorBlockInteraction',
'updateSponsorBlockIntro',
'updateSponsorBlockOutro',
'updateSponsorBlockRecap',
'updateSponsorBlockMusicOffTopic',
'updateSponsorBlockFiller'
])
}
})

View File

@ -0,0 +1,6 @@
@use "../../sass-partials/settings"
.sponsorBlockCategory
margin-top: 30px
padding: 0 10px
.sponsorTitle
font-size: x-large

View File

@ -0,0 +1,23 @@
<template>
<div class="sponsorBlockCategory">
<div class="sponsorTitle">
{{ $t("Video.Sponsor Block category."+categoryName) }}
</div>
<ft-select
:placeholder="$t('Settings.SponsorBlock Settings.Category Color')"
:value="sponsorBlockValues.color"
:select-names="colorNames"
:select-values="colorValues"
@change="updateColor"
/>
<ft-select
:placeholder="$t('Settings.SponsorBlock Settings.Skip Options.Skip Option')"
:value="sponsorBlockValues.skip"
:select-names="skipNames"
:select-values="skipValues"
@change="updateSkipOption"
/>
</div>
</template>
<script src="./ft-sponsor-block-category.js" />
<style scoped lang="sass" src="./ft-sponsor-block-category.sass" />

View File

@ -6,12 +6,12 @@ import $ from 'jquery'
import videojs from 'video.js' import videojs from 'video.js'
import qualitySelector from '@silvermine/videojs-quality-selector' import qualitySelector from '@silvermine/videojs-quality-selector'
import fs from 'fs' import fs from 'fs'
import path from 'path'
import 'videojs-overlay/dist/videojs-overlay' import 'videojs-overlay/dist/videojs-overlay'
import 'videojs-overlay/dist/videojs-overlay.css' import 'videojs-overlay/dist/videojs-overlay.css'
import 'videojs-vtt-thumbnails-freetube' import 'videojs-vtt-thumbnails-freetube'
import 'videojs-contrib-quality-levels' import 'videojs-contrib-quality-levels'
import 'videojs-http-source-selector' import 'videojs-http-source-selector'
import { ipcRenderer } from 'electron'
import { IpcChannels } from '../../../constants' import { IpcChannels } from '../../../constants'
@ -85,6 +85,10 @@ export default Vue.extend({
useHls: false, useHls: false,
selectedDefaultQuality: '', selectedDefaultQuality: '',
selectedQuality: '', selectedQuality: '',
selectedResolution: '',
selectedBitrate: '',
selectedMimeType: '',
selectedFPS: 0,
using60Fps: false, using60Fps: false,
maxFramerate: 0, maxFramerate: 0,
activeSourceList: [], activeSourceList: [],
@ -92,6 +96,10 @@ export default Vue.extend({
mouseTimeout: null, mouseTimeout: null,
touchTimeout: null, touchTimeout: null,
lastTouchTime: null, lastTouchTime: null,
playerStats: null,
statsModal: null,
showStatsModal: false,
statsModalEventName: 'updateStats',
dataSetup: { dataSetup: {
fluid: true, fluid: true,
nativeTextTracks: false, nativeTextTracks: false,
@ -108,6 +116,7 @@ export default Vue.extend({
'seekToLive', 'seekToLive',
'remainingTimeDisplay', 'remainingTimeDisplay',
'customControlSpacer', 'customControlSpacer',
'screenshotButton',
'playbackRateMenuButton', 'playbackRateMenuButton',
'loopButton', 'loopButton',
'chaptersButton', 'chaptersButton',
@ -119,56 +128,6 @@ export default Vue.extend({
'qualitySelector', 'qualitySelector',
'fullscreenToggle' 'fullscreenToggle'
] ]
},
playbackRates: [
0.25,
0.5,
0.75,
1,
1.25,
1.5,
1.75,
2,
2.25,
2.5,
2.75,
3,
3.25,
3.5,
3.75,
4,
4.25,
4.5,
4.75,
5,
5.25,
5.5,
5.75,
6,
6.25,
6.5,
6.75,
7,
7.25,
7.5,
7.75,
8
]
},
stats: {
videoId: '',
playerResolution: null,
frameInfo: null,
volume: 0,
bandwidth: null,
bufferPercent: 0,
fps: null,
display: {
modal: null,
event: 'statsUpdated',
keyboardShortcut: 'KeyI',
rightClickEvent: 'showVideoStatistics',
activated: false
} }
} }
} }
@ -230,35 +189,111 @@ export default Vue.extend({
displayVideoPlayButton: function() { displayVideoPlayButton: function() {
return this.$store.getters.getDisplayVideoPlayButton return this.$store.getters.getDisplayVideoPlayButton
}, },
formatted_stats: function() {
let resolution = ''
let dropFrame = ''
if (this.stats.playerResolution != null) {
resolution = `(${this.stats.playerResolution.height}X${this.stats.playerResolution.width}) @ ${this.stats.fps} ${this.$t('Video.Stats.fps')}`
}
if (this.stats.frameInfo != null) {
dropFrame = `${this.stats.frameInfo.droppedVideoFrames} ${this.$t('Video.Stats.out of')} ${this.stats.frameInfo.totalVideoFrames}`
}
const stats = [
[this.$t('Video.Stats.video id'), this.stats.videoId],
[this.$t('Video.Stats.frame drop'), dropFrame],
[this.$t('Video.Stats.player resolution'), resolution],
[this.$t('Video.Stats.volume'), `${(this.stats.volume * 100).toFixed(0)} %`],
[this.$t('Video.Stats.bandwidth'), `${(this.stats.bandwidth / 1000).toFixed(2)} Kbps`],
[this.$t('Video.Stats.buffered'), `${(this.stats.bufferPercent * 100).toFixed(0)} %`]
]
let formattedStats = '<ul style="list-style-type: none;text-align:left; padding-left:0px";>' sponsorSkips: function () {
for (const stat of stats) { const sponsorCats = ['sponsor',
formattedStats += `<li style="font-size: 75%">${stat[0]}: ${stat[1]}</li>` 'selfpromo',
'interaction',
'intro',
'outro',
'preview',
'music_offtopic',
'filler'
]
const autoSkip = {}
const seekBar = []
const promptSkip = {}
const categoryData = {}
sponsorCats.forEach(x => {
let sponsorVal = {}
switch (x) {
case 'sponsor':
sponsorVal = this.$store.getters.getSponsorBlockSponsor
break
case 'selfpromo':
sponsorVal = this.$store.getters.getSponsorBlockSelfPromo
break
case 'interaction':
sponsorVal = this.$store.getters.getSponsorBlockInteraction
break
case 'intro':
sponsorVal = this.$store.getters.getSponsorBlockIntro
break
case 'outro':
sponsorVal = this.$store.getters.getSponsorBlockOutro
break
case 'preview':
sponsorVal = this.$store.getters.getSponsorBlockRecap
break
case 'music_offtopic':
sponsorVal = this.$store.getters.getSponsorBlockMusicOffTopic
break
case 'filler':
sponsorVal = this.$store.getters.getSponsorBlockFiller
break
}
if (sponsorVal.skip !== 'doNothing') {
seekBar.push(x)
}
if (sponsorVal.skip === 'autoSkip') {
autoSkip[x] = true
}
if (sponsorVal.skip === 'promptToSkip') {
promptSkip[x] = true
}
categoryData[x] = sponsorVal
})
return { autoSkip, seekBar, promptSkip, categoryData }
},
maxVideoPlaybackRate: function () {
return parseInt(this.$store.getters.getMaxVideoPlaybackRate)
},
videoPlaybackRateInterval: function () {
return parseFloat(this.$store.getters.getVideoPlaybackRateInterval)
},
playbackRates: function () {
const playbackRates = []
let i = this.videoPlaybackRateInterval
while (i <= this.maxVideoPlaybackRate) {
playbackRates.push(i)
i = i + this.videoPlaybackRateInterval
i = parseFloat(i.toFixed(2))
} }
formattedStats += '</ul>'
return formattedStats return playbackRates
},
enableScreenshot: function() {
return this.$store.getters.getEnableScreenshot
},
screenshotFormat: function() {
return this.$store.getters.getScreenshotFormat
},
screenshotQuality: function() {
return this.$store.getters.getScreenshotQuality
},
screenshotAskPath: function() {
return this.$store.getters.getScreenshotAskPath
},
screenshotFolder: function() {
return this.$store.getters.getScreenshotFolderPath
} }
}, },
watch: { watch: {
selectedQuality: function() { showStatsModal: function() {
this.currentFps() this.player.trigger(this.statsModalEventName)
},
enableScreenshot: function() {
this.toggleScreenshotButton()
} }
}, },
mounted: function () { mounted: function () {
@ -270,11 +305,19 @@ export default Vue.extend({
this.volume = volume this.volume = volume
} }
this.dataSetup.playbackRates = this.playbackRates
this.createFullWindowButton() this.createFullWindowButton()
this.createLoopButton() this.createLoopButton()
this.createToggleTheatreModeButton() this.createToggleTheatreModeButton()
this.createScreenshotButton()
this.determineFormatType() this.determineFormatType()
this.determineMaxFramerate() this.determineMaxFramerate()
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => this.player.play())
navigator.mediaSession.setActionHandler('pause', () => this.player.pause())
}
}, },
beforeDestroy: function () { beforeDestroy: function () {
if (this.player !== null) { if (this.player !== null) {
@ -287,6 +330,12 @@ export default Vue.extend({
} }
} }
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', null)
navigator.mediaSession.setActionHandler('pause', null)
navigator.mediaSession.playbackState = 'none'
}
if (this.usingElectron && this.powerSaveBlocker !== null) { if (this.usingElectron && this.powerSaveBlocker !== null) {
const { ipcRenderer } = require('electron') const { ipcRenderer } = require('electron')
ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker) ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker)
@ -411,20 +460,34 @@ export default Vue.extend({
this.player.on('ready', () => { this.player.on('ready', () => {
this.$emit('ready') this.$emit('ready')
this.checkAspectRatio() this.checkAspectRatio()
this.createStatsModal()
if (this.captionHybridList.length !== 0) { if (this.captionHybridList.length !== 0) {
this.transformAndInsertCaptions() this.transformAndInsertCaptions()
} }
this.toggleScreenshotButton()
}) })
this.player.on('ended', () => { this.player.on('ended', () => {
this.$emit('ended') this.$emit('ended')
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'
}
}) })
this.player.on('error', (error, message) => { this.player.on('error', (error, message) => {
this.$emit('error', error.target.player.error_) this.$emit('error', error.target.player.error_)
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'
}
}) })
this.player.on('play', async function () { this.player.on('play', async function () {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing'
}
if (this.usingElectron) { if (this.usingElectron) {
const { ipcRenderer } = require('electron') const { ipcRenderer } = require('electron')
this.powerSaveBlocker = this.powerSaveBlocker =
@ -433,6 +496,10 @@ export default Vue.extend({
}) })
this.player.on('pause', function () { this.player.on('pause', function () {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'paused'
}
if (this.usingElectron && this.powerSaveBlocker !== null) { if (this.usingElectron && this.powerSaveBlocker !== null) {
const { ipcRenderer } = require('electron') const { ipcRenderer } = require('electron')
ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker) ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker)
@ -440,18 +507,43 @@ export default Vue.extend({
} }
}) })
this.player.on(this.statsModalEventName, () => {
if (this.showStatsModal) {
this.statsModal.open()
this.player.controls(true)
this.statsModal.contentEl().innerHTML = this.getFormattedStats()
} else {
this.statsModal.close()
}
})
this.player.on('timeupdate', () => {
if (this.format === 'dash') {
this.playerStats = this.player.tech({ IWillNotUseThisInPlugins: true }).vhs.stats
this.updateStatsContent()
}
})
this.player.textTrackSettings.on('modalclose', (_) => { this.player.textTrackSettings.on('modalclose', (_) => {
const settings = this.player.textTrackSettings.getValues() const settings = this.player.textTrackSettings.getValues()
this.updateDefaultCaptionSettings(JSON.stringify(settings)) this.updateDefaultCaptionSettings(JSON.stringify(settings))
}) })
this.addPlayerStatsEvent()
// right click menu
if (this.usingElectron) {
const { ipcRenderer } = require('electron')
ipcRenderer.removeAllListeners('showVideoStatistics')
ipcRenderer.on('showVideoStatistics', (event) => {
this.toggleShowStatsModal()
})
}
} }
}, },
initializeSponsorBlock() { initializeSponsorBlock() {
this.sponsorBlockSkipSegments({ this.sponsorBlockSkipSegments({
videoId: this.videoId, videoId: this.videoId,
categories: ['sponsor'] categories: this.sponsorSkips.seekBar
}).then((skipSegments) => { }).then((skipSegments) => {
if (skipSegments.length === 0) { if (skipSegments.length === 0) {
return return
@ -469,7 +561,8 @@ export default Vue.extend({
this.addSponsorBlockMarker({ this.addSponsorBlockMarker({
time: startTime, time: startTime,
duration: endTime - startTime, duration: endTime - startTime,
color: this.sponsorBlockCategoryColor(category) color: 'var(--primary-color)',
category: category
}) })
}) })
}) })
@ -488,10 +581,12 @@ export default Vue.extend({
} }
}) })
if (newTime !== null && Math.abs(duration - currentTime) > 0.500) { if (newTime !== null && Math.abs(duration - currentTime) > 0.500) {
if (this.sponsorBlockShowSkippedToast) { if (this.sponsorSkips.autoSkip[skippedCategory]) {
this.showSkippedSponsorSegmentInformation(skippedCategory) if (this.sponsorBlockShowSkippedToast) {
this.showSkippedSponsorSegmentInformation(skippedCategory)
}
this.player.currentTime(newTime)
} }
this.player.currentTime(newTime)
} }
}, },
@ -516,42 +611,25 @@ export default Vue.extend({
return this.$t('Video.Sponsor Block category.interaction') return this.$t('Video.Sponsor Block category.interaction')
case 'music_offtopic': case 'music_offtopic':
return this.$t('Video.Sponsor Block category.music offtopic') return this.$t('Video.Sponsor Block category.music offtopic')
case 'filler':
return this.$t('Video.Sponsor Block category.filler')
default: default:
console.error(`Unknown translation for SponsorBlock category ${category}`) console.error(`Unknown translation for SponsorBlock category ${category}`)
return category return category
} }
}, },
sponsorBlockCategoryColor(category) {
// TODO: allow to set these colors in settings
switch (category) {
case 'sponsor':
return 'var(--accent-color)'
case 'intro':
return 'var(--accent-color)'
case 'outro':
return 'var(--accent-color)'
case 'selfpromo':
return 'var(--accent-color)'
case 'interaction':
return 'var(--accent-color)'
case 'music_offtopic':
return 'var(--accent-color)'
default:
console.error(`Unknown SponsorBlock category ${category}`)
return 'var(--accent-color)'
}
},
addSponsorBlockMarker(marker) { addSponsorBlockMarker(marker) {
const markerDiv = videojs.dom.createEl('div', {}, {}) const markerDiv = videojs.dom.createEl('div', {}, {})
markerDiv.className = 'sponsorBlockMarker' markerDiv.className = `sponsorBlockMarker main${this.sponsorSkips.categoryData[marker.category].color}`
markerDiv.style.height = '100%' markerDiv.style.height = '100%'
markerDiv.style.position = 'absolute' markerDiv.style.position = 'absolute'
markerDiv.style.opacity = '0.6'
markerDiv.style['background-color'] = marker.color markerDiv.style['background-color'] = marker.color
markerDiv.style.width = (marker.duration / this.player.duration()) * 100 + '%' markerDiv.style.width = (marker.duration / this.player.duration()) * 100 + '%'
markerDiv.style.marginLeft = (marker.time / this.player.duration()) * 100 + '%' markerDiv.style.marginLeft = (marker.time / this.player.duration()) * 100 + '%'
markerDiv.title = this.sponsorBlockTranslatedCategory(marker.category)
this.player.el().querySelector('.vjs-progress-holder').appendChild(markerDiv) this.player.el().querySelector('.vjs-progress-holder').appendChild(markerDiv)
}, },
@ -851,6 +929,18 @@ export default Vue.extend({
qualityElement.innerText = selectedQuality qualityElement.innerText = selectedQuality
this.selectedQuality = selectedQuality this.selectedQuality = selectedQuality
if (selectedQuality !== 'auto') {
this.selectedResolution = `${adaptiveFormat.width}x${adaptiveFormat.height}`
this.selectedFPS = adaptiveFormat.fps
this.selectedBitrate = adaptiveFormat.bitrate
this.selectedMimeType = adaptiveFormat.mimeType
} else {
this.selectedResolution = 'auto'
this.selectedFPS = 'auto'
this.selectedBitrate = 'auto'
this.selectedMimeType = 'auto'
}
const qualityItems = $('.quality-item').get() const qualityItems = $('.quality-item').get()
$('.quality-item').removeClass('quality-selected') $('.quality-item').removeClass('quality-selected')
@ -999,7 +1089,7 @@ export default Vue.extend({
changePlayBackRate: function (rate) { changePlayBackRate: function (rate) {
const newPlaybackRate = (this.player.playbackRate() + rate).toFixed(2) const newPlaybackRate = (this.player.playbackRate() + rate).toFixed(2)
if (newPlaybackRate >= 0.25 && newPlaybackRate <= 8) { if (newPlaybackRate >= this.videoPlaybackRateInterval && newPlaybackRate <= this.maxVideoPlaybackRate) {
this.player.playbackRate(newPlaybackRate) this.player.playbackRate(newPlaybackRate)
} }
}, },
@ -1012,7 +1102,7 @@ export default Vue.extend({
if (this.maxFramerate === 60 && quality.height >= 480) { if (this.maxFramerate === 60 && quality.height >= 480) {
for (let i = 0; i < this.adaptiveFormats.length; i++) { for (let i = 0; i < this.adaptiveFormats.length; i++) {
if (this.adaptiveFormats[i].bitrate === quality.bitrate) { if (this.adaptiveFormats[i].bitrate === quality.bitrate) {
fps = this.adaptiveFormats[i].fps fps = this.adaptiveFormats[i].fps ? this.adaptiveFormats[i].fps : 30
break break
} }
} }
@ -1077,8 +1167,8 @@ export default Vue.extend({
toggleVideoLoop: async function () { toggleVideoLoop: async function () {
if (!this.player.loop()) { if (!this.player.loop()) {
const currentTheme = localStorage.getItem('mainColor') const currentTheme = this.$store.state.settings.mainColor
const colorNames = this.$store.state.utils.colorClasses const colorNames = this.$store.state.utils.colorNames
const colorValues = this.$store.state.utils.colorValues const colorValues = this.$store.state.utils.colorValues
const nameIndex = colorNames.findIndex((color) => { const nameIndex = colorNames.findIndex((color) => {
@ -1162,6 +1252,168 @@ export default Vue.extend({
this.$parent.toggleTheatreMode() this.$parent.toggleTheatreMode()
}, },
createScreenshotButton: function() {
const VjsButton = videojs.getComponent('Button')
const screenshotButton = videojs.extend(VjsButton, {
constructor: function(player, options) {
VjsButton.call(this, player, options)
},
handleClick: () => {
this.takeScreenshot()
const video = document.getElementsByTagName('video')[0]
video.focus()
video.blur()
},
createControlTextEl: function (button) {
return $(button)
.html('<div id="screenshotButton" class="vjs-icon-screenshot vjs-button vjs-hidden"></div>')
.attr('title', 'Screenshot')
}
})
videojs.registerComponent('screenshotButton', screenshotButton)
},
toggleScreenshotButton: function() {
const button = document.getElementById('screenshotButton')
if (this.enableScreenshot && this.format !== 'audio') {
button.classList.remove('vjs-hidden')
} else {
button.classList.add('vjs-hidden')
}
},
takeScreenshot: async function() {
if (!this.enableScreenshot || this.format === 'audio') {
return
}
const width = this.player.videoWidth()
const height = this.player.videoHeight()
if (width <= 0) {
return
}
// Need to set crossorigin="anonymous" for LegacyFormat on Invidious
// https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
const video = document.querySelector('video')
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(video, 0, 0)
const format = this.screenshotFormat
const mimeType = `image/${format === 'jpg' ? 'jpeg' : format}`
const imageQuality = format === 'jpg' ? this.screenshotQuality / 100 : 1
let filename
try {
filename = await this.parseScreenshotCustomFileName({
date: new Date(Date.now()),
playerTime: this.player.currentTime(),
videoId: this.videoId
})
} catch (err) {
console.error(`Parse failed: ${err.message}`)
this.showToast({
message: this.$t('Screenshot Error').replace('$', err.message)
})
canvas.remove()
return
}
const dirChar = process.platform === 'win32' ? '\\' : '/'
let subDir = ''
if (filename.indexOf(dirChar) !== -1) {
const lastIndex = filename.lastIndexOf(dirChar)
subDir = filename.substring(0, lastIndex)
filename = filename.substring(lastIndex + 1)
}
const filenameWithExtension = `${filename}.${format}`
let dirPath
let filePath
if (this.screenshotAskPath) {
const wasPlaying = !this.player.paused()
if (wasPlaying) {
this.player.pause()
}
if (this.screenshotFolder === '' || !fs.existsSync(this.screenshotFolder)) {
dirPath = await this.getPicturesPath()
} else {
dirPath = this.screenshotFolder
}
const options = {
defaultPath: path.join(dirPath, filenameWithExtension),
filters: [
{
name: format.toUpperCase(),
extensions: [format]
}
]
}
const response = await this.showSaveDialog({ options, useModal: true })
if (wasPlaying) {
this.player.play()
}
if (response.canceled || response.filePath === '') {
canvas.remove()
return
}
filePath = response.filePath
if (!filePath.endsWith(`.${format}`)) {
filePath = `${filePath}.${format}`
}
dirPath = path.dirname(filePath)
this.updateScreenshotFolderPath(dirPath)
} else {
if (this.screenshotFolder === '') {
dirPath = path.join(await this.getPicturesPath(), 'Freetube', subDir)
} else {
dirPath = path.join(this.screenshotFolder, subDir)
}
if (!fs.existsSync(dirPath)) {
try {
fs.mkdirSync(dirPath, { recursive: true })
} catch (err) {
console.error(err)
this.showToast({
message: this.$t('Screenshot Error').replace('$', err)
})
canvas.remove()
return
}
}
filePath = path.join(dirPath, filenameWithExtension)
}
canvas.toBlob((result) => {
result.arrayBuffer().then(ab => {
const arr = new Uint8Array(ab)
fs.writeFile(filePath, arr, (err) => {
if (err) {
console.error(err)
this.showToast({
message: this.$t('Screenshot Error').replace('$', err)
})
} else {
this.showToast({
message: this.$t('Screenshot Success').replace('$', filePath)
})
}
})
})
}, mimeType, imageQuality)
canvas.remove()
},
createDashQualitySelector: function (levels) { createDashQualitySelector: function (levels) {
if (levels.levels_.length === 0) { if (levels.levels_.length === 0) {
setTimeout(() => { setTimeout(() => {
@ -1209,6 +1461,10 @@ export default Vue.extend({
return format.bitrate === quality.bitrate return format.bitrate === quality.bitrate
}) })
if (typeof adaptiveFormat === 'undefined') {
return
}
this.activeAdaptiveFormats.push(adaptiveFormat) this.activeAdaptiveFormats.push(adaptiveFormat)
fps = adaptiveFormat.fps ? adaptiveFormat.fps : 30 fps = adaptiveFormat.fps ? adaptiveFormat.fps : 30
@ -1426,7 +1682,71 @@ export default Vue.extend({
handleTouchEnd: function (event) { handleTouchEnd: function (event) {
clearTimeout(this.touchPauseTimeout) clearTimeout(this.touchPauseTimeout)
}, },
toggleShowStatsModal: function() {
console.log(this.format)
if (this.format !== 'dash') {
this.showToast({
message: this.$t('Video.Stats.Video statistics are not available for legacy videos')
})
} else {
this.showStatsModal = !this.showStatsModal
}
},
createStatsModal: function() {
const ModalDialog = videojs.getComponent('ModalDialog')
this.statsModal = new ModalDialog(this.player, {
temporary: false,
pauseOnOpen: false
})
this.statsModal.handleKeyDown_ = (event) => {
// the default handler prevents keyboard events propagating beyond the modal
// the modal should only handle the escape and tab key, all others should be handled by the player
if (event.key === 'Escape' || event.key === 'Tab') {
this.statsModal.handleKeyDown(event)
}
}
this.player.addChild(this.statsModal)
this.statsModal.el_.classList.add('statsModal')
this.statsModal.on('modalclose', () => {
this.showStatsModal = false
})
},
updateStatsContent: function() {
if (this.showStatsModal) {
this.statsModal.contentEl().innerHTML = this.getFormattedStats()
}
},
getFormattedStats: function() {
const currentVolume = this.player.muted() ? 0 : this.player.volume()
const volume = `${(currentVolume * 100).toFixed(0)}%`
const bandwidth = `${(this.playerStats.bandwidth / 1000).toFixed(2)}kbps`
const buffered = `${(this.player.bufferedPercent() * 100).toFixed(0)}%`
const droppedFrames = this.playerStats.videoPlaybackQuality.droppedVideoFrames
const totalFrames = this.playerStats.videoPlaybackQuality.totalVideoFrames
const frames = `${droppedFrames} / ${totalFrames}`
const resolution = this.selectedResolution === 'auto' ? 'auto' : `${this.selectedResolution}@${this.selectedFPS}fps`
const playerDimensions = `${this.playerStats.playerDimensions.width}x${this.playerStats.playerDimensions.height}`
const statsArray = [
[this.$t('Video.Stats.Video ID'), this.videoId],
[this.$t('Video.Stats.Resolution'), resolution],
[this.$t('Video.Stats.Player Dimensions'), playerDimensions],
[this.$t('Video.Stats.Bitrate'), this.selectedBitrate],
[this.$t('Video.Stats.Volume'), volume],
[this.$t('Video.Stats.Bandwidth'), bandwidth],
[this.$t('Video.Stats.Buffered'), buffered],
[this.$t('Video.Stats.Dropped / Total Frames'), frames],
[this.$t('Video.Stats.Mimetype'), this.selectedMimeType]
]
let listContentHTML = ''
statsArray.forEach((stat) => {
const content = `<p>${stat[0]}: ${stat[1]}</p>`
listContentHTML += content
})
return listContentHTML
},
// This function should always be at the bottom of this file
keyboardShortcutHandler: function (event) { keyboardShortcutHandler: function (event) {
const activeInputs = $('.ft-input') const activeInputs = $('.ft-input')
@ -1452,7 +1772,7 @@ export default Vue.extend({
// J Key // J Key
// Rewind by 2x the time-skip interval (in seconds) // Rewind by 2x the time-skip interval (in seconds)
event.preventDefault() event.preventDefault()
this.changeDurationBySeconds(-this.defaultSkipInterval * 2) this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate() * 2)
break break
case 75: case 75:
// K Key // K Key
@ -1464,19 +1784,19 @@ export default Vue.extend({
// L Key // L Key
// Fast-Forward by 2x the time-skip interval (in seconds) // Fast-Forward by 2x the time-skip interval (in seconds)
event.preventDefault() event.preventDefault()
this.changeDurationBySeconds(this.defaultSkipInterval * 2) this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate() * 2)
break break
case 79: case 79:
// O Key // O Key
// Decrease playback rate by 0.25x // Decrease playback rate by 0.25x
event.preventDefault() event.preventDefault()
this.changePlayBackRate(-0.25) this.changePlayBackRate(-this.videoPlaybackRateInterval)
break break
case 80: case 80:
// P Key // P Key
// Increase playback rate by 0.25x // Increase playback rate by 0.25x
event.preventDefault() event.preventDefault()
this.changePlayBackRate(0.25) this.changePlayBackRate(this.videoPlaybackRateInterval)
break break
case 70: case 70:
// F Key // F Key
@ -1512,13 +1832,23 @@ export default Vue.extend({
// Left Arrow Key // Left Arrow Key
// Rewind by the time-skip interval (in seconds) // Rewind by the time-skip interval (in seconds)
event.preventDefault() event.preventDefault()
this.changeDurationBySeconds(-this.defaultSkipInterval * 1) this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate())
break break
case 39: case 39:
// Right Arrow Key // Right Arrow Key
// Fast-Forward by the time-skip interval (in seconds) // Fast-Forward by the time-skip interval (in seconds)
event.preventDefault() event.preventDefault()
this.changeDurationBySeconds(this.defaultSkipInterval * 1) this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate())
break
case 73:
// I Key
event.preventDefault()
// Toggle Picture in Picture Mode
if (this.format !== 'audio' && !this.player.isInPictureInPicture()) {
this.player.requestPictureInPicture()
} else if (this.player.isInPictureInPicture()) {
this.player.exitPictureInPicture()
}
break break
case 49: case 49:
// 1 Key // 1 Key
@ -1592,12 +1922,8 @@ export default Vue.extend({
break break
case 68: case 68:
// D Key // D Key
// Toggle Picture in Picture Mode event.preventDefault()
if (!this.player.isInPictureInPicture()) { this.toggleShowStatsModal()
this.player.requestPictureInPicture()
} else if (this.player.isInPictureInPicture()) {
this.player.exitPictureInPicture()
}
break break
case 27: case 27:
// esc Key // esc Key
@ -1615,86 +1941,11 @@ export default Vue.extend({
// Toggle Theatre Mode // Toggle Theatre Mode
this.toggleTheatreMode() this.toggleTheatreMode()
break break
} case 85:
} // U Key
}, // Take screenshot
this.takeScreenshot()
addPlayerStatsEvent: function() { break
this.stats.videoId = this.videoId
this.player.on('volumechange', () => {
this.stats.volume = this.player.volume()
this.player.trigger(this.stats.display.event)
})
this.player.on('timeupdate', () => {
const stats = this.player.tech({ IWillNotUseThisInPlugins: true }).vhs.stats
this.stats.frameInfo = stats.videoPlaybackQuality
this.player.trigger(this.stats.display.event)
})
this.player.on('progress', () => {
const stats = this.player.tech({ IWillNotUseThisInPlugins: true }).vhs.stats
this.stats.bandwidth = stats.bandwidth
this.stats.bufferPercent = this.player.bufferedPercent()
})
this.player.on('playerresize', () => {
this.stats.playerResolution = this.player.currentDimensions()
this.player.trigger(this.stats.display.event)
})
this.createStatsModal()
this.player.on(this.stats.display.event, () => {
if (this.stats.display.activated) {
this.stats.display.modal.open()
this.player.controls(true)
this.stats.display.modal.contentEl().innerHTML = this.formatted_stats
} else {
this.stats.display.modal.close()
}
})
// keyboard shortcut
window.addEventListener('keyup', (event) => {
if (event.code === this.stats.display.keyboardShortcut) {
if (this.stats.display.activated) {
this.deactivateStatsDisplay()
} else {
this.activateStatsDisplay()
}
}
}, true)
// right click menu
ipcRenderer.on(this.stats.display.rightClickEvent, () => {
this.activateStatsDisplay()
})
},
createStatsModal: function() {
const ModalDialog = videojs.getComponent('ModalDialog')
this.stats.display.modal = new ModalDialog(this.player, {
temporary: false,
pauseOnOpen: false
})
this.player.addChild(this.stats.display.modal)
this.stats.display.modal.height('35%')
this.stats.display.modal.width('50%')
this.stats.display.modal.contentEl().style.backgroundColor = 'rgba(0, 0, 0, 0.55)'
this.stats.display.modal.on('modalclose', () => {
this.deactivateStatsDisplay()
})
},
activateStatsDisplay: function() {
this.stats.display.activated = true
},
deactivateStatsDisplay: function() {
this.stats.display.activated = false
},
currentFps: function() {
for (const el of this.activeAdaptiveFormats) {
if (el.qualityLabel === this.selectedQuality) {
this.stats.fps = el.fps
break
} }
} }
}, },
@ -1703,7 +1954,11 @@ export default Vue.extend({
'calculateColorLuminance', 'calculateColorLuminance',
'updateDefaultCaptionSettings', 'updateDefaultCaptionSettings',
'showToast', 'showToast',
'sponsorBlockSkipSegments' 'sponsorBlockSkipSegments',
'parseScreenshotCustomFileName',
'updateScreenshotFolderPath',
'getPicturesPath',
'showSaveDialog'
]) ])
} }
}) })

View File

@ -7,6 +7,7 @@
controls controls
preload="auto" preload="auto"
:data-setup="JSON.stringify(dataSetup)" :data-setup="JSON.stringify(dataSetup)"
crossorigin="anonymous"
@touchstart="handleTouchStart" @touchstart="handleTouchStart"
@touchend="handleTouchEnd" @touchend="handleTouchEnd"
> >

View File

@ -5,6 +5,12 @@ import FtSelect from '../ft-select/ft-select.vue'
import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
import FtSlider from '../ft-slider/ft-slider.vue' import FtSlider from '../ft-slider/ft-slider.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtButton from '../ft-button/ft-button.vue'
import FtInput from '../ft-input/ft-input.vue'
import FtTooltip from '../ft-tooltip/ft-tooltip.vue'
import { ipcRenderer } from 'electron'
import { IpcChannels } from '../../../constants'
import path from 'path'
export default Vue.extend({ export default Vue.extend({
name: 'PlayerSettings', name: 'PlayerSettings',
@ -13,7 +19,10 @@ export default Vue.extend({
'ft-select': FtSelect, 'ft-select': FtSelect,
'ft-toggle-switch': FtToggleSwitch, 'ft-toggle-switch': FtToggleSwitch,
'ft-slider': FtSlider, 'ft-slider': FtSlider,
'ft-flex-box': FtFlexBox 'ft-flex-box': FtFlexBox,
'ft-button': FtButton,
'ft-input': FtInput,
'ft-tooltip': FtTooltip
}, },
data: function () { data: function () {
return { return {
@ -30,7 +39,24 @@ export default Vue.extend({
480, 480,
720, 720,
1080 1080
] ],
playbackRateIntervalValues: [
0.1,
0.25,
0.5,
1
],
screenshotFormatNames: [
'PNG',
'JPEG'
],
screenshotFormatValues: [
'png',
'jpg'
],
screenshotFolderPlaceholder: '',
screenshotFilenameExample: '',
screenshotDefaultPattern: '%Y%M%D-%H%N%S'
} }
}, },
computed: { computed: {
@ -106,6 +132,14 @@ export default Vue.extend({
return this.$store.getters.getDisplayVideoPlayButton return this.$store.getters.getDisplayVideoPlayButton
}, },
maxVideoPlaybackRate: function () {
return parseInt(this.$store.getters.getMaxVideoPlaybackRate)
},
videoPlaybackRateInterval: function () {
return this.$store.getters.getVideoPlaybackRateInterval
},
formatNames: function () { formatNames: function () {
return [ return [
this.$t('Settings.Player Settings.Default Video Format.Dash Formats'), this.$t('Settings.Player Settings.Default Video Format.Dash Formats'),
@ -124,9 +158,101 @@ export default Vue.extend({
this.$t('Settings.Player Settings.Default Quality.720p'), this.$t('Settings.Player Settings.Default Quality.720p'),
this.$t('Settings.Player Settings.Default Quality.1080p') this.$t('Settings.Player Settings.Default Quality.1080p')
] ]
},
enableScreenshot: function() {
return this.$store.getters.getEnableScreenshot
},
screenshotFormat: function() {
return this.$store.getters.getScreenshotFormat
},
screenshotQuality: function() {
return this.$store.getters.getScreenshotQuality
},
screenshotAskPath: function() {
return this.$store.getters.getScreenshotAskPath
},
screenshotFolder: function() {
return this.$store.getters.getScreenshotFolderPath
},
screenshotFilenamePattern: function() {
return this.$store.getters.getScreenshotFilenamePattern
} }
}, },
watch: {
screenshotFolder: function() {
this.getScreenshotFolderPlaceholder()
}
},
mounted: function() {
this.getScreenshotFolderPlaceholder()
this.getScreenshotFilenameExample(this.screenshotFilenamePattern)
},
methods: { methods: {
handleUpdateScreenshotFormat: async function(format) {
await this.updateScreenshotFormat(format)
this.getScreenshotFilenameExample(this.screenshotFilenamePattern)
},
getScreenshotEmptyFolderPlaceholder: async function() {
return path.join(await this.getPicturesPath(), 'Freetube')
},
getScreenshotFolderPlaceholder: function() {
if (this.screenshotFolder !== '') {
this.screenshotFolderPlaceholder = this.screenshotFolder
return
}
this.getScreenshotEmptyFolderPlaceholder().then((res) => {
this.screenshotFolderPlaceholder = res
})
},
chooseScreenshotFolder: async function() {
// only use with electron
const folder = await ipcRenderer.invoke(
IpcChannels.SHOW_OPEN_DIALOG,
{ properties: ['openDirectory'] }
)
if (!folder.canceled) {
await this.updateScreenshotFolderPath(folder.filePaths[0])
this.getScreenshotFolderPlaceholder()
}
},
handleScreenshotFilenamePatternChanged: async function(input) {
const pattern = input.trim()
if (!await this.getScreenshotFilenameExample(pattern)) {
return
}
if (pattern) {
this.updateScreenshotFilenamePattern(pattern)
} else {
this.updateScreenshotFilenamePattern(this.screenshotDefaultPattern)
}
},
getScreenshotFilenameExample: function(pattern) {
return this.parseScreenshotCustomFileName({
pattern: pattern || this.screenshotDefaultPattern,
date: new Date(Date.now()),
playerTime: 123.456,
videoId: 'dQw4w9WgXcQ'
}).then(res => {
this.screenshotFilenameExample = `${res}.${this.screenshotFormat}`
return true
}).catch(err => {
this.screenshotFilenameExample = `${this.$t(`Settings.Player Settings.Screenshot.Error.${err.message}`)}`
return false
})
},
...mapActions([ ...mapActions([
'updateAutoplayVideos', 'updateAutoplayVideos',
'updateAutoplayPlaylists', 'updateAutoplayPlaylists',
@ -143,7 +269,17 @@ export default Vue.extend({
'updateDefaultQuality', 'updateDefaultQuality',
'updateVideoVolumeMouseScroll', 'updateVideoVolumeMouseScroll',
'updateVideoPlaybackRateMouseScroll', 'updateVideoPlaybackRateMouseScroll',
'updateDisplayVideoPlayButton' 'updateDisplayVideoPlayButton',
'updateMaxVideoPlaybackRate',
'updateVideoPlaybackRateInterval',
'updateEnableScreenshot',
'updateScreenshotFormat',
'updateScreenshotQuality',
'updateScreenshotAskPath',
'updateScreenshotFolderPath',
'updateScreenshotFilenamePattern',
'parseScreenshotCustomFileName',
'getPicturesPath'
]) ])
} }
}) })

View File

@ -1 +1,14 @@
@use "../../sass-partials/settings" @use "../../sass-partials/settings"
.screenshotFolderContainer
width: 95%
margin: 0 auto
align-items: center
column-gap: 1rem
.screenshotFolderLabel, .screenshotFolderButton, .screenshotFilenamePatternTitle
flex-grow: 0
.screenshotFolderPath, .screenshotFilenamePatternInput, .screenshotFilenamePatternExample
flex-grow: 1
margin-top: 10px

View File

@ -115,6 +115,22 @@
value-extension="×" value-extension="×"
@change="updateDefaultPlayback" @change="updateDefaultPlayback"
/> />
<ft-slider
:label="$t('Settings.Player Settings.Max Video Playback Rate')"
:default-value="maxVideoPlaybackRate"
:min-value="2"
:max-value="10"
:step="1"
value-extension="x"
@change="updateMaxVideoPlaybackRate"
/>
<ft-select
:placeholder="$t('Settings.Player Settings.Video Playback Rate Interval')"
:value="videoPlaybackRateInterval"
:select-names="playbackRateIntervalValues"
:select-values="playbackRateIntervalValues"
@change="updateVideoPlaybackRateInterval"
/>
</ft-flex-box> </ft-flex-box>
<ft-flex-box> <ft-flex-box>
<ft-select <ft-select
@ -133,6 +149,88 @@
@change="updateDefaultQuality" @change="updateDefaultQuality"
/> />
</ft-flex-box> </ft-flex-box>
<br>
<ft-flex-box>
<ft-toggle-switch
:label="$t('Settings.Player Settings.Screenshot.Enable')"
:default-value="enableScreenshot"
@change="updateEnableScreenshot"
/>
</ft-flex-box>
<div v-if="enableScreenshot">
<ft-flex-box>
<ft-select
:placeholder="$t('Settings.Player Settings.Screenshot.Format Label')"
:value="screenshotFormat"
:select-names="screenshotFormatNames"
:select-values="screenshotFormatValues"
@change="handleUpdateScreenshotFormat"
/>
<ft-slider
:label="$t('Settings.Player Settings.Screenshot.Quality Label')"
:default-value="screenshotQuality"
:min-value="0"
:max-value="100"
:step="1"
value-extension="%"
:disabled="screenshotFormat !== 'jpg'"
@change="updateScreenshotQuality"
/>
</ft-flex-box>
<ft-flex-box>
<ft-toggle-switch
:label="$t('Settings.Player Settings.Screenshot.Ask Path')"
:default-value="screenshotAskPath"
@change="updateScreenshotAskPath"
/>
</ft-flex-box>
<ft-flex-box
v-if="!screenshotAskPath"
class="screenshotFolderContainer"
>
<p class="screenshotFolderLabel">
{{ $t('Settings.Player Settings.Screenshot.Folder Label') }}
</p>
<ft-input
class="screenshotFolderPath"
:placeholder="screenshotFolderPlaceholder"
:show-action-button="false"
:show-label="false"
:disabled="true"
/>
<ft-button
:label="$t('Settings.Player Settings.Screenshot.Folder Button')"
class="screenshotFolderButton"
@click="chooseScreenshotFolder"
/>
</ft-flex-box>
<ft-flex-box class="screenshotFolderContainer">
<p class="screenshotFilenamePatternTitle">
{{ $t('Settings.Player Settings.Screenshot.File Name Label') }}
<ft-tooltip
class="selectTooltip"
position="bottom"
:tooltip="$t('Settings.Player Settings.Screenshot.File Name Tooltip')"
/>
</p>
<ft-input
class="screenshotFilenamePatternInput"
placeholder=""
:value="screenshotFilenamePattern"
:spellcheck="false"
:show-action-button="false"
:show-label="false"
@input="handleScreenshotFilenamePatternChanged"
/>
<ft-input
class="screenshotFilenamePatternExample"
:placeholder="`${screenshotFilenameExample}`"
:show-action-button="false"
:show-label="false"
:disabled="true"
/>
</ft-flex-box>
</div>
</details> </details>
</template> </template>

View File

@ -3,32 +3,44 @@
.playlistThumbnail img .playlistThumbnail img
width: 100% width: 100%
cursor: pointer cursor: pointer
@media only screen and (max-width: 800px)
display: none
.playlistStats
font-size: 15px
.playlistStats p
color: var(--secondary-text-color)
margin: 0
.playlistTitle
margin-bottom: 0.1em
.playlistDescription .playlistDescription
max-height: 20vh max-height: 20vh
overflow-y: auto overflow-y: auto
white-space: break-spaces
@media only screen and (max-width: 500px) @media only screen and (max-width: 500px)
max-height: 10vh max-height: 10vh
.playlistChannel .playlistChannel
height: 70px display: flex
align-items: center
gap: 8px
height: 40px
/* Indicates the box can be clicked to navigate */ /* Indicates the box can be clicked to navigate */
cursor: pointer cursor: pointer
.channelThumbnail .channelThumbnail
width: 70px width: 40px
float: left float: left
border-radius: 200px 200px 200px 200px border-radius: 200px 200px 200px 200px
-webkit-border-radius: 200px 200px 200px 200px -webkit-border-radius: 200px 200px 200px 200px
.channelName .channelName
float: left margin: 0
position: relative
width: 200px
margin-left: 10px
top: 5px
font-size: 15px font-size: 15px

View File

@ -8,22 +8,27 @@
@click="playFirstVideo" @click="playFirstVideo"
> >
</div> </div>
<h2>
{{ title }} <div class="playlistStats">
</h2> <h2 class="playlistTitle">
<p> {{ title }}
{{ videoCount }} {{ $t("Playlist.Videos") }} - <span v-if="!hideViews">{{ viewCount }} {{ $t("Playlist.Views") }} -</span> </h2>
<span v-if="infoSource !== 'local'"> <p>
{{ $t("Playlist.Last Updated On") }} {{ videoCount }} {{ $t("Playlist.Videos") }} - <span v-if="!hideViews">{{ viewCount }} {{ $t("Playlist.Views") }} -</span>
</span> <span v-if="infoSource !== 'local'">
{{ lastUpdated }} {{ $t("Playlist.Last Updated On") }}
</p> </span>
{{ lastUpdated }}
</p>
</div>
<p <p
class="playlistDescription" class="playlistDescription"
> v-text="description"
{{ description }} />
</p>
<hr> <hr>
<div <div
class="playlistChannel" class="playlistChannel"
@click="goToChannel" @click="goToChannel"
@ -38,7 +43,9 @@
{{ channelName }} {{ channelName }}
</h3> </h3>
</div> </div>
<br> <br>
<ft-list-dropdown <ft-list-dropdown
:title="$t('Playlist.Share Playlist.Share Playlist')" :title="$t('Playlist.Share Playlist.Share Playlist')"
:label-names="shareHeaders" :label-names="shareHeaders"

View File

@ -31,8 +31,7 @@ export default Vue.extend({
return { return {
isLoading: false, isLoading: false,
dataAvailable: false, dataAvailable: false,
proxyTestUrl: 'https://api.ipify.org?format=json', proxyTestUrl: 'https://ipwho.is/',
proxyTestUrl1: 'https://freegeoip.app/json/',
proxyId: '', proxyId: '',
proxyCountry: '', proxyCountry: '',
proxyRegion: '', proxyRegion: '',
@ -125,11 +124,11 @@ export default Vue.extend({
if (!this.useProxy) { if (!this.useProxy) {
this.enableProxy() this.enableProxy()
} }
$.getJSON(this.proxyTestUrl1, (response) => { $.getJSON(this.proxyTestUrl, (response) => {
console.log(response) console.log(response)
this.proxyIp = response.ip this.proxyIp = response.ip
this.proxyCountry = response.country_name this.proxyCountry = response.country
this.proxyRegion = response.region_name this.proxyRegion = response.region
this.proxyCity = response.city this.proxyCity = response.city
this.dataAvailable = true this.dataAvailable = true
}).fail((xhr, textStatus, error) => { }).fail((xhr, textStatus, error) => {

View File

@ -45,7 +45,7 @@
class="center" class="center"
:style="{opacity: useProxy ? 1 : 0.4}" :style="{opacity: useProxy ? 1 : 0.4}"
> >
{{ $t('Settings.Proxy Settings.Clicking on Test Proxy will send a request to') }} https://freegeoip.app/json/ {{ $t('Settings.Proxy Settings.Clicking on Test Proxy will send a request to') }} {{ proxyTestUrl }}
</p> </p>
<ft-flex-box> <ft-flex-box>
<ft-button <ft-button

View File

@ -3,11 +3,10 @@
height: calc(100vh - 60px); height: calc(100vh - 60px);
width: 200px; width: 200px;
overflow-x: hidden; overflow-x: hidden;
position: fixed; position: sticky;
left: 0px; left: 0;
top: 0px; top: 60px;
z-index: 4; z-index: 3;
margin-top: 60px;
box-shadow: 1px -1px 1px -1px var(--primary-shadow-color); box-shadow: 1px -1px 1px -1px var(--primary-shadow-color);
background-color: var(--side-nav-color); background-color: var(--side-nav-color);
transition-property: width; transition-property: width;
@ -168,6 +167,10 @@
} }
.sideNav { .sideNav {
position: fixed;
left: 0;
bottom: 0;
display: flex; display: flex;
} }

View File

@ -4,6 +4,7 @@ import FtCard from '../ft-card/ft-card.vue'
import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
import FtInput from '../ft-input/ft-input.vue' import FtInput from '../ft-input/ft-input.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtSponsorBlockCategory from '../ft-sponsor-block-category/ft-sponsor-block-category.vue'
export default Vue.extend({ export default Vue.extend({
name: 'SponsorBlockSettings', name: 'SponsorBlockSettings',
@ -11,7 +12,22 @@ export default Vue.extend({
'ft-card': FtCard, 'ft-card': FtCard,
'ft-toggle-switch': FtToggleSwitch, 'ft-toggle-switch': FtToggleSwitch,
'ft-input': FtInput, 'ft-input': FtInput,
'ft-flex-box': FtFlexBox 'ft-flex-box': FtFlexBox,
'ft-sponsor-block-category': FtSponsorBlockCategory
},
data: function () {
return {
categories: [
'sponsor',
'self-promotion',
'interaction',
'intro',
'outro',
'recap',
'music offtopic',
'filler'
]
}
}, },
computed: { computed: {
useSponsorBlock: function () { useSponsorBlock: function () {

View File

@ -32,6 +32,13 @@
@input="handleUpdateSponsorBlockUrl" @input="handleUpdateSponsorBlockUrl"
/> />
</ft-flex-box> </ft-flex-box>
<ft-flex-box>
<ft-sponsor-block-category
v-for="category in categories"
:key="category"
:category-name="category"
/>
</ft-flex-box>
</div> </div>
</details> </details>
</template> </template>

View File

@ -19,10 +19,6 @@ export default Vue.extend({
}, },
data: function () { data: function () {
return { return {
currentBaseTheme: '',
currentMainColor: '',
currentSecColor: '',
expandSideBar: false,
minUiScale: 50, minUiScale: 50,
maxUiScale: 300, maxUiScale: 300,
uiScaleStep: 5, uiScaleStep: 5,
@ -33,35 +29,11 @@ export default Vue.extend({
'no' 'no'
], ],
baseThemeValues: [ baseThemeValues: [
'system',
'light', 'light',
'dark', 'dark',
'black', 'black',
'dracula' 'dracula'
],
colorValues: [
'Red',
'Pink',
'Purple',
'DeepPurple',
'Indigo',
'Blue',
'LightBlue',
'Cyan',
'Teal',
'Green',
'LightGreen',
'Lime',
'Yellow',
'Amber',
'Orange',
'DeepOrange',
'DraculaCyan',
'DraculaGreen',
'DraculaOrange',
'DraculaPink',
'DraculaPurple',
'DraculaRed',
'DraculaYellow'
] ]
} }
}, },
@ -70,6 +42,18 @@ export default Vue.extend({
return this.$store.getters.getBarColor return this.$store.getters.getBarColor
}, },
baseTheme: function () {
return this.$store.getters.getBaseTheme
},
mainColor: function () {
return this.$store.getters.getMainColor
},
secColor: function () {
return this.$store.getters.getSecColor
},
isSideNavOpen: function () { isSideNavOpen: function () {
return this.$store.getters.getIsSideNavOpen return this.$store.getters.getIsSideNavOpen
}, },
@ -81,9 +65,15 @@ export default Vue.extend({
disableSmoothScrolling: function () { disableSmoothScrolling: function () {
return this.$store.getters.getDisableSmoothScrolling return this.$store.getters.getDisableSmoothScrolling
}, },
expandSideBar: function () {
return this.$store.getters.getExpandSideBar
},
hideLabelsSideBar: function () { hideLabelsSideBar: function () {
return this.$store.getters.getHideLabelsSideBar return this.$store.getters.getHideLabelsSideBar
}, },
restartPromptMessage: function () { restartPromptMessage: function () {
return this.$t('Settings["The app needs to restart for changes to take effect. Restart and apply change?"]') return this.$t('Settings["The app needs to restart for changes to take effect. Restart and apply change?"]')
}, },
@ -97,6 +87,7 @@ export default Vue.extend({
baseThemeNames: function () { baseThemeNames: function () {
return [ return [
this.$t('Settings.Theme Settings.Base Theme.System Default'),
this.$t('Settings.Theme Settings.Base Theme.Light'), this.$t('Settings.Theme Settings.Base Theme.Light'),
this.$t('Settings.Theme Settings.Base Theme.Dark'), this.$t('Settings.Theme Settings.Base Theme.Dark'),
this.$t('Settings.Theme Settings.Base Theme.Black'), this.$t('Settings.Theme Settings.Base Theme.Black'),
@ -104,63 +95,28 @@ export default Vue.extend({
] ]
}, },
colorValues: function () {
return this.$store.getters.getColorNames
},
colorNames: function () { colorNames: function () {
return [ return this.colorValues.map(colorVal => {
this.$t('Settings.Theme Settings.Main Color Theme.Red'), // add spaces before capital letters
this.$t('Settings.Theme Settings.Main Color Theme.Pink'), const colorName = colorVal.replace(/([A-Z])/g, ' $1').trim()
this.$t('Settings.Theme Settings.Main Color Theme.Purple'), return this.$t(`Settings.Theme Settings.Main Color Theme.${colorName}`)
this.$t('Settings.Theme Settings.Main Color Theme.Deep Purple'), })
this.$t('Settings.Theme Settings.Main Color Theme.Indigo'),
this.$t('Settings.Theme Settings.Main Color Theme.Blue'),
this.$t('Settings.Theme Settings.Main Color Theme.Light Blue'),
this.$t('Settings.Theme Settings.Main Color Theme.Cyan'),
this.$t('Settings.Theme Settings.Main Color Theme.Teal'),
this.$t('Settings.Theme Settings.Main Color Theme.Green'),
this.$t('Settings.Theme Settings.Main Color Theme.Light Green'),
this.$t('Settings.Theme Settings.Main Color Theme.Lime'),
this.$t('Settings.Theme Settings.Main Color Theme.Yellow'),
this.$t('Settings.Theme Settings.Main Color Theme.Amber'),
this.$t('Settings.Theme Settings.Main Color Theme.Orange'),
this.$t('Settings.Theme Settings.Main Color Theme.Deep Orange'),
this.$t('Settings.Theme Settings.Main Color Theme.Dracula Cyan'),
this.$t('Settings.Theme Settings.Main Color Theme.Dracula Green'),
this.$t('Settings.Theme Settings.Main Color Theme.Dracula Orange'),
this.$t('Settings.Theme Settings.Main Color Theme.Dracula Pink'),
this.$t('Settings.Theme Settings.Main Color Theme.Dracula Purple'),
this.$t('Settings.Theme Settings.Main Color Theme.Dracula Red'),
this.$t('Settings.Theme Settings.Main Color Theme.Dracula Yellow')
]
} }
}, },
mounted: function () { mounted: function () {
this.currentBaseTheme = localStorage.getItem('baseTheme')
this.currentMainColor = localStorage.getItem('mainColor').replace('main', '')
this.currentSecColor = localStorage.getItem('secColor').replace('sec', '')
this.expandSideBar = localStorage.getItem('expandSideBar') === 'true'
this.disableSmoothScrollingToggleValue = this.disableSmoothScrolling this.disableSmoothScrollingToggleValue = this.disableSmoothScrolling
}, },
methods: { methods: {
updateBaseTheme: function (theme) {
const mainColor = `main${this.currentMainColor}`
const secColor = `sec${this.currentSecColor}`
const payload = {
baseTheme: theme,
mainColor: mainColor,
secColor: secColor
}
this.$parent.$parent.updateTheme(payload)
this.currentBaseTheme = theme
},
handleExpandSideBar: function (value) { handleExpandSideBar: function (value) {
if (this.isSideNavOpen !== value) { if (this.isSideNavOpen !== value) {
this.$store.commit('toggleSideNav') this.$store.commit('toggleSideNav')
} }
this.expandSideBar = value this.updateExpandSideBar(value)
localStorage.setItem('expandSideBar', value)
}, },
handleRestartPrompt: function (value) { handleRestartPrompt: function (value) {
@ -186,36 +142,12 @@ export default Vue.extend({
}) })
}, },
updateMainColor: function (color) {
const mainColor = `main${color}`
const secColor = `sec${this.currentSecColor}`
const payload = {
baseTheme: this.currentBaseTheme,
mainColor: mainColor,
secColor: secColor
}
this.$parent.$parent.updateTheme(payload)
this.currentMainColor = color
},
updateSecColor: function (color) {
const mainColor = `main${this.currentMainColor}`
const secColor = `sec${color}`
const payload = {
baseTheme: this.currentBaseTheme,
mainColor: mainColor,
secColor: secColor
}
this.$parent.$parent.updateTheme(payload)
this.currentSecColor = color
},
...mapActions([ ...mapActions([
'updateBarColor', 'updateBarColor',
'updateBaseTheme',
'updateMainColor',
'updateSecColor',
'updateExpandSideBar',
'updateUiScale', 'updateUiScale',
'updateDisableSmoothScrolling', 'updateDisableSmoothScrolling',
'updateHideLabelsSideBar' 'updateHideLabelsSideBar'

View File

@ -43,21 +43,21 @@
<ft-flex-box> <ft-flex-box>
<ft-select <ft-select
:placeholder="$t('Settings.Theme Settings.Base Theme.Base Theme')" :placeholder="$t('Settings.Theme Settings.Base Theme.Base Theme')"
:value="currentBaseTheme" :value="baseTheme"
:select-names="baseThemeNames" :select-names="baseThemeNames"
:select-values="baseThemeValues" :select-values="baseThemeValues"
@change="updateBaseTheme" @change="updateBaseTheme"
/> />
<ft-select <ft-select
:placeholder="$t('Settings.Theme Settings.Main Color Theme.Main Color Theme')" :placeholder="$t('Settings.Theme Settings.Main Color Theme.Main Color Theme')"
:value="currentMainColor" :value="mainColor"
:select-names="colorNames" :select-names="colorNames"
:select-values="colorValues" :select-values="colorValues"
@change="updateMainColor" @change="updateMainColor"
/> />
<ft-select <ft-select
:placeholder="$t('Settings.Theme Settings.Secondary Color Theme')" :placeholder="$t('Settings.Theme Settings.Secondary Color Theme')"
:value="currentSecColor" :value="secColor"
:select-names="colorNames" :select-names="colorNames"
:select-values="colorValues" :select-values="colorValues"
@change="updateSecColor" @change="updateSecColor"

View File

@ -36,12 +36,12 @@ export default Vue.extend({
return this.$store.getters.getEnableSearchSuggestions return this.$store.getters.getEnableSearchSuggestions
}, },
searchSettings: function () { searchInput: function () {
return this.$store.getters.getSearchSettings return this.$refs.searchInput.$refs.input
}, },
isSideNavOpen: function () { searchSettings: function () {
return this.$store.getters.getIsSideNavOpen return this.$store.getters.getSearchSettings
}, },
barColor: function () { barColor: function () {
@ -60,12 +60,16 @@ export default Vue.extend({
return this.$store.getters.getBackendPreference return this.$store.getters.getBackendPreference
}, },
expandSideBar: function () {
return this.$store.getters.getExpandSideBar
},
forwardText: function () { forwardText: function () {
return this.$t('Forward') return this.$t('Forward')
}, },
backwardText: function () { backwardText: function () {
return this.$t('Backward') return this.$t('Back')
}, },
newWindowText: function () { newWindowText: function () {
@ -80,9 +84,12 @@ export default Vue.extend({
searchContainer.style.display = 'none' searchContainer.style.display = 'none'
} }
if (localStorage.getItem('expandSideBar') === 'true') { // Store is not up-to-date when the component mounts, so we use timeout.
this.toggleSideNav() setTimeout(() => {
} if (this.expandSideBar) {
this.toggleSideNav()
}
}, 0)
window.addEventListener('resize', function (event) { window.addEventListener('resize', function (event) {
const width = event.srcElement.innerWidth const width = event.srcElement.innerWidth
@ -190,6 +197,10 @@ export default Vue.extend({
this.showFilters = false this.showFilters = false
}, },
focusSearch: function () {
this.searchInput.focus()
},
getSearchSuggestionsDebounce: function (query) { getSearchSuggestionsDebounce: function (query) {
if (this.enableSearchSuggestions) { if (this.enableSearchSuggestions) {
this.debounceSearchResults(query) this.debounceSearchResults(query)

View File

@ -4,12 +4,13 @@
@content @content
.topNav .topNav
position: fixed position: sticky
z-index: 4 z-index: 4
left: 0 left: 0
right: 0 right: 0
top: 0 top: 0
height: 60px height: 60px
width: 100%
line-height: 60px line-height: 60px
background-color: var(--card-bg-color) background-color: var(--card-bg-color)
-webkit-box-shadow: 0px 2px 1px 0px var(--primary-shadow-color) -webkit-box-shadow: 0px 2px 1px 0px var(--primary-shadow-color)
@ -24,6 +25,9 @@
@include top-nav-is-colored @include top-nav-is-colored
background-color: var(--primary-color) background-color: var(--primary-color)
@media only screen and (max-width: 680px)
position: fixed
.menuIcon // the hamburger button .menuIcon // the hamburger button
@media only screen and (max-width: 680px) @media only screen and (max-width: 680px)
display: none display: none
@ -74,6 +78,8 @@
.side // parts of the top nav either side of the search bar .side // parts of the top nav either side of the search bar
display: flex display: flex
gap: 3px
margin: 0 6px
align-items: center align-items: center
&.profiles &.profiles
@ -158,7 +164,3 @@
left: 0 left: 0
right: 0 right: 0
margin: 95px 10px 0px margin: 95px 10px 0px
@media only screen and (min-width: 681px)
&.expand
margin-left: 100px

View File

@ -18,7 +18,7 @@
icon="arrow-left" icon="arrow-left"
role="button" role="button"
tabindex="0" tabindex="0"
:title="forwardText" :title="backwardText"
@click="historyBack" @click="historyBack"
@keypress="historyBack" @keypress="historyBack"
/> />
@ -66,6 +66,7 @@
<div class="middle"> <div class="middle">
<div class="searchContainer"> <div class="searchContainer">
<ft-input <ft-input
ref="searchInput"
:placeholder="$t('Search / Go to URL')" :placeholder="$t('Search / Go to URL')"
class="searchInput" class="searchInput"
:is-search="true" :is-search="true"
@ -89,7 +90,6 @@
<ft-search-filters <ft-search-filters
v-show="showFilters" v-show="showFilters"
class="searchFilters" class="searchFilters"
:class="{ expand: !isSideNavOpen }"
@filterValueUpdated="handleSearchFilterValueChanged" @filterValueUpdated="handleSearchFilterValueChanged"
/> />
</div> </div>

View File

@ -78,6 +78,12 @@
font-size: 12px; font-size: 12px;
} }
.commentMemberIcon {
margin-left: 5px;
width: 14px;
height: 14px;
}
.commentLikeCount { .commentLikeCount {
font-size: 11px; font-size: 11px;
margin-left: 70px; margin-left: 70px;

View File

@ -269,6 +269,11 @@ export default Vue.extend({
comment.likes = null comment.likes = null
} }
comment.text = autolinker.link(comment.text.replace(/(<(?!br>)([^>]+)>)/ig, '')) comment.text = autolinker.link(comment.text.replace(/(<(?!br>)([^>]+)>)/ig, ''))
if (comment.customEmojis.length > 0) {
comment.customEmojis.forEach(emoji => {
comment.text = comment.text.replace(emoji.text, `<img width="14" height="14" class="commentCustomEmoji" alt="${emoji.text.substring(2, emoji.text.length - 1)}" src="${emoji.emojiThumbnails[0].url}">`)
})
}
return comment return comment
}) })

View File

@ -68,6 +68,14 @@
> >
{{ comment.author }} {{ comment.author }}
</span> </span>
<img
v-if="comment.isMember"
:src="comment.memberIconUrl"
:title="$t('Comments.Member')"
:aria-label="$t('Comments.Member')"
class="commentMemberIcon"
alt=""
>
<span class="commentDate"> <span class="commentDate">
{{ comment.time }} {{ comment.time }}
</span> </span>
@ -137,6 +145,12 @@
> >
{{ reply.author }} {{ reply.author }}
</span> </span>
<img
v-if="reply.isMember"
:src="reply.memberIconUrl"
class="commentMemberIcon"
alt=""
>
<span class="commentDate"> <span class="commentDate">
{{ reply.time }} {{ reply.time }}
</span> </span>

View File

@ -4,7 +4,8 @@
} }
.description { .description {
font-family: 'Roboto', sans-serif; font-family: 'Roboto', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 17px; font-size: 17px;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
} }

View File

@ -118,12 +118,7 @@ export default Vue.extend({
}, },
data: function () { data: function () {
return { return {
formatTypeLabel: 'VIDEO FORMATS', formatTypeLabel: 'VIDEO FORMATS'
formatTypeValues: [
'dash',
'legacy',
'audio'
]
} }
}, },
computed: { computed: {
@ -175,23 +170,33 @@ export default Vue.extend({
return this.inFavoritesPlaylist ? 'base favorite' : 'base' return this.inFavoritesPlaylist ? 'base favorite' : 'base'
}, },
downloadLinkNames: function () { downloadLinkOptions: function () {
return this.downloadLinks.map((download) => { return this.downloadLinks.map((download) => {
return download.label return {
label: download.label,
value: download.url
}
}) })
}, },
downloadLinkValues: function () { downloadBehavior: function () {
return this.downloadLinks.map((download) => { return this.$store.getters.getDownloadBehavior
return download.url
})
}, },
formatTypeNames: function () { formatTypeOptions: function () {
return [ return [
this.$t('Change Format.Use Dash Formats').toUpperCase(), {
this.$t('Change Format.Use Legacy Formats').toUpperCase(), label: this.$t('Change Format.Use Dash Formats').toUpperCase(),
this.$t('Change Format.Use Audio Formats').toUpperCase() value: 'dash'
},
{
label: this.$t('Change Format.Use Legacy Formats').toUpperCase(),
value: 'legacy'
},
{
label: this.$t('Change Format.Use Audio Formats').toUpperCase(),
value: 'audio'
}
] ]
}, },
@ -287,6 +292,18 @@ export default Vue.extend({
} }
] ]
}) })
this.$watch('$refs.downloadButton.dropdownShown', (dropdownShown) => {
this.$parent.infoAreaSticky = !dropdownShown
if (dropdownShown && window.innerWidth >= 901) {
// adds a slight delay so we know that the dropdown has shown up
// and won't mess up our scrolling
Promise.resolve().then(() => {
this.$parent.$refs.infoArea.scrollIntoView()
})
}
})
} }
}, },
methods: { methods: {
@ -298,6 +315,7 @@ export default Vue.extend({
watchProgress: this.getTimestamp(), watchProgress: this.getTimestamp(),
playbackRate: this.defaultPlayback, playbackRate: this.defaultPlayback,
videoId: this.id, videoId: this.id,
videoLength: this.lengthSeconds,
playlistId: this.playlistId, playlistId: this.playlistId,
playlistIndex: this.getPlaylistIndex(), playlistIndex: this.getPlaylistIndex(),
playlistReverse: this.getPlaylistReverse(), playlistReverse: this.getPlaylistReverse(),
@ -409,15 +427,20 @@ export default Vue.extend({
}, },
handleDownload: function (index) { handleDownload: function (index) {
const url = this.downloadLinkValues[index] const selectedDownloadLinkOption = this.downloadLinkOptions[index]
const linkName = this.downloadLinkNames[index] const url = selectedDownloadLinkOption.value
const linkName = selectedDownloadLinkOption.label
const extension = this.grabExtensionFromUrl(linkName) const extension = this.grabExtensionFromUrl(linkName)
this.downloadMedia({ if (this.downloadBehavior === 'open') {
url: url, this.openExternalLink(url)
title: this.title, } else {
extension: extension this.downloadMedia({
}) url: url,
title: this.title,
extension: extension
})
}
}, },
grabExtensionFromUrl: function (url) { grabExtensionFromUrl: function (url) {

View File

@ -95,13 +95,13 @@
/> />
<ft-icon-button <ft-icon-button
v-if="!isUpcoming && downloadLinks.length > 0" v-if="!isUpcoming && downloadLinks.length > 0"
ref="downloadButton"
:title="$t('Video.Download Video')" :title="$t('Video.Download Video')"
class="option" class="option"
theme="secondary" theme="secondary"
icon="download" icon="download"
:return-index="true" :return-index="true"
:dropdown-names="downloadLinkNames" :dropdown-options="downloadLinkOptions"
:dropdown-values="downloadLinkValues"
@click="handleDownload" @click="handleDownload"
/> />
<ft-icon-button <ft-icon-button
@ -110,8 +110,7 @@
class="option" class="option"
theme="secondary" theme="secondary"
icon="file-video" icon="file-video"
:dropdown-names="formatTypeNames" :dropdown-options="formatTypeOptions"
:dropdown-values="formatTypeValues"
@click="handleFormatChange" @click="handleFormatChange"
/> />
<ft-share-button <ft-share-button

View File

@ -98,6 +98,17 @@ export default Vue.extend({
break break
} }
} }
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('previoustrack', this.playPreviousVideo)
navigator.mediaSession.setActionHandler('nexttrack', this.playNextVideo)
}
},
beforeDestroy: function () {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('previoustrack', null)
navigator.mediaSession.setActionHandler('nexttrack', null)
}
}, },
methods: { methods: {
goToPlaylist: function () { goToPlaylist: function () {

View File

@ -4,7 +4,6 @@ import App from './App.vue'
import router from './router/index' import router from './router/index'
import store from './store/index' import store from './store/index'
import i18n from './i18n/index' import i18n from './i18n/index'
// import 'material-design-icons/iconfont/material-icons.css'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons' import { fas } from '@fortawesome/free-solid-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons/faGithub' import { faGithub } from '@fortawesome/free-brands-svg-icons/faGithub'
@ -32,7 +31,7 @@ new Vue({
render: h => h(App) render: h => h(App)
}) })
// to avoild accesing electorn api from web app build // to avoid accesing electron api from web app build
if (window && window.process && window.process.type === 'renderer') { if (window && window.process && window.process.type === 'renderer') {
const { ipcRenderer } = require('electron') const { ipcRenderer } = require('electron')

View File

@ -1,16 +1,12 @@
import { DBHistoryHandlers } from '../../../datastores/handlers/index' import { DBHistoryHandlers } from '../../../datastores/handlers/index'
const state = { const state = {
historyCache: [], historyCache: []
searchHistoryCache: []
} }
const getters = { const getters = {
getHistoryCache: () => { getHistoryCache: () => {
return state.historyCache return state.historyCache
},
getSearchHistoryCache: () => {
return state.searchHistoryCache
} }
} }
@ -51,15 +47,6 @@ const actions = {
} }
}, },
async searchHistory({ commit }, query) {
try {
const results = await DBHistoryHandlers.search(query)
commit('setSearchHistoryCache', results)
} catch (errMessage) {
console.error(errMessage)
}
},
async updateWatchProgress({ commit }, { videoId, watchProgress }) { async updateWatchProgress({ commit }, { videoId, watchProgress }) {
try { try {
await DBHistoryHandlers.updateWatchProgress(videoId, watchProgress) await DBHistoryHandlers.updateWatchProgress(videoId, watchProgress)
@ -79,10 +66,6 @@ const mutations = {
state.historyCache = historyCache state.historyCache = historyCache
}, },
setSearchHistoryCache(state, result) {
state.searchHistoryCache = result
},
hoistEntryToTopOfHistoryCache(state, { currentIndex, updatedEntry }) { hoistEntryToTopOfHistoryCache(state, { currentIndex, updatedEntry }) {
state.historyCache.splice(currentIndex, 1) state.historyCache.splice(currentIndex, 1)
state.historyCache.unshift(updatedEntry) state.historyCache.unshift(updatedEntry)

View File

@ -13,20 +13,14 @@ const state = {
removeOnWatched: true, removeOnWatched: true,
videos: [] videos: []
} }
], ]
searchPlaylistCache: {
videos: []
}
} }
const getters = { const getters = {
getAllPlaylists: () => state.playlists, getAllPlaylists: () => state.playlists,
getFavorites: () => state.playlists[0], getFavorites: () => state.playlists[0],
getPlaylist: (playlistId) => state.playlists.find(playlist => playlist._id === playlistId), getPlaylist: (playlistId) => state.playlists.find(playlist => playlist._id === playlistId),
getWatchLater: () => state.playlists[1], getWatchLater: () => state.playlists[1]
getSearchPlaylistCache: () => {
return state.searchPlaylistCache
}
} }
const actions = { const actions = {
@ -136,25 +130,10 @@ const actions = {
} catch (errMessage) { } catch (errMessage) {
console.error(errMessage) console.error(errMessage)
} }
},
async searchFavoritePlaylist({ commit }, query) {
const re = new RegExp(query, 'i')
// filtering in the frontend because the documents are the playlists and not the videos
const results = state.playlists[0].videos.slice()
.filter((video) => {
return video.author.match(re) ||
video.title.match(re)
})
commit('setPlaylistCache', results)
} }
} }
const mutations = { const mutations = {
setPlaylistCache(state, result) {
state.searchPlaylistCache = {
videos: result
}
},
addPlaylist(state, payload) { addPlaylist(state, payload) {
state.playlists.push(payload) state.playlists.push(payload)
}, },

View File

@ -89,6 +89,31 @@ const actions = {
commit('setProfileList', profiles) commit('setProfileList', profiles)
}, },
async updateSubscriptionDetails({ getters, dispatch }, { channelThumbnailUrl, channelName, channelId }) {
const thumbnail = channelThumbnailUrl?.replace(/=s\d*/, '=s176') ?? null // change thumbnail size if different
const profileList = getters.getProfileList
for (const profile of profileList) {
const currentProfileCopy = JSON.parse(JSON.stringify(profile))
const channel = currentProfileCopy.subscriptions.find((channel) => {
return channel.id === channelId
}) ?? null
if (channel === null) { continue }
let updated = false
if (channel.name !== channelName || (channel.thumbnail !== thumbnail && thumbnail !== null)) {
if (thumbnail !== null) {
channel.thumbnail = thumbnail
}
channel.name = channelName
updated = true
}
if (updated) {
await dispatch('updateProfile', currentProfileCopy)
} else { // channel has not been updated, stop iterating through profiles
break
}
}
},
async createProfile({ commit }, profile) { async createProfile({ commit }, profile) {
try { try {
const newProfile = await DBProfileHandlers.create(profile) const newProfile = await DBProfileHandlers.create(profile)

View File

@ -167,7 +167,9 @@ const state = {
barColor: false, barColor: false,
checkForBlogPosts: true, checkForBlogPosts: true,
checkForUpdates: true, checkForUpdates: true,
// currentTheme: 'lightRed', baseTheme: 'system',
mainColor: 'Red',
secColor: 'Blue',
defaultCaptionSettings: '{}', defaultCaptionSettings: '{}',
defaultInterval: 5, defaultInterval: 5,
defaultPlayback: 1, defaultPlayback: 1,
@ -185,6 +187,7 @@ const state = {
externalPlayerExecutable: '', externalPlayerExecutable: '',
externalPlayerIgnoreWarnings: false, externalPlayerIgnoreWarnings: false,
externalPlayerCustomArgs: '', externalPlayerCustomArgs: '',
expandSideBar: false,
forceLocalBackendForLegacy: false, forceLocalBackendForLegacy: false,
hideActiveSubscriptions: false, hideActiveSubscriptions: false,
hideChannelSubscriptions: false, hideChannelSubscriptions: false,
@ -200,6 +203,7 @@ const state = {
hideLabelsSideBar: false, hideLabelsSideBar: false,
landingPage: 'subscriptions', landingPage: 'subscriptions',
listType: 'grid', listType: 'grid',
maxVideoPlaybackRate: 3,
playNextVideo: false, playNextVideo: false,
proxyHostname: '127.0.0.1', proxyHostname: '127.0.0.1',
proxyPort: '9050', proxyPort: '9050',
@ -211,13 +215,53 @@ const state = {
saveWatchedProgress: true, saveWatchedProgress: true,
sponsorBlockShowSkippedToast: true, sponsorBlockShowSkippedToast: true,
sponsorBlockUrl: 'https://sponsor.ajay.app', sponsorBlockUrl: 'https://sponsor.ajay.app',
sponsorBlockSponsor: {
color: 'Blue',
skip: 'autoSkip'
},
sponsorBlockSelfPromo: {
color: 'Yellow',
skip: 'showInSeekBar'
},
sponsorBlockInteraction: {
color: 'Green',
skip: 'showInSeekBar'
},
sponsorBlockIntro: {
color: 'Orange',
skip: 'doNothing'
},
sponsorBlockOutro: {
color: 'Orange',
skip: 'doNothing'
},
sponsorBlockRecap: {
color: 'Orange',
skip: 'doNothing'
},
sponsorBlockMusicOffTopic: {
color: 'Orange',
skip: 'doNothing'
},
sponsorBlockFiller: {
color: 'Orange',
skip: 'doNothing'
},
thumbnailPreference: '', thumbnailPreference: '',
useProxy: false, useProxy: false,
useRssFeeds: false, useRssFeeds: false,
useSponsorBlock: false, useSponsorBlock: false,
videoVolumeMouseScroll: false, videoVolumeMouseScroll: false,
videoPlaybackRateMouseScroll: false, videoPlaybackRateMouseScroll: false,
downloadFolderPath: '' videoPlaybackRateInterval: 0.25,
downloadFolderPath: '',
downloadBehavior: 'download',
enableScreenshot: false,
screenshotFormat: 'png',
screenshotQuality: 95,
screenshotAskPath: false,
screenshotFolderPath: '',
screenshotFilenamePattern: '%Y%M%D-%H%N%S'
} }
const stateWithSideEffects = { const stateWithSideEffects = {
@ -228,12 +272,31 @@ const stateWithSideEffects = {
let targetLocale = value let targetLocale = value
if (value === 'system') { if (value === 'system') {
const systemLocale = await dispatch('getSystemLocale') const systemLocaleName = (await dispatch('getSystemLocale')).replace('-', '_') // ex: en_US
const systemLocaleLang = systemLocaleName.split('_')[0] // ex: en
targetLocale = Object.keys(i18n.messages).find((locale) => { const targetLocaleOptions = Object.keys(i18n.messages).filter((locale) => { // filter out other languages
const localeName = locale.replace('-', '_') const localeLang = locale.replace('-', '_').split('_')[0]
return localeName.includes(systemLocale.replace('-', '_')) return localeLang.includes(systemLocaleLang)
}).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
return -1
} else if (bLocale.includes(systemLocaleName)) { // country & language match, prefer b
return 1
} else if (aLocale.length === 1) { // no country code for a, prefer a
return -1
} else if (bLocale.length === 1) { // no country code for b, prefer b
return 1
} else { // a & b have different country code from system, sort alphabetically
return aLocaleName.localeCompare(bLocaleName)
}
}) })
if (targetLocaleOptions.length > 0) {
targetLocale = targetLocaleOptions[0]
}
// Go back to default value if locale is unavailable // Go back to default value if locale is unavailable
if (!targetLocale) { if (!targetLocale) {
@ -322,7 +385,9 @@ const customActions = {
dispatch(defaultSideEffectsTriggerId(_id), value) dispatch(defaultSideEffectsTriggerId(_id), value)
} }
commit(defaultMutationId(_id), value) if (Object.keys(mutations).includes(defaultMutationId(_id))) {
commit(defaultMutationId(_id), value)
}
} }
} catch (errMessage) { } catch (errMessage) {
console.error(errMessage) console.error(errMessage)

View File

@ -1,5 +1,4 @@
import $ from 'jquery' import $ from 'jquery'
import forge from 'node-forge'
const state = {} const state = {}
const getters = {} const getters = {}
@ -7,22 +6,30 @@ const getters = {}
const actions = { const actions = {
sponsorBlockSkipSegments ({ rootState }, { videoId, categories }) { sponsorBlockSkipSegments ({ rootState }, { videoId, categories }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const messageDigestSha256 = forge.md.sha256.create() const videoIdBuffer = new TextEncoder().encode(videoId)
messageDigestSha256.update(videoId)
const videoIdHashPrefix = messageDigestSha256.digest().toHex().substring(0, 4)
const requestUrl = `${rootState.settings.sponsorBlockUrl}/api/skipSegments/${videoIdHashPrefix}?categories=${JSON.stringify(categories)}`
$.getJSON(requestUrl, (response) => { crypto.subtle.digest('SHA-256', videoIdBuffer).then((hashBuffer) => {
const segments = response const hashArray = Array.from(new Uint8Array(hashBuffer))
.filter((result) => result.videoID === videoId)
.flatMap((result) => result.segments) const videoIdHashPrefix = hashArray
resolve(segments) .map(byte => byte.toString(16).padStart(2, '0'))
}).fail((xhr, textStatus, error) => { .slice(0, 4)
console.log(xhr) .join('')
console.log(textStatus)
console.log(requestUrl) const requestUrl = `${rootState.settings.sponsorBlockUrl}/api/skipSegments/${videoIdHashPrefix}?categories=${JSON.stringify(categories)}`
console.log(error)
reject(xhr) $.getJSON(requestUrl, (response) => {
const segments = response
.filter((result) => result.videoID === videoId)
.flatMap((result) => result.segments)
resolve(segments)
}).fail((xhr, textStatus, error) => {
console.log(xhr)
console.log(textStatus)
console.log(requestUrl)
console.log(error)
reject(xhr)
})
}) })
}) })
} }

View File

@ -4,7 +4,8 @@ const state = {
allSubscriptionsList: [], allSubscriptionsList: [],
profileSubscriptions: { profileSubscriptions: {
activeProfile: MAIN_PROFILE_ID, activeProfile: MAIN_PROFILE_ID,
videoList: [] videoList: [],
errorChannels: []
} }
} }

View File

@ -1,10 +1,10 @@
import IsEqual from 'lodash.isequal' import IsEqual from 'lodash.isequal'
import FtToastEvents from '../../components/ft-toast/ft-toast-events' import FtToastEvents from '../../components/ft-toast/ft-toast-events'
import fs from 'fs' import fs from 'fs'
import path from 'path'
import i18n from '../../i18n/index' import i18n from '../../i18n/index'
import { IpcChannels } from '../../../constants' import { IpcChannels } from '../../../constants'
import { ipcRenderer } from 'electron'
const state = { const state = {
isSideNavOpen: false, isSideNavOpen: false,
@ -27,30 +27,30 @@ const state = {
type: 'all', type: 'all',
duration: '' duration: ''
}, },
colorClasses: [ colorNames: [
'mainRed', 'Red',
'mainPink', 'Pink',
'mainPurple', 'Purple',
'mainDeepPurple', 'DeepPurple',
'mainIndigo', 'Indigo',
'mainBlue', 'Blue',
'mainLightBlue', 'LightBlue',
'mainCyan', 'Cyan',
'mainTeal', 'Teal',
'mainGreen', 'Green',
'mainLightGreen', 'LightGreen',
'mainLime', 'Lime',
'mainYellow', 'Yellow',
'mainAmber', 'Amber',
'mainOrange', 'Orange',
'mainDeepOrange', 'DeepOrange',
'mainDraculaCyan', 'DraculaCyan',
'mainDraculaGreen', 'DraculaGreen',
'mainDraculaOrange', 'DraculaOrange',
'mainDraculaPink', 'DraculaPink',
'mainDraculaPurple', 'DraculaPurple',
'mainDraculaRed', 'DraculaRed',
'mainDraculaYellow' 'DraculaYellow'
], ],
colorValues: [ colorValues: [
'#d50000', '#d50000',
@ -107,6 +107,10 @@ const getters = {
return state.searchSettings return state.searchSettings
}, },
getColorNames () {
return state.colorNames
},
getColorValues () { getColorValues () {
return state.colorValues return state.colorValues
}, },
@ -177,8 +181,41 @@ const actions = {
} }
}, },
replaceFilenameForbiddenChars(_, filenameOriginal) {
let filenameNew = filenameOriginal
let forbiddenChars = {}
switch (process.platform) {
case 'win32':
forbiddenChars = {
'<': '', // U+FF1C
'>': '', // U+FF1E
':': '', // U+FF1A
'"': '', // U+FF02
'/': '', // U+FF0F
'\\': '', // U+FF3C
'|': '', // U+FF5C
'?': '', // U+FF1F
'*': '' // U+FF0A
}
break
case 'darwin':
forbiddenChars = { '/': '', ':': '' }
break
case 'linux':
forbiddenChars = { '/': '' }
break
default:
break
}
for (const forbiddenChar in forbiddenChars) {
filenameNew = filenameNew.replaceAll(forbiddenChar, forbiddenChars[forbiddenChar])
}
return filenameNew
},
async downloadMedia({ rootState, dispatch }, { url, title, extension, fallingBackPath }) { async downloadMedia({ rootState, dispatch }, { url, title, extension, fallingBackPath }) {
const fileName = `${title}.${extension}` const fileName = `${await dispatch('replaceFilenameForbiddenChars', title)}.${extension}`
const usingElectron = rootState.settings.usingElectron const usingElectron = rootState.settings.usingElectron
const locale = i18n._vm.locale const locale = i18n._vm.locale
const translations = i18n._vm.messages[locale] const translations = i18n._vm.messages[locale]
@ -197,11 +234,12 @@ const actions = {
defaultPath: fileName, defaultPath: fileName,
filters: [ filters: [
{ {
name: extension.toUpperCase(),
extensions: [extension] extensions: [extension]
} }
] ]
} }
const response = await dispatch('showSaveDialog', options) const response = await dispatch('showSaveDialog', { options })
if (response.canceled || response.filePath === '') { if (response.canceled || response.filePath === '') {
// User canceled the save dialog // User canceled the save dialog
@ -209,6 +247,19 @@ const actions = {
} }
folderPath = response.filePath folderPath = response.filePath
} else {
if (!fs.existsSync(folderPath)) {
try {
fs.mkdirSync(folderPath, { recursive: true })
} catch (err) {
console.error(err)
this.showToast({
message: err
})
return
}
}
folderPath = path.join(folderPath, fileName)
} }
dispatch('showToast', { dispatch('showToast', {
@ -223,8 +274,6 @@ const actions = {
}) })
const reader = response.body.getReader() const reader = response.body.getReader()
const contentLength = response.headers.get('Content-Length')
let receivedLength = 0
const chunks = [] const chunks = []
const handleError = (err) => { const handleError = (err) => {
@ -240,9 +289,10 @@ const actions = {
} }
chunks.push(value) chunks.push(value)
receivedLength += value.length
// Can be used in the future to determine download percentage // Can be used in the future to determine download percentage
const percentage = receivedLength / contentLength // const contentLength = response.headers.get('Content-Length')
// const receivedLength = value.length
// const percentage = receivedLength / contentLength
await reader.read().then(processText).catch(handleError) await reader.read().then(processText).catch(handleError)
} }
@ -281,10 +331,10 @@ const actions = {
return await invokeIRC(context, IpcChannels.SHOW_OPEN_DIALOG, webCbk, options) return await invokeIRC(context, IpcChannels.SHOW_OPEN_DIALOG, webCbk, options)
}, },
async showSaveDialog (context, options) { async showSaveDialog (context, { options, useModal = false }) {
// TODO: implement showSaveDialog web compatible callback // TODO: implement showSaveDialog web compatible callback
const webCbk = () => null const webCbk = () => null
return await invokeIRC(context, IpcChannels.SHOW_SAVE_DIALOG, webCbk, options) return await invokeIRC(context, IpcChannels.SHOW_SAVE_DIALOG, webCbk, { options, useModal })
}, },
async getUserDataPath (context) { async getUserDataPath (context) {
@ -293,13 +343,73 @@ const actions = {
return await invokeIRC(context, IpcChannels.GET_USER_DATA_PATH, webCbk) return await invokeIRC(context, IpcChannels.GET_USER_DATA_PATH, webCbk)
}, },
async getPicturesPath (context) {
const webCbk = () => null
return await invokeIRC(context, IpcChannels.GET_PICTURES_PATH, webCbk)
},
parseScreenshotCustomFileName: function({ rootState }, payload) {
return new Promise((resolve, reject) => {
const { pattern = rootState.settings.screenshotFilenamePattern, date, playerTime, videoId } = payload
const keywords = [
['%Y', date.getFullYear()], // year 4 digits
['%M', (date.getMonth() + 1).toString().padStart(2, '0')], // month 2 digits
['%D', date.getDate().toString().padStart(2, '0')], // day 2 digits
['%H', date.getHours().toString().padStart(2, '0')], // hour 2 digits
['%N', date.getMinutes().toString().padStart(2, '0')], // minute 2 digits
['%S', date.getSeconds().toString().padStart(2, '0')], // second 2 digits
['%T', date.getMilliseconds().toString().padStart(3, '0')], // millisecond 3 digits
['%s', parseInt(playerTime)], // video position second n digits
['%t', (playerTime % 1).toString().slice(2, 5) || '000'], // video position millisecond 3 digits
['%i', videoId] // video id
]
let parsedString = pattern
for (const [key, value] of keywords) {
parsedString = parsedString.replaceAll(key, value)
}
const platform = process.platform
if (platform === 'win32') {
// https://www.boost.org/doc/libs/1_78_0/libs/filesystem/doc/portability_guide.htm
// https://stackoverflow.com/questions/1976007/
const noForbiddenChars = ['<', '>', ':', '"', '/', '|', '?', '*'].every(char => {
return parsedString.indexOf(char) === -1
})
if (!noForbiddenChars) {
reject(new Error('Forbidden Characters')) // use message as translation key
}
} else if (platform === 'darwin') {
// https://superuser.com/questions/204287/
if (parsedString.indexOf(':') !== -1) {
reject(new Error('Forbidden Characters'))
}
}
const dirChar = platform === 'win32' ? '\\' : '/'
let filename
if (parsedString.indexOf(dirChar) !== -1) {
const lastIndex = parsedString.lastIndexOf(dirChar)
filename = parsedString.substring(lastIndex + 1)
} else {
filename = parsedString
}
if (!filename) {
reject(new Error('Empty File Name'))
}
resolve(parsedString)
})
},
updateShowProgressBar ({ commit }, value) { updateShowProgressBar ({ commit }, value) {
commit('setShowProgressBar', value) commit('setShowProgressBar', value)
}, },
getRandomColorClass () { getRandomColorClass () {
const randomInt = Math.floor(Math.random() * state.colorClasses.length) const randomInt = Math.floor(Math.random() * state.colorNames.length)
return state.colorClasses[randomInt] return 'main' + state.colorNames[randomInt]
}, },
getRandomColor () { getRandomColor () {
@ -832,7 +942,7 @@ const actions = {
args.push(...defaultCustomArguments) args.push(...defaultCustomArguments)
} }
if (payload.watchProgress > 0) { if (payload.watchProgress > 0 && payload.watchProgress < payload.videoLength - 10) {
if (typeof cmdArgs.startOffset === 'string') { if (typeof cmdArgs.startOffset === 'string') {
args.push(`${cmdArgs.startOffset}${payload.watchProgress}`) args.push(`${cmdArgs.startOffset}${payload.watchProgress}`)
} else { } else {
@ -973,7 +1083,7 @@ const mutations = {
}) })
if (sameSearch !== -1) { if (sameSearch !== -1) {
state.sessionSearchHistory[sameSearch].data = state.sessionSearchHistory[sameSearch].data.concat(payload.data) state.sessionSearchHistory[sameSearch].data = payload.data
state.sessionSearchHistory[sameSearch].nextPageRef = payload.nextPageRef state.sessionSearchHistory[sameSearch].nextPageRef = payload.nextPageRef
} else { } else {
state.sessionSearchHistory.push(payload) state.sessionSearchHistory.push(payload)
@ -984,7 +1094,7 @@ const mutations = {
state.popularCache = value state.popularCache = value
}, },
setTrendingCache (state, value, page) { setTrendingCache (state, { value, page }) {
state.trendingCache[page] = value state.trendingCache[page] = value
}, },

View File

@ -7,6 +7,8 @@ import { SocksProxyAgent } from 'socks-proxy-agent'
import { HttpsProxyAgent } from 'https-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent'
import { HttpProxyAgent } from 'http-proxy-agent' import { HttpProxyAgent } from 'http-proxy-agent'
import i18n from '../../i18n/index'
const state = { const state = {
isYtSearchRunning: false isYtSearchRunning: false
} }
@ -288,10 +290,15 @@ const actions = {
break break
} }
} }
const locale = settings.currentLocale.replace('-', '_') let locale = i18n.locale.replace('_', '-')
if (locale === 'nn') {
locale = 'no'
}
ytpl(playlistId, { ytpl(playlistId, {
hl: locale, hl: locale,
limit: 'Infinity', limit: Infinity,
requestOptions: { agent } requestOptions: { agent }
}).then((result) => { }).then((result) => {
resolve(result) resolve(result)

View File

@ -1,4 +1,4 @@
.light { .system[data-system-theme*='light'], .light {
--primary-text-color: #212121; --primary-text-color: #212121;
--secondary-text-color: #424242; --secondary-text-color: #424242;
--tertiary-text-color: #757575; --tertiary-text-color: #757575;
@ -22,7 +22,7 @@
--logo-text: url("~../../_icons/textColorSmall.png"); --logo-text: url("~../../_icons/textColorSmall.png");
} }
.dark { .system[data-system-theme*='dark'], .dark {
--primary-text-color: #EEEEEE; --primary-text-color: #EEEEEE;
--secondary-text-color: #ddd; --secondary-text-color: #ddd;
--tertiary-text-color: #999; --tertiary-text-color: #999;
@ -61,7 +61,7 @@
--secondary-card-bg-color: rgba(0, 0, 0, 0.75); --secondary-card-bg-color: rgba(0, 0, 0, 0.75);
--scrollbar-color: #515151; --scrollbar-color: #515151;
--scrollbar-color-hover: #424242; --scrollbar-color-hover: #424242;
--side-nav-color: #000000; --side-nav-color: #0f0f0f;
--side-nav-hover-color: #212121; --side-nav-hover-color: #212121;
--side-nav-active-color: #303030; --side-nav-active-color: #303030;
--search-bar-color: #262626; --search-bar-color: #262626;
@ -619,6 +619,8 @@
} }
body { body {
margin: 0;
min-height: 100vh;
color: var(--primary-text-color); color: var(--primary-text-color);
background-color: var(--bg-color); background-color: var(--bg-color);
--red-500: #f44336; --red-500: #f44336;

View File

@ -350,7 +350,7 @@
line-height: 1; line-height: 1;
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
word-break: initial; word-break: initial;
} }
.video-js:-moz-full-screen { .video-js:-moz-full-screen {
@ -490,6 +490,16 @@ body.vjs-full-window {
content: url(assets/img/close_theatre.svg) content: url(assets/img/close_theatre.svg)
} }
.vjs-icon-screenshot {
margin-top: 3px;
padding-top: 3px;
cursor: pointer;
}
.vjs-icon-screenshot::before {
content: url(assets/img/camera.svg)
}
@media only screen and (max-width: 1350px) { @media only screen and (max-width: 1350px) {
.videoPlayer .vjs-button-theatre { .videoPlayer .vjs-button-theatre {
display: none display: none
@ -1023,7 +1033,7 @@ body.vjs-full-window {
padding: 6px 8px 8px 8px; padding: 6px 8px 8px 8px;
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: -3.4em; top: -2.7em;
visibility: hidden; visibility: hidden;
z-index: 2; z-index: 2;
} }
@ -2136,7 +2146,8 @@ video::-webkit-media-text-track-display {
.video-js .vjs-vtt-thumbnail-display { .video-js .vjs-vtt-thumbnail-display {
position: absolute; position: absolute;
transition: transform .1s, opacity .2s; transition: transform .1s, opacity .2s;
bottom: 20px; /* `bottom` was 20px, to avoid obstruction by time tooltip updated to current value */
bottom: 56px;
pointer-events: none; pointer-events: none;
box-shadow: 0 0 7px rgba(0,0,0,.6); box-shadow: 0 0 7px rgba(0,0,0,.6);
z-index: 3; z-index: 3;
@ -2160,3 +2171,24 @@ video::-webkit-media-text-track-display {
font-size: xx-large; font-size: xx-large;
max-width: 100% !important; max-width: 100% !important;
} }
.vjs-modal-dialog.statsModal {
line-height: 10px;
width: 550px;
height: 225px;
font-size: 10px;
background-color: rgba(0, 0, 0, 0.5) !important;
}
.vjs-modal-dialog.statsModal p {
line-height: 10px;
position:relative;
bottom: 15px;
}
@media screen and (max-width: 775px) {
.vjs-modal-dialog.statsModal {
width: 100%;
height: 100%;
}
}

View File

@ -2,26 +2,32 @@
position: relative; position: relative;
width: 85%; width: 85%;
margin: 0 auto 20px; margin: 0 auto 20px;
box-sizing: border-box;
} }
.channelBanner { .channelDetails {
width: 100%; padding: 0 0 16px;
max-height: 200px;
} }
.defaultChannelBanner { .channelBannerContainer {
background: center / cover no-repeat var(--banner-url, transparent);
height: 13vw;
min-height: 110px;
max-height: 32vh;
width: 100%; width: 100%;
max-height: 200px; }
height:200px;
background-color: black; .channelBannerContainer.default {
background-image: url("images/defaultBanner.png"); background-image: url("images/defaultBanner.png");
background-repeat: repeat;
background-size: contain;
} }
.channelInfoContainer { .channelInfoContainer {
width: 100%;
position: relative; position: relative;
background-color: var(--card-bg-color); background-color: var(--card-bg-color);
margin-top: 10px; margin-top: 10px;
padding: 0 16px;
} }
.channelInfo { .channelInfo {
@ -102,7 +108,7 @@
} }
.aboutInfo { .aboutInfo {
font-family: 'Roboto', sans-serif; font-family: 'Roboto', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 17px; font-size: 17px;
white-space: pre-wrap; white-space: pre-wrap;
} }
@ -143,3 +149,8 @@
flex-direction: column; flex-direction: column;
padding-left: 1em; padding-left: 1em;
} }
.channelLineContainer h1,
.channelLineContainer p {
margin: 0;
}

View File

@ -50,6 +50,7 @@ export default Vue.extend({
searchResults: [], searchResults: [],
shownElementList: [], shownElementList: [],
apiUsed: '', apiUsed: '',
errorMessage: '',
videoSelectValues: [ videoSelectValues: [
'newest', 'newest',
'oldest', 'oldest',
@ -90,16 +91,14 @@ export default Vue.extend({
return this.$store.getters.getActiveProfile return this.$store.getters.getActiveProfile
}, },
isSubscribed: function () { subscriptionInfo: function () {
const subIndex = this.activeProfile.subscriptions.findIndex((channel) => { return this.activeProfile.subscriptions.find((channel) => {
return channel.id === this.id return channel.id === this.id
}) }) ?? null
},
if (subIndex === -1) { isSubscribed: function () {
return false return this.subscriptionInfo !== null
} else {
return true
}
}, },
subscribedText: function () { subscribedText: function () {
@ -249,25 +248,40 @@ export default Vue.extend({
getChannelInfoLocal: function () { getChannelInfoLocal: function () {
this.apiUsed = 'local' this.apiUsed = 'local'
ytch.getChannelInfo(this.id).then((response) => { const expectedId = this.id
this.id = response.authorId ytch.getChannelInfo({ channelId: expectedId }).then((response) => {
this.channelName = response.author if (response.alertMessage) {
this.setErrorMessage(response.alertMessage)
return
}
this.errorMessage = ''
if (expectedId !== this.id) {
return
}
const channelId = response.authorId
const channelName = response.author
const channelThumbnailUrl = response.authorThumbnails[2].url
this.id = channelId
this.channelName = channelName
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}` document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
if (this.hideChannelSubscriptions || response.subscriberCount === 0) { if (this.hideChannelSubscriptions || response.subscriberCount === 0) {
this.subCount = null this.subCount = null
} else { } else {
this.subCount = response.subscriberCount.toFixed(0) this.subCount = response.subscriberCount.toFixed(0)
} }
this.thumbnailUrl = response.authorThumbnails[2].url this.thumbnailUrl = channelThumbnailUrl
this.updateSubscriptionDetails({ channelThumbnailUrl, channelName, channelId })
this.channelDescription = autolinker.link(response.description) this.channelDescription = autolinker.link(response.description)
this.relatedChannels = response.relatedChannels.items this.relatedChannels = response.relatedChannels.items
this.relatedChannels.forEach(relatedChannel => { this.relatedChannels.forEach(relatedChannel => {
relatedChannel.authorThumbnails.map(thumbnail => { relatedChannel.thumbnail.map(thumbnail => {
if (!thumbnail.url.includes('https')) { if (!thumbnail.url.includes('https')) {
thumbnail.url = `https:${thumbnail.url}` thumbnail.url = `https:${thumbnail.url}`
} }
return thumbnail return thumbnail
}) })
relatedChannel.authorThumbnails = relatedChannel.thumbnail
}) })
if (response.authorBanners !== null) { if (response.authorBanners !== null) {
@ -306,7 +320,12 @@ export default Vue.extend({
getChannelVideosLocal: function () { getChannelVideosLocal: function () {
this.isElementListLoading = true this.isElementListLoading = true
ytch.getChannelVideos(this.id, this.videoSortBy).then((response) => { const expectedId = this.id
ytch.getChannelVideos({ channelId: expectedId, sortBy: this.videoSortBy }).then((response) => {
if (expectedId !== this.id) {
return
}
this.latestVideos = response.items this.latestVideos = response.items
this.videoContinuationString = response.continuation this.videoContinuationString = response.continuation
this.isElementListLoading = false this.isElementListLoading = false
@ -332,7 +351,7 @@ export default Vue.extend({
}, },
channelLocalNextPage: function () { channelLocalNextPage: function () {
ytch.getChannelVideosMore(this.videoContinuationString).then((response) => { ytch.getChannelVideosMore({ continuation: this.videoContinuationString }).then((response) => {
this.latestVideos = this.latestVideos.concat(response.items) this.latestVideos = this.latestVideos.concat(response.items)
this.videoContinuationString = response.continuation this.videoContinuationString = response.continuation
}).catch((err) => { }).catch((err) => {
@ -352,17 +371,26 @@ export default Vue.extend({
this.isLoading = true this.isLoading = true
this.apiUsed = 'invidious' this.apiUsed = 'invidious'
this.invidiousGetChannelInfo(this.id).then((response) => { const expectedId = this.id
this.invidiousGetChannelInfo(expectedId).then((response) => {
if (expectedId !== this.id) {
return
}
console.log(response) console.log(response)
this.channelName = response.author const channelName = response.author
const channelId = response.authorId
this.channelName = channelName
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}` document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
this.id = response.authorId this.id = channelId
if (this.hideChannelSubscriptions) { if (this.hideChannelSubscriptions) {
this.subCount = null this.subCount = null
} else { } else {
this.subCount = response.subCount this.subCount = response.subCount
} }
this.thumbnailUrl = response.authorThumbnails[3].url.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`) const thumbnail = response.authorThumbnails[3].url
this.thumbnailUrl = thumbnail.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`)
this.updateSubscriptionDetails({ channelThumbnailUrl: thumbnail, channelName: channelName, channelId: channelId })
this.channelDescription = autolinker.link(response.description) this.channelDescription = autolinker.link(response.description)
this.relatedChannels = response.relatedChannels.map((channel) => { this.relatedChannels = response.relatedChannels.map((channel) => {
channel.authorThumbnails[channel.authorThumbnails.length - 1].url = channel.authorThumbnails[channel.authorThumbnails.length - 1].url.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`) channel.authorThumbnails[channel.authorThumbnails.length - 1].url = channel.authorThumbnails[channel.authorThumbnails.length - 1].url.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`)
@ -371,12 +399,16 @@ export default Vue.extend({
}) })
this.latestVideos = response.latestVideos this.latestVideos = response.latestVideos
if (typeof (response.authorBanners) !== 'undefined') { if (response.authorBanners instanceof Array && response.authorBanners.length > 0) {
this.bannerUrl = response.authorBanners[0].url.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`) this.bannerUrl = response.authorBanners[0].url.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`)
} else {
this.bannerUrl = null
} }
this.errorMessage = ''
this.isLoading = false this.isLoading = false
}).catch((err) => { }).catch((err) => {
this.setErrorMessage(err.responseJSON.error)
console.log(err) console.log(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)') const errorMessage = this.$t('Invidious API Error (Click to copy)')
this.showToast({ this.showToast({
@ -418,7 +450,12 @@ export default Vue.extend({
}, },
getPlaylistsLocal: function () { getPlaylistsLocal: function () {
ytch.getChannelPlaylistInfo(this.id, this.playlistSortBy).then((response) => { const expectedId = this.id
ytch.getChannelPlaylistInfo({ channelId: expectedId, sortBy: this.playlistSortBy }).then((response) => {
if (expectedId !== this.id) {
return
}
console.log(response) console.log(response)
this.latestPlaylists = response.items.map((item) => { this.latestPlaylists = response.items.map((item) => {
item.proxyThumbnail = false item.proxyThumbnail = false
@ -448,7 +485,7 @@ export default Vue.extend({
}, },
getPlaylistsLocalMore: function () { getPlaylistsLocalMore: function () {
ytch.getChannelPlaylistsMore(this.playlistContinuationString).then((response) => { ytch.getChannelPlaylistsMore({ continuation: this.playlistContinuationString }).then((response) => {
console.log(response) console.log(response)
this.latestPlaylists = this.latestPlaylists.concat(response.items) this.latestPlaylists = this.latestPlaylists.concat(response.items)
this.playlistContinuationString = response.continuation this.playlistContinuationString = response.continuation
@ -466,6 +503,40 @@ export default Vue.extend({
}, },
getPlaylistsInvidious: function () { getPlaylistsInvidious: function () {
const payload = {
resource: 'channels/playlists',
id: this.id,
params: {
sort_by: this.playlistSortBy
}
}
this.invidiousAPICall(payload).then((response) => {
this.playlistContinuationString = response.continuation
this.latestPlaylists = response.playlists
this.isElementListLoading = false
}).catch((err) => {
console.log(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
this.showToast({
message: `${errorMessage}: ${err.responseJSON.error}`,
time: 10000,
action: () => {
navigator.clipboard.writeText(err.responseJSON.error)
}
})
if (this.backendPreference === 'invidious' && this.backendFallback) {
this.showToast({
message: this.$t('Falling back to Local API')
})
this.getPlaylistsLocal()
} else {
this.isLoading = false
}
})
},
getPlaylistsInvidiousMore: function () {
if (this.playlistContinuationString === null) { if (this.playlistContinuationString === null) {
console.log('There are no more playlists available for this channel') console.log('There are no more playlists available for this channel')
return return
@ -580,6 +651,16 @@ export default Vue.extend({
} }
}, },
setErrorMessage: function (errorMessage) {
this.isLoading = false
this.errorMessage = errorMessage
this.id = this.subscriptionInfo.id
this.channelName = this.subscriptionInfo.name
this.thumbnailUrl = this.subscriptionInfo.thumbnail
this.bannerUrl = null
this.subCount = null
},
handleFetchMore: function () { handleFetchMore: function () {
switch (this.currentTab) { switch (this.currentTab) {
case 'videos': case 'videos':
@ -598,7 +679,7 @@ export default Vue.extend({
this.getPlaylistsLocalMore() this.getPlaylistsLocalMore()
break break
case 'invidious': case 'invidious':
this.getPlaylistsInvidious() this.getPlaylistsInvidiousMore()
break break
} }
break break
@ -638,7 +719,7 @@ export default Vue.extend({
searchChannelLocal: function () { searchChannelLocal: function () {
if (this.searchContinuationString === '') { if (this.searchContinuationString === '') {
ytch.searchChannel(this.id, this.lastSearchQuery).then((response) => { ytch.searchChannel({ channelId: this.id, query: this.lastSearchQuery }).then((response) => {
console.log(response) console.log(response)
this.searchResults = response.items this.searchResults = response.items
this.isElementListLoading = false this.isElementListLoading = false
@ -663,7 +744,7 @@ export default Vue.extend({
} }
}) })
} else { } else {
ytch.searchChannelMore(this.searchContinuationString).then((response) => { ytch.searchChannelMore({ continuation: this.searchContinuationString }).then((response) => {
console.log(response) console.log(response)
this.searchResults = this.searchResults.concat(response.items) this.searchResults = this.searchResults.concat(response.items)
this.isElementListLoading = false this.isElementListLoading = false
@ -721,7 +802,8 @@ export default Vue.extend({
'showToast', 'showToast',
'updateProfile', 'updateProfile',
'invidiousGetChannelInfo', 'invidiousGetChannelInfo',
'invidiousAPICall' 'invidiousAPICall',
'updateSubscriptionDetails'
]) ])
} }
}) })

View File

@ -3,22 +3,21 @@
ref="search" ref="search"
> >
<ft-loader <ft-loader
v-if="isLoading" v-if="isLoading && !errorMessage"
:fullscreen="true" :fullscreen="true"
/> />
<ft-card <ft-card
v-else v-else
class="card" class="card channelDetails"
> >
<img <div
v-if="bannerUrl !== null" class="channelBannerContainer"
class="channelBanner" :class="{
:src="bannerUrl" default: !bannerUrl
> }"
<img :style="{ '--banner-url': `url('${bannerUrl}')` }"
v-else />
class="defaultChannelBanner"
>
<div <div
class="channelInfoContainer" class="channelInfoContainer"
> >
@ -35,19 +34,20 @@
<div <div
class="channelLineContainer" class="channelLineContainer"
> >
<span <h1
class="channelName" class="channelName"
> >
{{ channelName }} {{ channelName }}
</span> </h1>
<span
<p
v-if="subCount !== null" v-if="subCount !== null"
class="channelSubCount" class="channelSubCount"
> >
{{ formattedSubCount }} {{ formattedSubCount }}
<span v-if="subCount === 1">{{ $t("Channel.Subscriber") }}</span> <span v-if="subCount === 1">{{ $t("Channel.Subscriber") }}</span>
<span v-else>{{ $t("Channel.Subscribers") }}</span> <span v-else>{{ $t("Channel.Subscribers") }}</span>
</span> </p>
</div> </div>
</div> </div>
@ -61,6 +61,7 @@
</div> </div>
<ft-flex-box <ft-flex-box
v-if="!errorMessage"
class="channelInfoTabs" class="channelInfoTabs"
> >
<div <div
@ -112,7 +113,7 @@
</div> </div>
</ft-card> </ft-card>
<ft-card <ft-card
v-if="!isLoading" v-if="!isLoading && !errorMessage"
class="card" class="card"
> >
<div <div
@ -138,10 +139,10 @@
<ft-channel-bubble <ft-channel-bubble
v-for="(channel, index) in relatedChannels" v-for="(channel, index) in relatedChannels"
:key="index" :key="index"
:channel-name="channel.author" :channel-name="channel.author || channel.channelName"
:channel-id="channel.authorId" :channel-id="channel.channelId"
:channel-thumbnail="channel.authorThumbnails[channel.authorThumbnails.length - 1].url" :channel-thumbnail="channel.authorThumbnails[channel.authorThumbnails.length - 1].url"
@click="goToChannel(channel.authorId)" @click="goToChannel(channel.channelId)"
/> />
</ft-flex-box> </ft-flex-box>
</div> </div>
@ -194,6 +195,14 @@
</div> </div>
</div> </div>
</ft-card> </ft-card>
<ft-card
v-if="errorMessage"
class="card"
>
<p>
{{ errorMessage }}
</p>
</ft-card>
</div> </div>
</template> </template>

View File

@ -20,19 +20,19 @@ export default Vue.extend({
return { return {
isLoading: false, isLoading: false,
dataLimit: 100, dataLimit: 100,
hasQuery: false searchDataLimit: 100,
showLoadMoreButton: false,
hasQuery: false,
query: '',
activeData: []
} }
}, },
computed: { computed: {
historyCache: function () { historyCache: function () {
if (!this.hasQuery) { return this.$store.getters.getHistoryCache
return this.$store.getters.getHistoryCache
} else {
return this.$store.getters.getSearchHistoryCache
}
}, },
activeData: function () { fullData: function () {
if (this.historyCache.length < this.dataLimit) { if (this.historyCache.length < this.dataLimit) {
return this.historyCache return this.historyCache
} else { } else {
@ -40,30 +40,79 @@ export default Vue.extend({
} }
} }
}, },
watch: {
query() {
this.searchDataLimit = 100
this.filterHistory()
},
activeData() {
this.refreshPage()
},
fullData() {
this.activeData = this.fullData
this.filterHistory()
}
},
mounted: function () { mounted: function () {
console.log(this.historyCache)
const limit = sessionStorage.getItem('historyLimit') const limit = sessionStorage.getItem('historyLimit')
if (limit !== null) { if (limit !== null) {
this.dataLimit = limit this.dataLimit = limit
} }
this.activeData = this.fullData
if (this.activeData.length < this.historyCache.length) {
this.showLoadMoreButton = true
} else {
this.showLoadMoreButton = false
}
}, },
methods: { methods: {
increaseLimit: function () { increaseLimit: function () {
this.dataLimit += 100 if (this.query !== '') {
sessionStorage.setItem('historyLimit', this.dataLimit) this.searchDataLimit += 100
this.filterHistory()
} else {
this.dataLimit += 100
sessionStorage.setItem('historyLimit', this.dataLimit)
}
}, },
filterHistory: function(query) { filterHistory: function(query) {
this.hasQuery = query !== '' if (this.query === '') {
this.$store.dispatch('searchHistory', query) this.activeData = this.fullData
if (this.activeData.length < this.historyCache.length) {
this.showLoadMoreButton = true
} else {
this.showLoadMoreButton = false
}
} else {
const filteredQuery = this.historyCache.filter((video) => {
if (typeof (video.title) !== 'string' || typeof (video.author) !== 'string') {
return false
} else {
return video.title.toLowerCase().includes(this.query.toLowerCase()) || video.author.toLowerCase().includes(this.query.toLowerCase())
}
}).sort((a, b) => {
return b.timeWatched - a.timeWatched
})
if (filteredQuery.length <= this.searchDataLimit) {
this.showLoadMoreButton = false
} else {
this.showLoadMoreButton = true
}
this.activeData = filteredQuery.length < this.searchDataLimit ? filteredQuery : filteredQuery.slice(0, this.searchDataLimit)
}
}, },
load: function() { refreshPage: function() {
const scrollPos = window.scrollY || window.scrollTop || document.getElementsByTagName('html')[0].scrollTop
this.isLoading = true this.isLoading = true
setTimeout(() => { Vue.nextTick(() => {
this.isLoading = false this.isLoading = false
}, 100) Vue.nextTick(() => {
window.scrollTo(0, scrollPos)
})
})
} }
} }
}) })

View File

@ -1,34 +1,43 @@
<template> <template>
<div> <div>
<ft-loader <ft-loader
v-if="isLoading" v-show="isLoading"
:fullscreen="true" :fullscreen="true"
/> />
<ft-card <ft-card
v-else v-show="!isLoading"
class="card" class="card"
> >
<h3>{{ $t("History.History") }}</h3> <h3>{{ $t("History.History") }}</h3>
<ft-input <ft-input
v-show="fullData.length > 0"
ref="searchBar" ref="searchBar"
:placeholder="$t('History.Search bar placeholder')" :placeholder="$t('History.Search bar placeholder')"
:show-clear-text-button="true" :show-clear-text-button="true"
:show-action-button="false" :show-action-button="false"
@input="filterHistory" @input="(input) => query = input"
@clear="query = ''"
/> />
<ft-flex-box <ft-flex-box
v-if="activeData.length === 0" v-show="fullData.length === 0"
> >
<p class="message"> <p class="message">
{{ $t("History['Your history list is currently empty.']") }} {{ $t("History['Your history list is currently empty.']") }}
</p> </p>
</ft-flex-box> </ft-flex-box>
<ft-flex-box
v-show="activeData.length === 0 && fullData.length > 0"
>
<p class="message">
{{ $t("History['Empty Search Message']") }}
</p>
</ft-flex-box>
<ft-element-list <ft-element-list
v-else v-if="activeData.length > 0 && !isLoading"
:data="activeData" :data="activeData"
/> />
<ft-flex-box <ft-flex-box
v-if="activeData.length < historyCache.length" v-if="showLoadMoreButton"
> >
<ft-button <ft-button
label="Load More" label="Load More"

View File

@ -1,18 +1,42 @@
.routerView {
display: flex;
}
.playlistInfo { .playlistInfo {
background-color: var(--card-bg-color); background-color: var(--card-bg-color);
padding: 10px; box-sizing: border-box;
float: left; height: calc(100vh - 96px);
position: fixed; margin-right: 1em;
top: 60px;
width: 30%;
height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 10px;
position: sticky;
top: 78px;
width: 30%;
} }
.playlistItems { .playlistItems {
float: right;
width: 60%;
padding: 10px;
display: grid; display: grid;
grid-gap: 10px; grid-gap: 10px;
margin: 0;
padding: 10px;
width: 60%;
}
@media only screen and (max-width: 800px) {
.routerView {
flex-direction: column;
}
.playlistInfo {
box-sizing: border-box;
position: relative;
top: 0;
height: auto;
width: 100%;
}
.playlistItems {
box-sizing: border-box;
width: 100%;
}
} }

View File

@ -1,11 +1,11 @@
import Vue from 'vue' import Vue from 'vue'
import { mapActions } from 'vuex' import { mapActions } from 'vuex'
import dateFormat from 'dateformat'
import FtLoader from '../../components/ft-loader/ft-loader.vue' import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtCard from '../../components/ft-card/ft-card.vue' import FtCard from '../../components/ft-card/ft-card.vue'
import PlaylistInfo from '../../components/playlist-info/playlist-info.vue' import PlaylistInfo from '../../components/playlist-info/playlist-info.vue'
import FtListVideo from '../../components/ft-list-video/ft-list-video.vue' import FtListVideo from '../../components/ft-list-video/ft-list-video.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue' import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import i18n from '../../i18n/index'
export default Vue.extend({ export default Vue.extend({
name: 'Playlist', name: 'Playlist',
@ -36,6 +36,9 @@ export default Vue.extend({
}, },
currentInvidiousInstance: function () { currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance return this.$store.getters.getCurrentInvidiousInstance
},
currentLocale: function () {
return i18n.locale.replace('_', '-')
} }
}, },
watch: { watch: {
@ -81,6 +84,12 @@ export default Vue.extend({
infoSource: 'local' infoSource: 'local'
} }
this.updateSubscriptionDetails({
channelThumbnailUrl: this.infoData.channelThumbnail,
channelName: this.infoData.channelName,
channelId: this.infoData.channelId
})
this.playlistItems = result.items.map((video) => { this.playlistItems = result.items.map((video) => {
if (typeof video.author !== 'undefined') { if (typeof video.author !== 'undefined') {
const channelName = video.author.name const channelName = video.author.name
@ -133,9 +142,14 @@ export default Vue.extend({
infoSource: 'invidious' infoSource: 'invidious'
} }
this.updateSubscriptionDetails({
channelThumbnailUrl: result.authorThumbnails[2].url,
channelName: this.infoData.channelName,
channelId: this.infoData.channelId
})
const dateString = new Date(result.updated * 1000) const dateString = new Date(result.updated * 1000)
dateString.setDate(dateString.getDate() + 1) this.infoData.lastUpdated = dateString.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' })
this.infoData.lastUpdated = dateFormat(dateString, 'mmm dS, yyyy')
this.playlistItems = this.playlistItems.concat(result.videos) this.playlistItems = this.playlistItems.concat(result.videos)
@ -172,7 +186,8 @@ export default Vue.extend({
...mapActions([ ...mapActions([
'ytGetPlaylistInfo', 'ytGetPlaylistInfo',
'invidiousGetPlaylistInfo' 'invidiousGetPlaylistInfo',
'updateSubscriptionDetails'
]) ])
} }
}) })

View File

@ -4,11 +4,13 @@
v-if="isLoading" v-if="isLoading"
:fullscreen="true" :fullscreen="true"
/> />
<playlist-info <playlist-info
v-if="!isLoading" v-if="!isLoading"
:data="infoData" :data="infoData"
class="playlistInfo" class="playlistInfo"
/> />
<ft-card <ft-card
v-if="!isLoading" v-if="!isLoading"
class="playlistItems" class="playlistItems"

View File

@ -14,6 +14,10 @@
right: 10px; right: 10px;
} }
.channelBubble {
display: inline-block;
}
@media only screen and (max-width: 350px) { @media only screen and (max-width: 350px) {
.floatingTopButton { .floatingTopButton {
position: absolute position: absolute

View File

@ -6,6 +6,7 @@ import FtButton from '../../components/ft-button/ft-button.vue'
import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue' import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue' import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue' import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
import ytch from 'yt-channel-info' import ytch from 'yt-channel-info'
import Parser from 'rss-parser' import Parser from 'rss-parser'
@ -19,13 +20,15 @@ export default Vue.extend({
'ft-button': FtButton, 'ft-button': FtButton,
'ft-icon-button': FtIconButton, 'ft-icon-button': FtIconButton,
'ft-flex-box': FtFlexBox, 'ft-flex-box': FtFlexBox,
'ft-element-list': FtElementList 'ft-element-list': FtElementList,
'ft-channel-bubble': FtChannelBubble
}, },
data: function () { data: function () {
return { return {
isLoading: false, isLoading: false,
dataLimit: 100, dataLimit: 100,
videoList: [] videoList: [],
errorChannels: []
} }
}, },
computed: { computed: {
@ -110,6 +113,7 @@ export default Vue.extend({
})) }))
} else { } else {
this.videoList = subscriptionList.videoList this.videoList = subscriptionList.videoList
this.errorChannels = subscriptionList.errorChannels
} }
} else { } else {
this.getProfileSubscriptions() this.getProfileSubscriptions()
@ -123,6 +127,10 @@ export default Vue.extend({
} }
}, },
methods: { methods: {
goToChannel: function (id) {
this.$router.push({ path: `/channel/${id}` })
},
getSubscriptions: function () { getSubscriptions: function () {
if (this.activeSubscriptionList.length === 0) { if (this.activeSubscriptionList.length === 0) {
this.isLoading = false this.isLoading = false
@ -144,10 +152,9 @@ export default Vue.extend({
let videoList = [] let videoList = []
let channelCount = 0 let channelCount = 0
this.errorChannels = []
this.activeSubscriptionList.forEach(async (channel) => { this.activeSubscriptionList.forEach(async (channel) => {
let videos = [] let videos = []
if (!this.usingElectron || this.backendPreference === 'invidious') { if (!this.usingElectron || this.backendPreference === 'invidious') {
if (useRss) { if (useRss) {
videos = await this.getChannelVideosInvidiousRSS(channel) videos = await this.getChannelVideosInvidiousRSS(channel)
@ -174,7 +181,8 @@ export default Vue.extend({
const profileSubscriptions = { const profileSubscriptions = {
activeProfile: this.activeProfile._id, activeProfile: this.activeProfile._id,
videoList: videoList videoList: videoList,
errorChannels: this.errorChannels
} }
this.videoList = await Promise.all(videoList.filter((video) => { this.videoList = await Promise.all(videoList.filter((video) => {
@ -225,7 +233,12 @@ export default Vue.extend({
getChannelVideosLocalScraper: function (channel, failedAttempts = 0) { getChannelVideosLocalScraper: function (channel, failedAttempts = 0) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ytch.getChannelVideos(channel.id, 'latest').then(async (response) => { ytch.getChannelVideos({ channelId: channel.id, sortBy: 'latest' }).then(async (response) => {
if (response.alertMessage) {
this.errorChannels.push(channel)
resolve([])
return
}
const videos = await Promise.all(response.items.map(async (video) => { const videos = await Promise.all(response.items.map(async (video) => {
if (video.liveNow) { if (video.liveNow) {
video.publishedDate = new Date().getTime() video.publishedDate = new Date().getTime()
@ -297,33 +310,38 @@ export default Vue.extend({
resolve(items) resolve(items)
}).catch((err) => { }).catch((err) => {
console.log(err) console.log(err)
const errorMessage = this.$t('Local API Error (Click to copy)') if (err.toString().match(/404/)) {
this.showToast({ this.errorChannels.push(channel)
message: `${errorMessage}: ${err}`, resolve([])
time: 10000, } else {
action: () => { const errorMessage = this.$t('Local API Error (Click to copy)')
navigator.clipboard.writeText(err) this.showToast({
} message: `${errorMessage}: ${err}`,
}) time: 10000,
switch (failedAttempts) { action: () => {
case 0: navigator.clipboard.writeText(err)
resolve(this.getChannelVideosLocalScraper(channel, failedAttempts + 1))
break
case 1:
if (this.backendFallback) {
this.showToast({
message: this.$t('Falling back to Invidious API')
})
resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1))
} else {
resolve([])
} }
break })
case 2: switch (failedAttempts) {
resolve(this.getChannelVideosLocalScraper(channel, failedAttempts + 1)) case 0:
break resolve(this.getChannelVideosLocalScraper(channel, failedAttempts + 1))
default: break
resolve([]) case 1:
if (this.backendFallback) {
this.showToast({
message: this.$t('Falling back to Invidious API')
})
resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1))
} else {
resolve([])
}
break
case 2:
resolve(this.getChannelVideosLocalScraper(channel, failedAttempts + 1))
break
default:
resolve([])
}
} }
}) })
}) })
@ -403,25 +421,30 @@ export default Vue.extend({
navigator.clipboard.writeText(err) navigator.clipboard.writeText(err)
} }
}) })
switch (failedAttempts) { if (err.toString().match(/500/)) {
case 0: this.errorChannels.push(channel)
resolve(this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)) resolve([])
break } else {
case 1: switch (failedAttempts) {
if (this.backendFallback) { case 0:
this.showToast({ resolve(this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1))
message: this.$t('Falling back to the local API') break
}) case 1:
resolve(this.getChannelVideosLocalRSS(channel, failedAttempts + 1)) if (this.backendFallback) {
} else { this.showToast({
message: this.$t('Falling back to the local API')
})
resolve(this.getChannelVideosLocalRSS(channel, failedAttempts + 1))
} else {
resolve([])
}
break
case 2:
resolve(this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1))
break
default:
resolve([]) resolve([])
} }
break
case 2:
resolve(this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1))
break
default:
resolve([])
} }
}) })
}) })

View File

@ -8,6 +8,22 @@
v-else v-else
class="card" class="card"
> >
<div
v-if="errorChannels.length !== 0"
>
<h3> {{ $t("Subscriptions.Error Channels") }}</h3>
<div>
<ft-channel-bubble
v-for="(channel, index) in errorChannels"
:key="index"
:channel-name="channel.name"
:channel-id="channel.id"
:channel-thumbnail="channel.thumbnail"
class="channelBubble"
@click="goToChannel(channel.id)"
/>
</div>
</div>
<h3>{{ $t("Subscriptions.Subscriptions") }}</h3> <h3>{{ $t("Subscriptions.Subscriptions") }}</h3>
<ft-flex-box <ft-flex-box
v-if="activeVideoList.length === 0" v-if="activeVideoList.length === 0"

View File

@ -53,7 +53,7 @@ export default Vue.extend({
}, },
mounted: function () { mounted: function () {
if (this.trendingCache[this.currentTab] && this.trendingCache[this.currentTab].length > 0) { if (this.trendingCache[this.currentTab] && this.trendingCache[this.currentTab].length > 0) {
this.shownResults = this.trendingCache this.getTrendingInfoCache()
} else { } else {
this.getTrendingInfo() this.getTrendingInfo()
} }
@ -92,7 +92,11 @@ export default Vue.extend({
currentTabNode.attr('aria-selected', 'false') currentTabNode.attr('aria-selected', 'false')
newTabNode.attr('aria-selected', 'true') newTabNode.attr('aria-selected', 'true')
this.currentTab = tab this.currentTab = tab
this.getTrendingInfo() if (this.trendingCache[this.currentTab] && this.trendingCache[this.currentTab].length > 0) {
this.getTrendingInfoCache()
} else {
this.getTrendingInfo()
}
}, },
getTrendingInfo () { getTrendingInfo () {
@ -127,7 +131,8 @@ export default Vue.extend({
this.shownResults = returnData this.shownResults = returnData
this.isLoading = false this.isLoading = false
this.$store.commit('setTrendingCache', this.shownResults, this.currentTab) const currentTab = this.currentTab
this.$store.commit('setTrendingCache', { value: returnData, page: currentTab })
}).then(() => { }).then(() => {
document.querySelector(`#${this.currentTab}Tab`).focus() document.querySelector(`#${this.currentTab}Tab`).focus()
}).catch((err) => { }).catch((err) => {
@ -151,6 +156,14 @@ export default Vue.extend({
}) })
}, },
getTrendingInfoCache: function() {
this.isLoading = true
setTimeout(() => {
this.shownResults = this.trendingCache[this.currentTab]
this.isLoading = false
})
},
getTrendingInfoInvidious: function () { getTrendingInfoInvidious: function () {
this.isLoading = true this.isLoading = true
@ -177,7 +190,8 @@ export default Vue.extend({
this.shownResults = returnData this.shownResults = returnData
this.isLoading = false this.isLoading = false
this.$store.commit('setTrendingCache', this.shownResults, this.trendingCache) const currentTab = this.currentTab
this.$store.commit('setTrendingCache', { value: returnData, page: currentTab })
}).then(() => { }).then(() => {
document.querySelector(`#${this.currentTab}Tab`).focus() document.querySelector(`#${this.currentTab}Tab`).focus()
}).catch((err) => { }).catch((err) => {

View File

@ -22,19 +22,19 @@ export default Vue.extend({
return { return {
isLoading: false, isLoading: false,
dataLimit: 100, dataLimit: 100,
hasQuery: false searchDataLimit: 100,
showLoadMoreButton: false,
query: '',
hasQuery: false,
activeData: []
} }
}, },
computed: { computed: {
favoritesPlaylist: function () { favoritesPlaylist: function () {
if (!this.hasQuery) { return this.$store.getters.getFavorites
return this.$store.getters.getFavorites
} else {
return this.$store.getters.getSearchPlaylistCache
}
}, },
activeData: function () { fullData: function () {
const data = [].concat(this.favoritesPlaylist.videos).reverse() const data = [].concat(this.favoritesPlaylist.videos).reverse()
if (this.favoritesPlaylist.videos.length < this.dataLimit) { if (this.favoritesPlaylist.videos.length < this.dataLimit) {
return data return data
@ -44,16 +44,16 @@ export default Vue.extend({
} }
}, },
watch: { watch: {
query() {
this.searchDataLimit = 100
this.filterPlaylist()
},
activeData() { activeData() {
const scrollPos = window.scrollY || window.scrollTop || document.getElementsByTagName('html')[0].scrollTop this.refreshPage()
this.isLoading = true },
setTimeout(() => { fullData() {
this.isLoading = false this.activeData = this.fullData
// This is kinda ugly, but should fix a few existing issues this.filterPlaylist()
setTimeout(() => {
window.scrollTo(0, scrollPos)
}, 100)
}, 100)
} }
}, },
mounted: function () { mounted: function () {
@ -62,15 +62,60 @@ export default Vue.extend({
if (limit !== null) { if (limit !== null) {
this.dataLimit = limit this.dataLimit = limit
} }
if (this.activeData.length < this.favoritesPlaylist.videos.length) {
this.showLoadMoreButton = true
} else {
this.showLoadMoreButton = false
}
this.activeData = this.fullData
}, },
methods: { methods: {
increaseLimit: function () { increaseLimit: function () {
this.dataLimit += 100 if (this.query !== '') {
sessionStorage.setItem('favoritesLimit', this.dataLimit) this.searchDataLimit += 100
this.filterPlaylist()
} else {
this.dataLimit += 100
sessionStorage.setItem('favoritesLimit', this.dataLimit)
}
}, },
filterPlaylist: function(query) { filterPlaylist: function() {
this.hasQuery = query !== '' if (this.query === '') {
this.$store.dispatch('searchFavoritePlaylist', query) this.activeData = this.fullData
if (this.activeData.length < this.favoritesPlaylist.videos.length) {
this.showLoadMoreButton = true
} else {
this.showLoadMoreButton = false
}
} else {
const filteredQuery = this.favoritesPlaylist.videos.filter((video) => {
if (typeof (video.title) !== 'string' || typeof (video.author) !== 'string') {
return false
} else {
return video.title.toLowerCase().includes(this.query.toLowerCase()) || video.author.toLowerCase().includes(this.query.toLowerCase())
}
}).sort((a, b) => {
return b.timeAdded - a.timeAdded
})
if (filteredQuery.length <= this.searchDataLimit) {
this.showLoadMoreButton = false
} else {
this.showLoadMoreButton = true
}
this.activeData = filteredQuery.length < this.searchDataLimit ? filteredQuery : filteredQuery.slice(0, this.searchDataLimit)
}
},
refreshPage: function() {
const scrollPos = window.scrollY || window.scrollTop || document.getElementsByTagName('html')[0].scrollTop
this.isLoading = true
Vue.nextTick(() => {
this.isLoading = false
Vue.nextTick(() => {
window.scrollTo(0, scrollPos)
})
})
} }
} }
}) })

View File

@ -1,11 +1,11 @@
<template> <template>
<div> <div>
<ft-loader <ft-loader
v-if="isLoading" v-show="isLoading"
:fullscreen="true" :fullscreen="true"
/> />
<ft-card <ft-card
v-else v-show="!isLoading"
class="card" class="card"
> >
<h3> <h3>
@ -17,25 +17,34 @@
/> />
</h3> </h3>
<ft-input <ft-input
v-show="fullData.length > 0"
ref="searchBar" ref="searchBar"
:placeholder="$t('User Playlists.Search bar placeholder')" :placeholder="$t('User Playlists.Search bar placeholder')"
:show-clear-text-button="true" :show-clear-text-button="true"
:show-action-button="false" :show-action-button="false"
@input="filterPlaylist" @input="(input) => query = input"
@clear="query = ''"
/> />
<ft-flex-box <ft-flex-box
v-if="activeData.length === 0" v-show="fullData.length === 0"
> >
<p class="message"> <p class="message">
{{ $t("User Playlists['Your saved videos are empty. Click on the save button on the corner of a video to have it listed here']") }} {{ $t("User Playlists['Your saved videos are empty. Click on the save button on the corner of a video to have it listed here']") }}
</p> </p>
</ft-flex-box> </ft-flex-box>
<ft-flex-box
v-show="activeData.length === 0 && fullData.length > 0"
>
<p class="message">
{{ $t("User Playlists['Empty Search Message']") }}
</p>
</ft-flex-box>
<ft-element-list <ft-element-list
v-else v-if="activeData.length > 0 && !isLoading"
:data="activeData" :data="activeData"
/> />
<ft-flex-box <ft-flex-box
v-if="activeData.length < favoritesPlaylist.videos.length" v-if="showLoadMoreButton"
> >
<ft-button <ft-button
label="Load More" label="Load More"

View File

@ -29,11 +29,11 @@ export default Vue.extend({
'watch-video-recommendations': WatchVideoRecommendations 'watch-video-recommendations': WatchVideoRecommendations
}, },
beforeRouteLeave: function (to, from, next) { beforeRouteLeave: function (to, from, next) {
this.handleRouteChange() this.handleRouteChange(this.videoId)
window.removeEventListener('beforeunload', this.handleWatchProgress) window.removeEventListener('beforeunload', this.handleWatchProgress)
next() next()
}, },
data: function() { data: function () {
return { return {
isLoading: false, isLoading: false,
firstLoad: true, firstLoad: true,
@ -75,7 +75,9 @@ export default Vue.extend({
playlistId: '', playlistId: '',
timestamp: null, timestamp: null,
playNextTimeout: null, playNextTimeout: null,
playNextCountDownIntervalId: null playNextCountDownIntervalId: null,
pictureInPictureButtonInverval: null,
infoAreaSticky: true
} }
}, },
computed: { computed: {
@ -127,6 +129,9 @@ export default Vue.extend({
playNextVideo: function () { playNextVideo: function () {
return this.$store.getters.getPlayNextVideo return this.$store.getters.getPlayNextVideo
}, },
autoplayPlaylists: function () {
return this.$store.getters.getAutoplayPlaylists
},
hideRecommendedVideos: function () { hideRecommendedVideos: function () {
return this.$store.getters.getHideRecommendedVideos return this.$store.getters.getHideRecommendedVideos
}, },
@ -143,13 +148,13 @@ export default Vue.extend({
hideVideoLikesAndDislikes: function () { hideVideoLikesAndDislikes: function () {
return this.$store.getters.getHideVideoLikesAndDislikes return this.$store.getters.getHideVideoLikesAndDislikes
}, },
theatrePossible: function() { theatrePossible: function () {
return !this.hideRecommendedVideos || (!this.hideLiveChat && this.isLive) || this.watchingPlaylist return !this.hideRecommendedVideos || (!this.hideLiveChat && this.isLive) || this.watchingPlaylist
} }
}, },
watch: { watch: {
$route() { $route() {
this.handleRouteChange() this.handleRouteChange(this.videoId)
// react to route changes... // react to route changes...
this.videoId = this.$route.params.id this.videoId = this.$route.params.id
@ -174,6 +179,25 @@ export default Vue.extend({
} }
break break
} }
},
activeFormat: function (format) {
clearInterval(this.pictureInPictureButtonInverval)
// only hide/show the button once the player is available
this.pictureInPictureButtonInverval = setInterval(() => {
if (!this.hidePlayer) {
const pipButton = document.querySelector('.vjs-picture-in-picture-control')
if (pipButton === null) {
return
}
if (format === 'audio') {
pipButton.classList.add('vjs-hidden')
} else {
pipButton.classList.remove('vjs-hidden')
}
clearInterval(this.pictureInPictureButtonInverval)
}
}, 100)
} }
}, },
mounted: function () { mounted: function () {
@ -200,14 +224,14 @@ export default Vue.extend({
window.addEventListener('beforeunload', this.handleWatchProgress) window.addEventListener('beforeunload', this.handleWatchProgress)
}, },
methods: { methods: {
changeTimestamp: function(timestamp) { changeTimestamp: function (timestamp) {
this.$refs.videoPlayer.player.currentTime(timestamp) this.$refs.videoPlayer.player.currentTime(timestamp)
}, },
toggleTheatreMode: function() { toggleTheatreMode: function () {
this.useTheatreMode = !this.useTheatreMode this.useTheatreMode = !this.useTheatreMode
}, },
getVideoInformationLocal: function() { getVideoInformationLocal: function () {
if (this.firstLoad) { if (this.firstLoad) {
this.isLoading = true this.isLoading = true
} }
@ -256,6 +280,12 @@ export default Vue.extend({
this.channelName = result.player_response.videoDetails.author this.channelName = result.player_response.videoDetails.author
this.channelThumbnail = result.player_response.embedPreview.thumbnailPreviewRenderer.videoDetails.embeddedPlayerOverlayVideoDetailsRenderer.channelThumbnail.thumbnails[0].url this.channelThumbnail = result.player_response.embedPreview.thumbnailPreviewRenderer.videoDetails.embeddedPlayerOverlayVideoDetailsRenderer.channelThumbnail.thumbnails[0].url
} }
this.updateSubscriptionDetails({
channelThumbnailUrl: this.channelThumbnail,
channelName: this.channelName,
channelId: this.channelId
})
this.videoPublished = new Date(result.videoDetails.publishDate.replace('-', '/')).getTime() this.videoPublished = new Date(result.videoDetails.publishDate.replace('-', '/')).getTime()
this.videoDescription = result.player_response.videoDetails.shortDescription this.videoDescription = result.player_response.videoDetails.shortDescription
@ -310,7 +340,7 @@ export default Vue.extend({
} }
} }
if ((this.isLive || this.isLiveContent) && !this.isUpcoming) { if ((this.isLive && this.isLiveContent) && !this.isUpcoming) {
this.enableLegacyFormat() this.enableLegacyFormat()
this.videoSourceList = result.formats.filter((format) => { this.videoSourceList = result.formats.filter((format) => {
@ -402,8 +432,9 @@ export default Vue.extend({
) )
if (!standardLocale.startsWith('en') && noLocaleCaption) { if (!standardLocale.startsWith('en') && noLocaleCaption) {
const baseUrl = result.player_response.captions.playerCaptionsRenderer.baseUrl captionTracks.forEach((caption) => {
this.tryAddingTranslatedLocaleCaption(captionTracks, standardLocale, baseUrl) this.tryAddingTranslatedLocaleCaption(captionTracks, standardLocale, caption.baseUrl)
})
} }
} }
@ -508,7 +539,7 @@ export default Vue.extend({
}) })
}, },
getVideoInformationInvidious: function() { getVideoInformationInvidious: function () {
if (this.firstLoad) { if (this.firstLoad) {
this.isLoading = true this.isLoading = true
} }
@ -540,7 +571,14 @@ export default Vue.extend({
} }
this.channelId = result.authorId this.channelId = result.authorId
this.channelName = result.author this.channelName = result.author
this.channelThumbnail = result.authorThumbnails[1] ? result.authorThumbnails[1].url.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`) : '' const channelThumb = result.authorThumbnails[1]
this.channelThumbnail = channelThumb ? channelThumb.url.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`) : ''
this.updateSubscriptionDetails({
channelThumbnailUrl: channelThumb?.url,
channelName: result.author,
channelId: result.authorId
})
this.videoPublished = result.published * 1000 this.videoPublished = result.published * 1000
this.videoDescriptionHtml = result.descriptionHtml this.videoDescriptionHtml = result.descriptionHtml
this.recommendedVideos = result.recommendedVideos this.recommendedVideos = result.recommendedVideos
@ -880,7 +918,7 @@ export default Vue.extend({
}, },
handleVideoEnded: function () { handleVideoEnded: function () {
if (!this.watchingPlaylist && !this.playNextVideo) { if ((!this.watchingPlaylist || !this.autoplayPlaylists) && !this.playNextVideo) {
return return
} }
@ -933,7 +971,11 @@ export default Vue.extend({
this.playNextCountDownIntervalId = setInterval(showCountDownMessage, 1000) this.playNextCountDownIntervalId = setInterval(showCountDownMessage, 1000)
}, },
handleRouteChange: async function () { handleRouteChange: async function (videoId) {
// if the user navigates to another video, the ipc call for the userdata path
// takes long enough for the video id to have already changed to the new one
// receiving it as an arg instead of accessing it ourselves means we always have the right one
clearTimeout(this.playNextTimeout) clearTimeout(this.playNextTimeout)
clearInterval(this.playNextCountDownIntervalId) clearInterval(this.playNextCountDownIntervalId)
@ -943,14 +985,13 @@ export default Vue.extend({
const player = this.$refs.videoPlayer.player const player = this.$refs.videoPlayer.player
if (player !== null && !player.paused() && player.isInPictureInPicture()) { if (player !== null && !player.paused() && player.isInPictureInPicture()) {
const playerId = this.videoId
setTimeout(() => { setTimeout(() => {
player.play() player.play()
player.on('leavepictureinpicture', (event) => { player.on('leavepictureinpicture', (event) => {
const watchTime = player.currentTime() const watchTime = player.currentTime()
if (this.$route.fullPath.includes('/watch')) { if (this.$route.fullPath.includes('/watch')) {
const routeId = this.$route.params.id const routeId = this.$route.params.id
if (routeId === playerId) { if (routeId === videoId) {
const activePlayer = $('.ftVideoPlayer video').get(0) const activePlayer = $('.ftVideoPlayer video').get(0)
activePlayer.currentTime = watchTime activePlayer.currentTime = watchTime
} }
@ -966,23 +1007,23 @@ export default Vue.extend({
if (this.removeVideoMetaFiles) { if (this.removeVideoMetaFiles) {
const userData = await this.getUserDataPath() const userData = await this.getUserDataPath()
if (this.isDev) { if (this.isDev) {
const dashFileLocation = `static/dashFiles/${this.videoId}.xml` const dashFileLocation = `static/dashFiles/${videoId}.xml`
const vttFileLocation = `static/storyboards/${this.videoId}.vtt` const vttFileLocation = `static/storyboards/${videoId}.vtt`
// only delete the file it actually exists // only delete the file it actually exists
if (fs.existsSync('static/dashFiles/') && fs.existsSync(dashFileLocation)) { if (fs.existsSync(dashFileLocation)) {
fs.rmSync(dashFileLocation) fs.rmSync(dashFileLocation)
} }
if (fs.existsSync('static/storyboards/') && fs.existsSync(vttFileLocation)) { if (fs.existsSync(vttFileLocation)) {
fs.rmSync(vttFileLocation) fs.rmSync(vttFileLocation)
} }
} else { } else {
const dashFileLocation = `${userData}/dashFiles/${this.videoId}.xml` const dashFileLocation = `${userData}/dashFiles/${videoId}.xml`
const vttFileLocation = `${userData}/storyboards/${this.videoId}.vtt` const vttFileLocation = `${userData}/storyboards/${videoId}.vtt`
if (fs.existsSync(`${userData}/dashFiles/`) && fs.existsSync(dashFileLocation)) { if (fs.existsSync(dashFileLocation)) {
fs.rmSync(dashFileLocation) fs.rmSync(dashFileLocation)
} }
if (fs.existsSync(`${userData}/storyboards/`) && fs.existsSync(vttFileLocation)) { if (fs.existsSync(vttFileLocation)) {
fs.rmSync(vttFileLocation) fs.rmSync(vttFileLocation)
} }
} }
@ -1136,6 +1177,13 @@ export default Vue.extend({
label = `${this.$t('Locale Name')} (translated from English)` label = `${this.$t('Locale Name')} (translated from English)`
} }
const indexTranslated = captionTracks.findIndex((item) => {
return item.name.simpleText === label
})
if (indexTranslated !== -1) {
return
}
if (enCaptionExists) { if (enCaptionExists) {
url = new URL(captionTracks[enCaptionIdx].baseUrl) url = new URL(captionTracks[enCaptionIdx].baseUrl)
} else { } else {
@ -1240,7 +1288,8 @@ export default Vue.extend({
'updateWatchProgress', 'updateWatchProgress',
'getUserDataPath', 'getUserDataPath',
'ytGetVideoInformation', 'ytGetVideoInformation',
'invidiousGetVideoInformation' 'invidiousGetVideoInformation',
'updateSubscriptionDetails'
]) ])
} }
}) })

View File

@ -1,5 +1,5 @@
=dual-column-template =dual-column-template
grid-template: "video video sidebar" 0fr "info info sidebar" auto "info info sidebar" auto / 1fr 1fr 1fr grid-template: "video video sidebar" 0fr "info info sidebar" 1fr "info info sidebar" 1fr / 1fr 1fr 1fr
=theatre-mode-template =theatre-mode-template
grid-template: "video video video" auto "info info sidebar" auto "info info sidebar" auto / 1fr 1fr 1fr grid-template: "video video video" auto "info info sidebar" auto "info info sidebar" auto / 1fr 1fr 1fr
@ -29,7 +29,7 @@
grid-area: video grid-area: video
.videoAreaMargin .videoAreaMargin
margin: 0px 8px 16px margin: 0 0 16px
.videoPlayer .videoPlayer
grid-column: 1 grid-column: 1
@ -61,11 +61,20 @@
margin-top: 10px margin-top: 10px
.watchVideo .watchVideo
margin: 0px 8px 16px margin: 0 0 16px
grid-column: 1 grid-column: 1
.infoArea .infoArea
grid-area: info grid-area: info
position: relative
@media only screen and (min-width: 901px)
.infoArea
scroll-margin-top: 76px
.infoAreaSticky
position: sticky
top: 76px
.sidebarArea .sidebarArea
grid-area: sidebar grid-area: sidebar
@ -83,4 +92,7 @@
height: 500px height: 500px
.watchVideoRecommendations, .theatreRecommendations .watchVideoRecommendations, .theatreRecommendations
margin: 0 8px 16px margin: 0 0 16px
@media only screen and (min-width: 901px)
margin: 0 8px 16px

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