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",
{
"targets": {
"chrome": "73",
"node": 12
"chrome": "96",
"node": 16
}
}
],
"@babel/typescript"
]
],
"plugins": [
"@babel/proposal-class-properties",
"@babel/proposal-object-rest-spread"
"@babel/proposal-class-properties"
]
}

View File

@ -17,6 +17,8 @@ body:
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.
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
attributes:
label: Describe the bug
@ -113,4 +115,4 @@ body:
description: Please ensure you've completed the following, if applicable.
options:
- 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:
- 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
- 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
attributes:
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:
strategy:
matrix:
node-version: [14.x]
node-version: [16.x]
runtime: [ linux-x64, linux-arm64, win-x64, osx-x64 ]
include:
- runtime: linux-x64
os: ubuntu-latest
os: ubuntu-18.04
- runtime: linux-arm64
os: ubuntu-latest

View File

@ -6,7 +6,7 @@ name: Linter
# events but only for the master branch
on:
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
jobs:
@ -18,10 +18,10 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
- name: Use Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: 14.x
node-version: 16.x
cache: "yarn"
- run: npm run ci
- run: npm run lint

View File

@ -12,7 +12,7 @@ jobs:
build:
strategy:
matrix:
node-version: [14.x]
node-version: [16.x]
runtime: [ linux-x64, linux-arm64, win-x64, osx-x64 ]
include:
- 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).
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.
## 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 Package Manager (winget): [Usage](https://docs.microsoft.com/en-us/windows/package-manager/winget/)
### 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).

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
let targets
var platform = os.platform()
const platform = os.platform()
const cpus = os.cpus()
if (platform == 'darwin') {
targets = Platform.MAC.createTarget()
} else if (platform == 'win32') {
if (platform === 'darwin') {
let arch = Arch.x64
// 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()
} else if (platform == 'linux') {
} else if (platform === 'linux') {
let arch = Arch.x64
if (args[2] === 'arm64') {

View File

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

View File

@ -49,7 +49,7 @@ const config = {
path: path.join(__dirname, '../dist'),
},
resolve: {
extensions: ['.ts', '.js', '.json'],
extensions: ['.js', '.json'],
alias: {
'@': 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/'),
static: path.join(__dirname, '../static/'),
},
extensions: ['.ts', '.js', '.vue', '.json'],
extensions: ['.js', '.vue', '.json'],
},
target: 'electron-renderer',
}

View File

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

View File

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

View File

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

View File

@ -27,6 +27,10 @@ class Settings {
return db.settings.findOne({ _id: 'bounds' })
}
static _findTheme() {
return db.settings.findOne({ _id: 'baseTheme' })
}
static _updateBounds(value) {
return db.settings.update({ _id: 'bounds' }, { _id: 'bounds', value }, { upsert: true })
}
@ -38,11 +42,6 @@ class History {
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) {
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) {
return ipcRenderer.invoke(
IpcChannels.DB_HISTORY,

View File

@ -25,10 +25,6 @@ class History {
return baseHandlers.history.find()
}
static search(query) {
return baseHandlers.history.search(query)
}
static 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.history = Datastore.create({ filename: dbPath('history'), autoload: true })
db.history.ensureIndex({ fieldName: 'author' })
db.history.ensureIndex({ fieldName: 'title' })
export default db

View File

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

View File

@ -1,6 +1,6 @@
import {
app, BrowserWindow, dialog, Menu, ipcMain,
powerSaveBlocker, screen, session, shell
powerSaveBlocker, screen, session, shell, nativeTheme
} from 'electron'
import path from 'path'
import cp from 'child_process'
@ -25,7 +25,7 @@ function runApp() {
label: 'Show Video Statistics',
visible: parameters.mediaType === 'video',
click: () => {
browserWindow.webContents.send('showVideoStatistics', 'show')
browserWindow.webContents.send('showVideoStatistics')
}
}
]
@ -35,6 +35,7 @@ function runApp() {
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true'
const isDev = process.env.NODE_ENV === 'development'
const isDebug = process.argv.includes('--debug')
let mainWindow
let startupUrl
@ -146,7 +147,8 @@ function runApp() {
session.defaultSession.cookies.set({
url: url,
name: 'CONSENT',
value: 'YES+'
value: 'YES+',
sameSite: 'no_restriction'
})
})
@ -172,11 +174,33 @@ function runApp() {
}
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
*/
const commonBrowserWindowOptions = {
backgroundColor: '#212121',
backgroundColor: windowBackground,
darkTheme: nativeTheme.shouldUseDarkColors,
icon: isDev
? path.join(__dirname, '../../_icons/iconColor.png')
/* eslint-disable-next-line */
@ -191,6 +215,7 @@ function runApp() {
contextIsolation: false
}
}
const newWindow = new BrowserWindow(
Object.assign(
{
@ -241,6 +266,7 @@ function runApp() {
height: bounds.height
})
}
if (maximized) {
newWindow.maximize()
}
@ -345,6 +371,14 @@ function runApp() {
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) => {
console.log(url)
session.defaultSession.setProxy({
@ -372,11 +406,23 @@ function runApp() {
event.returnValue = app.getPath('userData')
})
ipcMain.handle(IpcChannels.GET_PICTURES_PATH, () => {
return app.getPath('pictures')
})
ipcMain.handle(IpcChannels.SHOW_OPEN_DIALOG, async (_, 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)
})
@ -388,10 +434,11 @@ function runApp() {
return powerSaveBlocker.start('prevent-display-sleep')
})
ipcMain.on(IpcChannels.CREATE_NEW_WINDOW, () => {
ipcMain.on(IpcChannels.CREATE_NEW_WINDOW, (_e, { windowStartupUrl = null } = { }) => {
createWindow({
replaceMainWindow: false,
showWindowNow: true
showWindowNow: true,
windowStartupUrl: windowStartupUrl
})
})
@ -456,9 +503,6 @@ function runApp() {
)
return null
case DBActions.HISTORY.SEARCH:
return await baseHandlers.history.search(data)
case DBActions.GENERAL.DELETE:
await baseHandlers.history.delete(data)
syncOtherWindows(
@ -726,7 +770,21 @@ function runApp() {
const template = [
{
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',

View File

@ -3,33 +3,29 @@
src: url(assets/font/Roboto-Regular.ttf);
}
body {
min-height: 100vh;
}
#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 {
margin-left: 200px;
margin-top: 80px;
transition-property: margin;
transition-duration: 150ms;
transition-timing-function: ease-in-out;
}
.expand {
margin-left: 80px;
flex: 1 1 0%;
margin: 18px 10px;
}
.banner {
width: 85%;
margin: 20px auto 0;
}
.banner-wrapper {
margin: 0 10px;
}
.flexBox {
margin-top: 60px;
margin-bottom: -75px;
display: block;
}
#changeLogText {
@ -46,13 +42,13 @@ body {
}
@media only screen and (max-width: 680px) {
.expand, .routerView {
margin-left: 0px;
margin-bottom: 80px;
.routerView {
margin: 68px 8px 68px;
}
.banner {
width: 90%;
width: 80%;
margin-top: 20px;
}
.flexBox {

View File

@ -12,6 +12,7 @@ import FtProgressBar from './components/ft-progress-bar/ft-progress-bar.vue'
import $ from 'jquery'
import { marked } from 'marked'
import Parser from 'rss-parser'
import { IpcChannels } from '../constants'
let ipcRenderer = null
@ -101,6 +102,22 @@ export default Vue.extend({
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 () {
return [
this.$t('Yes'),
@ -114,6 +131,13 @@ export default Vue.extend({
},
watch: {
windowTitle: 'setWindowTitle',
baseTheme: 'checkThemeSettings',
mainColor: 'checkThemeSettings',
secColor: 'checkThemeSettings',
$route () {
// react to route changes...
// Hide top nav filter panel on page change
@ -126,6 +150,8 @@ export default Vue.extend({
},
mounted: function () {
this.grabUserSettings().then(async () => {
this.checkThemeSettings()
await this.fetchInvidiousInstances({ isDev: this.isDev })
if (this.defaultInvidiousInstance === '') {
await this.setRandomCurrentInvidiousInstance()
@ -142,6 +168,7 @@ export default Vue.extend({
this.activateKeyboardShortcuts()
this.openAllLinksExternally()
this.enableOpenUrl()
this.watchSystemTheme()
await this.checkExternalPlayer()
}
@ -160,45 +187,27 @@ export default Vue.extend({
},
methods: {
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 = {
baseTheme: baseTheme,
mainColor: mainColor,
secColor: secColor
baseTheme: this.baseTheme || 'dark',
mainColor: this.mainColor || 'mainRed',
secColor: this.secColor || 'secBlue'
}
this.updateTheme(theme)
},
updateTheme: function (theme) {
console.log(theme)
const className = `${theme.baseTheme} ${theme.mainColor} ${theme.secColor}`
const body = document.getElementsByTagName('body')[0]
body.className = className
localStorage.setItem('baseTheme', theme.baseTheme)
localStorage.setItem('mainColor', theme.mainColor)
localStorage.setItem('secColor', theme.secColor)
console.group('updateTheme')
console.log('Theme: ', theme)
document.body.className = `${theme.baseTheme} main${theme.mainColor} sec${theme.secColor}`
document.body.dataset.systemTheme = this.systemTheme
console.groupEnd()
},
checkForNewUpdates: function () {
if (this.checkForUpdates) {
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) => {
const tagName = response[0].tag_name
@ -301,12 +310,21 @@ export default Vue.extend({
case 'ArrowLeft':
this.$refs.topNav.historyBack()
break
case 'KeyD':
this.$refs.topNav.focusSearch()
break
}
}
switch (event.code) {
case 'Tab':
this.hideOutlines = false
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) => {
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) {
const el = event.currentTarget
console.log(this.usingElectron)
console.log(el)
event.preventDefault()
// Check if it's a YouTube link
@ -331,7 +352,11 @@ export default Vue.extend({
const isYoutubeLink = youtubeUrlPattern.test(el.href)
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') {
// Let user know opening external link is disabled via setting
this.showToast({
@ -348,7 +373,7 @@ export default Vue.extend({
}
},
handleYoutubeLink: function (href) {
handleYoutubeLink: function (href, { doCreateNewWindow = false } = { }) {
this.getYoutubeUrlInfo(href).then((result) => {
switch (result.urlType) {
case 'video': {
@ -361,9 +386,11 @@ export default Vue.extend({
if (playlistId && playlistId.length > 0) {
query.playlistId = playlistId
}
this.$router.push({
path: `/watch/${videoId}`,
query: query
const path = `/watch/${videoId}`
this.openInternalPath({
path,
query,
doCreateNewWindow
})
break
}
@ -371,9 +398,11 @@ export default Vue.extend({
case 'playlist': {
const { playlistId, query } = result
this.$router.push({
path: `/playlist/${playlistId}`,
query
const path = `/playlist/${playlistId}`
this.openInternalPath({
path,
query,
doCreateNewWindow
})
break
}
@ -381,9 +410,11 @@ export default Vue.extend({
case 'search': {
const { searchQuery, query } = result
this.$router.push({
path: `/search/${encodeURIComponent(searchQuery)}`,
query
const path = `/search/${encodeURIComponent(searchQuery)}`
this.openInternalPath({
path,
query,
doCreateNewWindow
})
break
}
@ -404,8 +435,10 @@ export default Vue.extend({
case 'channel': {
const { channelId, subPath } = result
this.$router.push({
path: `/channel/${channelId}/${subPath}`
const path = `/channel/${channelId}/${subPath}`
this.openInternalPath({
path,
doCreateNewWindow
})
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 () {
ipcRenderer.on('openUrl', (event, url) => {
if (url) {
@ -473,7 +537,10 @@ export default Vue.extend({
'getExternalPlayerCmdArgumentsData',
'fetchInvidiousInstances',
'setRandomCurrentInvidiousInstance',
'setupListenersToSyncWindows'
'setupListenersToSyncWindows',
'updateBaseTheme',
'updateMainColor',
'updateSecColor'
])
}
})

View File

@ -1,5 +1,6 @@
<template>
<div
v-if="dataReady"
id="app"
:class="{
hideOutlines: hideOutlines,
@ -9,36 +10,39 @@
<top-nav ref="topNav" />
<side-nav ref="sideNav" />
<ft-flex-box
v-if="showUpdatesBanner || showBlogBanner"
class="flexBox routerView"
:class="{ expand: !isOpen }"
>
<ft-notification-banner
v-if="showUpdatesBanner"
class="banner"
:message="updateBannerMessage"
@click="handleUpdateBannerClick"
/>
<ft-notification-banner
v-if="showBlogBanner"
class="banner"
:message="blogBannerMessage"
@click="handleNewBlogBannerClick"
/>
</ft-flex-box>
<transition
v-if="dataReady"
mode="out-in"
name="fade"
>
<!-- <keep-alive> -->
<RouterView
ref="router"
class="routerView"
:class="{ expand: !isOpen }"
/>
<div
v-if="showUpdatesBanner || showBlogBanner"
class="banner-wrapper"
>
<ft-notification-banner
v-if="showUpdatesBanner"
class="banner"
:message="updateBannerMessage"
@click="handleUpdateBannerClick"
/>
<ft-notification-banner
v-if="showBlogBanner"
class="banner"
:message="blogBannerMessage"
@click="handleNewBlogBannerClick"
/>
</div>
<transition
v-if="dataReady"
mode="out-in"
name="fade"
>
<!-- <keep-alive> -->
<RouterView
ref="router"
class="routerView"
/>
<!-- </keep-alive> -->
</transition>
</transition>
</ft-flex-box>
<ft-prompt
v-if="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
}
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 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 === '') {
// User canceled the save dialog
return
@ -764,7 +766,7 @@ export default Vue.extend({
return object
})
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
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 === '') {
// User canceled the save dialog
return
@ -855,10 +857,14 @@ export default Vue.extend({
let exportText = 'Channel ID,Channel URL,Channel title\n'
this.profileList[0].subscriptions.forEach((channel) => {
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'
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return
@ -911,7 +917,7 @@ export default Vue.extend({
newPipeObject.subscriptions.push(subscription)
})
const response = await this.showSaveDialog(options)
const response = await this.showSaveDialog({ options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
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 === '') {
// User canceled the save dialog
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 === '') {
// User canceled the save dialog
return
@ -1303,7 +1309,7 @@ export default Vue.extend({
getChannelInfoLocal: function (channelId) {
return new Promise((resolve, reject) => {
ytch.getChannelInfo(channelId, 'latest').then(async (response) => {
ytch.getChannelInfo({ channelId: channelId }).then(async (response) => {
resolve(response)
}).catch((err) => {
console.log(err)

View File

@ -1,6 +1,7 @@
import Vue from 'vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.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 FtInput from '../ft-input/ft-input.vue'
import { mapActions } from 'vuex'
@ -12,19 +13,36 @@ export default Vue.extend({
components: {
'ft-toggle-switch': FtToggleSwitch,
'ft-flex-box': FtFlexBox,
'ft-select': FtSelect,
'ft-button': FtButton,
'ft-input': FtInput
},
data: function () {
return {
askForDownloadPath: this.$store.getters.getDownloadFolderPath === ''
askForDownloadPath: false,
downloadBehaviorValues: [
'download',
'open'
]
}
},
computed: {
downloadPath: function() {
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: {
handleDownloadingSettingChange: function (value) {
this.askForDownloadPath = value
@ -42,7 +60,8 @@ export default Vue.extend({
this.updateDownloadFolderPath(folder.filePaths[0])
},
...mapActions([
'updateDownloadFolderPath'
'updateDownloadFolderPath',
'updateDownloadBehavior'
])
}

View File

@ -6,7 +6,19 @@
</h3>
</summary>
<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
:label="$t('Settings.Download Settings.Ask Download Path')"
:default-value="askForDownloadPath"
@ -14,7 +26,7 @@
/>
</ft-flex-box>
<ft-flex-box
v-if="!askForDownloadPath"
v-if="!askForDownloadPath && downloadBehavior === 'download'"
>
<ft-input
class="folderDisplay"
@ -25,7 +37,7 @@
/>
</ft-flex-box>
<ft-flex-box
v-if="!askForDownloadPath"
v-if="!askForDownloadPath && downloadBehavior === 'download'"
>
<ft-button
:label="$t('Settings.Download Settings.Choose Path')"

View File

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

View File

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

View File

@ -83,7 +83,7 @@
list-style-type: none
.listItem
padding: 10px
padding: 8px 10px
margin: 0
white-space: nowrap
cursor: pointer
@ -96,3 +96,11 @@
&:active
background-color: var(--side-nav-active-color)
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>
<ul
v-if="dropdownNames.length > 0"
v-if="dropdownOptions.length > 0"
class="list"
>
<li
v-for="(label, index) in dropdownNames"
v-for="(option, index) in dropdownOptions"
:key="index"
class="listItem"
@click="handleDropdownClick(index)"
:class="option.type === 'divider' ? 'listItemDivider' : 'listItem'"
@click="handleDropdownClick({url: option.value, index: index})"
>
{{ label }}
{{ option.type === 'divider' ? '' : option.label }}
</li>
</ul>
</slot>

View File

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

View File

@ -87,28 +87,6 @@ export default Vue.extend({
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 () {
this.id = this._uid
this.inputData = this.value
@ -136,14 +114,20 @@ export default Vue.extend({
},
handleClearTextClick: function () {
// No action if no input text
if (!this.inputDataPresent) { return }
this.inputData = ''
this.handleActionIconChange()
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
const inputElement = document.getElementById(this.id)
inputElement.focus()
this.$emit('clear')
},
handleActionIconChange: function() {
@ -200,7 +184,7 @@ export default Vue.extend({
if (inputElement !== null) {
inputElement.addEventListener('keydown', (event) => {
if (event.keyCode === 13) {
if (event.key === 'Enter') {
this.handleClick()
}
})
@ -214,14 +198,14 @@ export default Vue.extend({
this.handleClick()
},
handleKeyDown: function (keyCode) {
if (this.dataList.length === 0) { return }
handleKeyDown: function (event) {
if (this.visibleDataList.length === 0) { return }
// Update selectedOption based on arrow key pressed
if (keyCode === 40) {
this.searchState.selectedOption = (this.searchState.selectedOption + 1) % this.dataList.length
} else if (keyCode === 38) {
if (this.searchState.selectedOption === -1) {
this.searchState.selectedOption = this.dataList.length - 1
if (event.key === 'ArrowDown') {
this.searchState.selectedOption = (this.searchState.selectedOption + 1) % this.visibleDataList.length
} else if (event.key === 'ArrowUp') {
if (this.searchState.selectedOption < 1) {
this.searchState.selectedOption = this.visibleDataList.length - 1
} else {
this.searchState.selectedOption--
}
@ -230,14 +214,13 @@ export default Vue.extend({
}
// Key pressed isn't enter
if (keyCode !== 13) {
if (event.key !== 'Enter') {
this.searchState.showOptions = true
}
// 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]
} else {
this.updateVisibleDataList()
}
},

View File

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

View File

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

View File

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

View File

@ -1 +1,4 @@
@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">
<ft-icon-button
class="optionsButton"
icon="ellipsis-v"
title="More Options"
theme="base-no-default"
:size="16"
:use-shadow="false"
dropdown-position-x="left"
:dropdown-names="optionsNames"
:dropdown-values="optionsValues"
:dropdown-options="dropdownOptions"
@click="handleOptionsClick"
/>
<router-link

View File

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

View File

@ -29,10 +29,15 @@
width: auto
@at-root
.dark &
.dark &, .system[data-system-theme*='dark'] &
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)
.invidious
@ -48,8 +53,11 @@
margin-right: 2px
@at-root
.dark &
.dark &,
.black &,
.dracula &,
.system[data-system-theme*='dark'] &
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)

View File

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

View File

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

View File

@ -5,11 +5,12 @@
<input
:id="id"
v-model.number="currentValue"
:disabled="disabled"
type="range"
:min="minValue"
:max="maxValue"
:step="step"
@change="$emit('change', $event.target.value)"
@change="$emit('change', currentValue)"
>
<span>
{{ 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 qualitySelector from '@silvermine/videojs-quality-selector'
import fs from 'fs'
import path from 'path'
import 'videojs-overlay/dist/videojs-overlay'
import 'videojs-overlay/dist/videojs-overlay.css'
import 'videojs-vtt-thumbnails-freetube'
import 'videojs-contrib-quality-levels'
import 'videojs-http-source-selector'
import { ipcRenderer } from 'electron'
import { IpcChannels } from '../../../constants'
@ -85,6 +85,10 @@ export default Vue.extend({
useHls: false,
selectedDefaultQuality: '',
selectedQuality: '',
selectedResolution: '',
selectedBitrate: '',
selectedMimeType: '',
selectedFPS: 0,
using60Fps: false,
maxFramerate: 0,
activeSourceList: [],
@ -92,6 +96,10 @@ export default Vue.extend({
mouseTimeout: null,
touchTimeout: null,
lastTouchTime: null,
playerStats: null,
statsModal: null,
showStatsModal: false,
statsModalEventName: 'updateStats',
dataSetup: {
fluid: true,
nativeTextTracks: false,
@ -108,6 +116,7 @@ export default Vue.extend({
'seekToLive',
'remainingTimeDisplay',
'customControlSpacer',
'screenshotButton',
'playbackRateMenuButton',
'loopButton',
'chaptersButton',
@ -119,56 +128,6 @@ export default Vue.extend({
'qualitySelector',
'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() {
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";>'
for (const stat of stats) {
formattedStats += `<li style="font-size: 75%">${stat[0]}: ${stat[1]}</li>`
sponsorSkips: function () {
const sponsorCats = ['sponsor',
'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: {
selectedQuality: function() {
this.currentFps()
showStatsModal: function() {
this.player.trigger(this.statsModalEventName)
},
enableScreenshot: function() {
this.toggleScreenshotButton()
}
},
mounted: function () {
@ -270,11 +305,19 @@ export default Vue.extend({
this.volume = volume
}
this.dataSetup.playbackRates = this.playbackRates
this.createFullWindowButton()
this.createLoopButton()
this.createToggleTheatreModeButton()
this.createScreenshotButton()
this.determineFormatType()
this.determineMaxFramerate()
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => this.player.play())
navigator.mediaSession.setActionHandler('pause', () => this.player.pause())
}
},
beforeDestroy: function () {
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) {
const { ipcRenderer } = require('electron')
ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker)
@ -411,20 +460,34 @@ export default Vue.extend({
this.player.on('ready', () => {
this.$emit('ready')
this.checkAspectRatio()
this.createStatsModal()
if (this.captionHybridList.length !== 0) {
this.transformAndInsertCaptions()
}
this.toggleScreenshotButton()
})
this.player.on('ended', () => {
this.$emit('ended')
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'
}
})
this.player.on('error', (error, message) => {
this.$emit('error', error.target.player.error_)
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'
}
})
this.player.on('play', async function () {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing'
}
if (this.usingElectron) {
const { ipcRenderer } = require('electron')
this.powerSaveBlocker =
@ -433,6 +496,10 @@ export default Vue.extend({
})
this.player.on('pause', function () {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'paused'
}
if (this.usingElectron && this.powerSaveBlocker !== null) {
const { ipcRenderer } = require('electron')
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', (_) => {
const settings = this.player.textTrackSettings.getValues()
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() {
this.sponsorBlockSkipSegments({
videoId: this.videoId,
categories: ['sponsor']
categories: this.sponsorSkips.seekBar
}).then((skipSegments) => {
if (skipSegments.length === 0) {
return
@ -469,7 +561,8 @@ export default Vue.extend({
this.addSponsorBlockMarker({
time: 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 (this.sponsorBlockShowSkippedToast) {
this.showSkippedSponsorSegmentInformation(skippedCategory)
if (this.sponsorSkips.autoSkip[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')
case 'music_offtopic':
return this.$t('Video.Sponsor Block category.music offtopic')
case 'filler':
return this.$t('Video.Sponsor Block category.filler')
default:
console.error(`Unknown translation for SponsorBlock category ${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) {
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.position = 'absolute'
markerDiv.style.opacity = '0.6'
markerDiv.style['background-color'] = marker.color
markerDiv.style.width = (marker.duration / 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)
},
@ -851,6 +929,18 @@ export default Vue.extend({
qualityElement.innerText = 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()
$('.quality-item').removeClass('quality-selected')
@ -999,7 +1089,7 @@ export default Vue.extend({
changePlayBackRate: function (rate) {
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)
}
},
@ -1012,7 +1102,7 @@ export default Vue.extend({
if (this.maxFramerate === 60 && quality.height >= 480) {
for (let i = 0; i < this.adaptiveFormats.length; i++) {
if (this.adaptiveFormats[i].bitrate === quality.bitrate) {
fps = this.adaptiveFormats[i].fps
fps = this.adaptiveFormats[i].fps ? this.adaptiveFormats[i].fps : 30
break
}
}
@ -1077,8 +1167,8 @@ export default Vue.extend({
toggleVideoLoop: async function () {
if (!this.player.loop()) {
const currentTheme = localStorage.getItem('mainColor')
const colorNames = this.$store.state.utils.colorClasses
const currentTheme = this.$store.state.settings.mainColor
const colorNames = this.$store.state.utils.colorNames
const colorValues = this.$store.state.utils.colorValues
const nameIndex = colorNames.findIndex((color) => {
@ -1162,6 +1252,168 @@ export default Vue.extend({
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) {
if (levels.levels_.length === 0) {
setTimeout(() => {
@ -1209,6 +1461,10 @@ export default Vue.extend({
return format.bitrate === quality.bitrate
})
if (typeof adaptiveFormat === 'undefined') {
return
}
this.activeAdaptiveFormats.push(adaptiveFormat)
fps = adaptiveFormat.fps ? adaptiveFormat.fps : 30
@ -1426,7 +1682,71 @@ export default Vue.extend({
handleTouchEnd: function (event) {
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) {
const activeInputs = $('.ft-input')
@ -1452,7 +1772,7 @@ export default Vue.extend({
// J Key
// Rewind by 2x the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(-this.defaultSkipInterval * 2)
this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate() * 2)
break
case 75:
// K Key
@ -1464,19 +1784,19 @@ export default Vue.extend({
// L Key
// Fast-Forward by 2x the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(this.defaultSkipInterval * 2)
this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate() * 2)
break
case 79:
// O Key
// Decrease playback rate by 0.25x
event.preventDefault()
this.changePlayBackRate(-0.25)
this.changePlayBackRate(-this.videoPlaybackRateInterval)
break
case 80:
// P Key
// Increase playback rate by 0.25x
event.preventDefault()
this.changePlayBackRate(0.25)
this.changePlayBackRate(this.videoPlaybackRateInterval)
break
case 70:
// F Key
@ -1512,13 +1832,23 @@ export default Vue.extend({
// Left Arrow Key
// Rewind by the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(-this.defaultSkipInterval * 1)
this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate())
break
case 39:
// Right Arrow Key
// Fast-Forward by the time-skip interval (in seconds)
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
case 49:
// 1 Key
@ -1592,12 +1922,8 @@ export default Vue.extend({
break
case 68:
// D Key
// Toggle Picture in Picture Mode
if (!this.player.isInPictureInPicture()) {
this.player.requestPictureInPicture()
} else if (this.player.isInPictureInPicture()) {
this.player.exitPictureInPicture()
}
event.preventDefault()
this.toggleShowStatsModal()
break
case 27:
// esc Key
@ -1615,86 +1941,11 @@ export default Vue.extend({
// Toggle Theatre Mode
this.toggleTheatreMode()
break
}
}
},
addPlayerStatsEvent: function() {
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
case 85:
// U Key
// Take screenshot
this.takeScreenshot()
break
}
}
},
@ -1703,7 +1954,11 @@ export default Vue.extend({
'calculateColorLuminance',
'updateDefaultCaptionSettings',
'showToast',
'sponsorBlockSkipSegments'
'sponsorBlockSkipSegments',
'parseScreenshotCustomFileName',
'updateScreenshotFolderPath',
'getPicturesPath',
'showSaveDialog'
])
}
})

View File

@ -7,6 +7,7 @@
controls
preload="auto"
:data-setup="JSON.stringify(dataSetup)"
crossorigin="anonymous"
@touchstart="handleTouchStart"
@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 FtSlider from '../ft-slider/ft-slider.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({
name: 'PlayerSettings',
@ -13,7 +19,10 @@ export default Vue.extend({
'ft-select': FtSelect,
'ft-toggle-switch': FtToggleSwitch,
'ft-slider': FtSlider,
'ft-flex-box': FtFlexBox
'ft-flex-box': FtFlexBox,
'ft-button': FtButton,
'ft-input': FtInput,
'ft-tooltip': FtTooltip
},
data: function () {
return {
@ -30,7 +39,24 @@ export default Vue.extend({
480,
720,
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: {
@ -106,6 +132,14 @@ export default Vue.extend({
return this.$store.getters.getDisplayVideoPlayButton
},
maxVideoPlaybackRate: function () {
return parseInt(this.$store.getters.getMaxVideoPlaybackRate)
},
videoPlaybackRateInterval: function () {
return this.$store.getters.getVideoPlaybackRateInterval
},
formatNames: function () {
return [
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.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: {
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([
'updateAutoplayVideos',
'updateAutoplayPlaylists',
@ -143,7 +269,17 @@ export default Vue.extend({
'updateDefaultQuality',
'updateVideoVolumeMouseScroll',
'updateVideoPlaybackRateMouseScroll',
'updateDisplayVideoPlayButton'
'updateDisplayVideoPlayButton',
'updateMaxVideoPlaybackRate',
'updateVideoPlaybackRateInterval',
'updateEnableScreenshot',
'updateScreenshotFormat',
'updateScreenshotQuality',
'updateScreenshotAskPath',
'updateScreenshotFolderPath',
'updateScreenshotFilenamePattern',
'parseScreenshotCustomFileName',
'getPicturesPath'
])
}
})

View File

@ -1 +1,14 @@
@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="×"
@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-select
@ -133,6 +149,88 @@
@change="updateDefaultQuality"
/>
</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>
</template>

View File

@ -3,32 +3,44 @@
.playlistThumbnail img
width: 100%
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
max-height: 20vh
overflow-y: auto
white-space: break-spaces
@media only screen and (max-width: 500px)
max-height: 10vh
.playlistChannel
height: 70px
display: flex
align-items: center
gap: 8px
height: 40px
/* Indicates the box can be clicked to navigate */
cursor: pointer
.channelThumbnail
width: 70px
width: 40px
float: left
border-radius: 200px 200px 200px 200px
-webkit-border-radius: 200px 200px 200px 200px
.channelName
float: left
position: relative
width: 200px
margin-left: 10px
top: 5px
margin: 0
font-size: 15px

View File

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

View File

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

View File

@ -45,7 +45,7 @@
class="center"
: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>
<ft-flex-box>
<ft-button

View File

@ -3,11 +3,10 @@
height: calc(100vh - 60px);
width: 200px;
overflow-x: hidden;
position: fixed;
left: 0px;
top: 0px;
z-index: 4;
margin-top: 60px;
position: sticky;
left: 0;
top: 60px;
z-index: 3;
box-shadow: 1px -1px 1px -1px var(--primary-shadow-color);
background-color: var(--side-nav-color);
transition-property: width;
@ -168,6 +167,10 @@
}
.sideNav {
position: fixed;
left: 0;
bottom: 0;
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 FtInput from '../ft-input/ft-input.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({
name: 'SponsorBlockSettings',
@ -11,7 +12,22 @@ export default Vue.extend({
'ft-card': FtCard,
'ft-toggle-switch': FtToggleSwitch,
'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: {
useSponsorBlock: function () {

View File

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

View File

@ -19,10 +19,6 @@ export default Vue.extend({
},
data: function () {
return {
currentBaseTheme: '',
currentMainColor: '',
currentSecColor: '',
expandSideBar: false,
minUiScale: 50,
maxUiScale: 300,
uiScaleStep: 5,
@ -33,35 +29,11 @@ export default Vue.extend({
'no'
],
baseThemeValues: [
'system',
'light',
'dark',
'black',
'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
},
baseTheme: function () {
return this.$store.getters.getBaseTheme
},
mainColor: function () {
return this.$store.getters.getMainColor
},
secColor: function () {
return this.$store.getters.getSecColor
},
isSideNavOpen: function () {
return this.$store.getters.getIsSideNavOpen
},
@ -81,9 +65,15 @@ export default Vue.extend({
disableSmoothScrolling: function () {
return this.$store.getters.getDisableSmoothScrolling
},
expandSideBar: function () {
return this.$store.getters.getExpandSideBar
},
hideLabelsSideBar: function () {
return this.$store.getters.getHideLabelsSideBar
},
restartPromptMessage: function () {
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 () {
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.Dark'),
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 () {
return [
this.$t('Settings.Theme Settings.Main Color Theme.Red'),
this.$t('Settings.Theme Settings.Main Color Theme.Pink'),
this.$t('Settings.Theme Settings.Main Color Theme.Purple'),
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')
]
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}`)
})
}
},
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
},
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) {
if (this.isSideNavOpen !== value) {
this.$store.commit('toggleSideNav')
}
this.expandSideBar = value
localStorage.setItem('expandSideBar', value)
this.updateExpandSideBar(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([
'updateBarColor',
'updateBaseTheme',
'updateMainColor',
'updateSecColor',
'updateExpandSideBar',
'updateUiScale',
'updateDisableSmoothScrolling',
'updateHideLabelsSideBar'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -269,6 +269,11 @@ export default Vue.extend({
comment.likes = null
}
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
})

View File

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

View File

@ -4,7 +4,8 @@
}
.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;
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@ -118,12 +118,7 @@ export default Vue.extend({
},
data: function () {
return {
formatTypeLabel: 'VIDEO FORMATS',
formatTypeValues: [
'dash',
'legacy',
'audio'
]
formatTypeLabel: 'VIDEO FORMATS'
}
},
computed: {
@ -175,23 +170,33 @@ export default Vue.extend({
return this.inFavoritesPlaylist ? 'base favorite' : 'base'
},
downloadLinkNames: function () {
downloadLinkOptions: function () {
return this.downloadLinks.map((download) => {
return download.label
return {
label: download.label,
value: download.url
}
})
},
downloadLinkValues: function () {
return this.downloadLinks.map((download) => {
return download.url
})
downloadBehavior: function () {
return this.$store.getters.getDownloadBehavior
},
formatTypeNames: function () {
formatTypeOptions: function () {
return [
this.$t('Change Format.Use Dash Formats').toUpperCase(),
this.$t('Change Format.Use Legacy Formats').toUpperCase(),
this.$t('Change Format.Use Audio Formats').toUpperCase()
{
label: this.$t('Change Format.Use Dash 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: {
@ -298,6 +315,7 @@ export default Vue.extend({
watchProgress: this.getTimestamp(),
playbackRate: this.defaultPlayback,
videoId: this.id,
videoLength: this.lengthSeconds,
playlistId: this.playlistId,
playlistIndex: this.getPlaylistIndex(),
playlistReverse: this.getPlaylistReverse(),
@ -409,15 +427,20 @@ export default Vue.extend({
},
handleDownload: function (index) {
const url = this.downloadLinkValues[index]
const linkName = this.downloadLinkNames[index]
const selectedDownloadLinkOption = this.downloadLinkOptions[index]
const url = selectedDownloadLinkOption.value
const linkName = selectedDownloadLinkOption.label
const extension = this.grabExtensionFromUrl(linkName)
this.downloadMedia({
url: url,
title: this.title,
extension: extension
})
if (this.downloadBehavior === 'open') {
this.openExternalLink(url)
} else {
this.downloadMedia({
url: url,
title: this.title,
extension: extension
})
}
},
grabExtensionFromUrl: function (url) {

View File

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

View File

@ -98,6 +98,17 @@ export default Vue.extend({
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: {
goToPlaylist: function () {

View File

@ -4,7 +4,6 @@ import App from './App.vue'
import router from './router/index'
import store from './store/index'
import i18n from './i18n/index'
// import 'material-design-icons/iconfont/material-icons.css'
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons/faGithub'
@ -32,7 +31,7 @@ new Vue({
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') {
const { ipcRenderer } = require('electron')

View File

@ -1,16 +1,12 @@
import { DBHistoryHandlers } from '../../../datastores/handlers/index'
const state = {
historyCache: [],
searchHistoryCache: []
historyCache: []
}
const getters = {
getHistoryCache: () => {
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 }) {
try {
await DBHistoryHandlers.updateWatchProgress(videoId, watchProgress)
@ -79,10 +66,6 @@ const mutations = {
state.historyCache = historyCache
},
setSearchHistoryCache(state, result) {
state.searchHistoryCache = result
},
hoistEntryToTopOfHistoryCache(state, { currentIndex, updatedEntry }) {
state.historyCache.splice(currentIndex, 1)
state.historyCache.unshift(updatedEntry)

View File

@ -13,20 +13,14 @@ const state = {
removeOnWatched: true,
videos: []
}
],
searchPlaylistCache: {
videos: []
}
]
}
const getters = {
getAllPlaylists: () => state.playlists,
getFavorites: () => state.playlists[0],
getPlaylist: (playlistId) => state.playlists.find(playlist => playlist._id === playlistId),
getWatchLater: () => state.playlists[1],
getSearchPlaylistCache: () => {
return state.searchPlaylistCache
}
getWatchLater: () => state.playlists[1]
}
const actions = {
@ -136,25 +130,10 @@ const actions = {
} catch (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 = {
setPlaylistCache(state, result) {
state.searchPlaylistCache = {
videos: result
}
},
addPlaylist(state, payload) {
state.playlists.push(payload)
},

View File

@ -89,6 +89,31 @@ const actions = {
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) {
try {
const newProfile = await DBProfileHandlers.create(profile)

View File

@ -167,7 +167,9 @@ const state = {
barColor: false,
checkForBlogPosts: true,
checkForUpdates: true,
// currentTheme: 'lightRed',
baseTheme: 'system',
mainColor: 'Red',
secColor: 'Blue',
defaultCaptionSettings: '{}',
defaultInterval: 5,
defaultPlayback: 1,
@ -185,6 +187,7 @@ const state = {
externalPlayerExecutable: '',
externalPlayerIgnoreWarnings: false,
externalPlayerCustomArgs: '',
expandSideBar: false,
forceLocalBackendForLegacy: false,
hideActiveSubscriptions: false,
hideChannelSubscriptions: false,
@ -200,6 +203,7 @@ const state = {
hideLabelsSideBar: false,
landingPage: 'subscriptions',
listType: 'grid',
maxVideoPlaybackRate: 3,
playNextVideo: false,
proxyHostname: '127.0.0.1',
proxyPort: '9050',
@ -211,13 +215,53 @@ const state = {
saveWatchedProgress: true,
sponsorBlockShowSkippedToast: true,
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: '',
useProxy: false,
useRssFeeds: false,
useSponsorBlock: false,
videoVolumeMouseScroll: 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 = {
@ -228,12 +272,31 @@ const stateWithSideEffects = {
let targetLocale = value
if (value === 'system') {
const systemLocale = await dispatch('getSystemLocale')
targetLocale = Object.keys(i18n.messages).find((locale) => {
const localeName = locale.replace('-', '_')
return localeName.includes(systemLocale.replace('-', '_'))
const systemLocaleName = (await dispatch('getSystemLocale')).replace('-', '_') // ex: en_US
const systemLocaleLang = systemLocaleName.split('_')[0] // ex: en
const targetLocaleOptions = Object.keys(i18n.messages).filter((locale) => { // filter out other languages
const localeLang = locale.replace('-', '_').split('_')[0]
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
if (!targetLocale) {
@ -322,7 +385,9 @@ const customActions = {
dispatch(defaultSideEffectsTriggerId(_id), value)
}
commit(defaultMutationId(_id), value)
if (Object.keys(mutations).includes(defaultMutationId(_id))) {
commit(defaultMutationId(_id), value)
}
}
} catch (errMessage) {
console.error(errMessage)

View File

@ -1,5 +1,4 @@
import $ from 'jquery'
import forge from 'node-forge'
const state = {}
const getters = {}
@ -7,22 +6,30 @@ const getters = {}
const actions = {
sponsorBlockSkipSegments ({ rootState }, { videoId, categories }) {
return new Promise((resolve, reject) => {
const messageDigestSha256 = forge.md.sha256.create()
messageDigestSha256.update(videoId)
const videoIdHashPrefix = messageDigestSha256.digest().toHex().substring(0, 4)
const requestUrl = `${rootState.settings.sponsorBlockUrl}/api/skipSegments/${videoIdHashPrefix}?categories=${JSON.stringify(categories)}`
const videoIdBuffer = new TextEncoder().encode(videoId)
$.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)
crypto.subtle.digest('SHA-256', videoIdBuffer).then((hashBuffer) => {
const hashArray = Array.from(new Uint8Array(hashBuffer))
const videoIdHashPrefix = hashArray
.map(byte => byte.toString(16).padStart(2, '0'))
.slice(0, 4)
.join('')
const requestUrl = `${rootState.settings.sponsorBlockUrl}/api/skipSegments/${videoIdHashPrefix}?categories=${JSON.stringify(categories)}`
$.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: [],
profileSubscriptions: {
activeProfile: MAIN_PROFILE_ID,
videoList: []
videoList: [],
errorChannels: []
}
}

View File

@ -1,10 +1,10 @@
import IsEqual from 'lodash.isequal'
import FtToastEvents from '../../components/ft-toast/ft-toast-events'
import fs from 'fs'
import path from 'path'
import i18n from '../../i18n/index'
import { IpcChannels } from '../../../constants'
import { ipcRenderer } from 'electron'
const state = {
isSideNavOpen: false,
@ -27,30 +27,30 @@ const state = {
type: 'all',
duration: ''
},
colorClasses: [
'mainRed',
'mainPink',
'mainPurple',
'mainDeepPurple',
'mainIndigo',
'mainBlue',
'mainLightBlue',
'mainCyan',
'mainTeal',
'mainGreen',
'mainLightGreen',
'mainLime',
'mainYellow',
'mainAmber',
'mainOrange',
'mainDeepOrange',
'mainDraculaCyan',
'mainDraculaGreen',
'mainDraculaOrange',
'mainDraculaPink',
'mainDraculaPurple',
'mainDraculaRed',
'mainDraculaYellow'
colorNames: [
'Red',
'Pink',
'Purple',
'DeepPurple',
'Indigo',
'Blue',
'LightBlue',
'Cyan',
'Teal',
'Green',
'LightGreen',
'Lime',
'Yellow',
'Amber',
'Orange',
'DeepOrange',
'DraculaCyan',
'DraculaGreen',
'DraculaOrange',
'DraculaPink',
'DraculaPurple',
'DraculaRed',
'DraculaYellow'
],
colorValues: [
'#d50000',
@ -107,6 +107,10 @@ const getters = {
return state.searchSettings
},
getColorNames () {
return state.colorNames
},
getColorValues () {
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 }) {
const fileName = `${title}.${extension}`
const fileName = `${await dispatch('replaceFilenameForbiddenChars', title)}.${extension}`
const usingElectron = rootState.settings.usingElectron
const locale = i18n._vm.locale
const translations = i18n._vm.messages[locale]
@ -197,11 +234,12 @@ const actions = {
defaultPath: fileName,
filters: [
{
name: extension.toUpperCase(),
extensions: [extension]
}
]
}
const response = await dispatch('showSaveDialog', options)
const response = await dispatch('showSaveDialog', { options })
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
@ -209,6 +247,19 @@ const actions = {
}
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', {
@ -223,8 +274,6 @@ const actions = {
})
const reader = response.body.getReader()
const contentLength = response.headers.get('Content-Length')
let receivedLength = 0
const chunks = []
const handleError = (err) => {
@ -240,9 +289,10 @@ const actions = {
}
chunks.push(value)
receivedLength += value.length
// 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)
}
@ -281,10 +331,10 @@ const actions = {
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
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) {
@ -293,13 +343,73 @@ const actions = {
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) {
commit('setShowProgressBar', value)
},
getRandomColorClass () {
const randomInt = Math.floor(Math.random() * state.colorClasses.length)
return state.colorClasses[randomInt]
const randomInt = Math.floor(Math.random() * state.colorNames.length)
return 'main' + state.colorNames[randomInt]
},
getRandomColor () {
@ -832,7 +942,7 @@ const actions = {
args.push(...defaultCustomArguments)
}
if (payload.watchProgress > 0) {
if (payload.watchProgress > 0 && payload.watchProgress < payload.videoLength - 10) {
if (typeof cmdArgs.startOffset === 'string') {
args.push(`${cmdArgs.startOffset}${payload.watchProgress}`)
} else {
@ -973,7 +1083,7 @@ const mutations = {
})
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
} else {
state.sessionSearchHistory.push(payload)
@ -984,7 +1094,7 @@ const mutations = {
state.popularCache = value
},
setTrendingCache (state, value, page) {
setTrendingCache (state, { value, page }) {
state.trendingCache[page] = value
},

View File

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

View File

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

View File

@ -350,7 +350,7 @@
line-height: 1;
font-weight: 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;
}
.video-js:-moz-full-screen {
@ -490,6 +490,16 @@ body.vjs-full-window {
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) {
.videoPlayer .vjs-button-theatre {
display: none
@ -1023,7 +1033,7 @@ body.vjs-full-window {
padding: 6px 8px 8px 8px;
pointer-events: none;
position: absolute;
top: -3.4em;
top: -2.7em;
visibility: hidden;
z-index: 2;
}
@ -2136,7 +2146,8 @@ video::-webkit-media-text-track-display {
.video-js .vjs-vtt-thumbnail-display {
position: absolute;
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;
box-shadow: 0 0 7px rgba(0,0,0,.6);
z-index: 3;
@ -2160,3 +2171,24 @@ video::-webkit-media-text-track-display {
font-size: xx-large;
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;
width: 85%;
margin: 0 auto 20px;
box-sizing: border-box;
}
.channelBanner {
width: 100%;
max-height: 200px;
.channelDetails {
padding: 0 0 16px;
}
.defaultChannelBanner {
.channelBannerContainer {
background: center / cover no-repeat var(--banner-url, transparent);
height: 13vw;
min-height: 110px;
max-height: 32vh;
width: 100%;
max-height: 200px;
height:200px;
background-color: black;
}
.channelBannerContainer.default {
background-image: url("images/defaultBanner.png");
background-repeat: repeat;
background-size: contain;
}
.channelInfoContainer {
width: 100%;
position: relative;
background-color: var(--card-bg-color);
margin-top: 10px;
padding: 0 16px;
}
.channelInfo {
@ -102,7 +108,7 @@
}
.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;
white-space: pre-wrap;
}
@ -143,3 +149,8 @@
flex-direction: column;
padding-left: 1em;
}
.channelLineContainer h1,
.channelLineContainer p {
margin: 0;
}

View File

@ -50,6 +50,7 @@ export default Vue.extend({
searchResults: [],
shownElementList: [],
apiUsed: '',
errorMessage: '',
videoSelectValues: [
'newest',
'oldest',
@ -90,16 +91,14 @@ export default Vue.extend({
return this.$store.getters.getActiveProfile
},
isSubscribed: function () {
const subIndex = this.activeProfile.subscriptions.findIndex((channel) => {
subscriptionInfo: function () {
return this.activeProfile.subscriptions.find((channel) => {
return channel.id === this.id
})
}) ?? null
},
if (subIndex === -1) {
return false
} else {
return true
}
isSubscribed: function () {
return this.subscriptionInfo !== null
},
subscribedText: function () {
@ -249,25 +248,40 @@ export default Vue.extend({
getChannelInfoLocal: function () {
this.apiUsed = 'local'
ytch.getChannelInfo(this.id).then((response) => {
this.id = response.authorId
this.channelName = response.author
const expectedId = this.id
ytch.getChannelInfo({ channelId: expectedId }).then((response) => {
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}`
if (this.hideChannelSubscriptions || response.subscriberCount === 0) {
this.subCount = null
} else {
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.relatedChannels = response.relatedChannels.items
this.relatedChannels.forEach(relatedChannel => {
relatedChannel.authorThumbnails.map(thumbnail => {
relatedChannel.thumbnail.map(thumbnail => {
if (!thumbnail.url.includes('https')) {
thumbnail.url = `https:${thumbnail.url}`
}
return thumbnail
})
relatedChannel.authorThumbnails = relatedChannel.thumbnail
})
if (response.authorBanners !== null) {
@ -306,7 +320,12 @@ export default Vue.extend({
getChannelVideosLocal: function () {
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.videoContinuationString = response.continuation
this.isElementListLoading = false
@ -332,7 +351,7 @@ export default Vue.extend({
},
channelLocalNextPage: function () {
ytch.getChannelVideosMore(this.videoContinuationString).then((response) => {
ytch.getChannelVideosMore({ continuation: this.videoContinuationString }).then((response) => {
this.latestVideos = this.latestVideos.concat(response.items)
this.videoContinuationString = response.continuation
}).catch((err) => {
@ -352,17 +371,26 @@ export default Vue.extend({
this.isLoading = true
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)
this.channelName = response.author
const channelName = response.author
const channelId = response.authorId
this.channelName = channelName
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
this.id = response.authorId
this.id = channelId
if (this.hideChannelSubscriptions) {
this.subCount = null
} else {
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.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/`)
@ -371,12 +399,16 @@ export default Vue.extend({
})
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/`)
} else {
this.bannerUrl = null
}
this.errorMessage = ''
this.isLoading = false
}).catch((err) => {
this.setErrorMessage(err.responseJSON.error)
console.log(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
this.showToast({
@ -418,7 +450,12 @@ export default Vue.extend({
},
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)
this.latestPlaylists = response.items.map((item) => {
item.proxyThumbnail = false
@ -448,7 +485,7 @@ export default Vue.extend({
},
getPlaylistsLocalMore: function () {
ytch.getChannelPlaylistsMore(this.playlistContinuationString).then((response) => {
ytch.getChannelPlaylistsMore({ continuation: this.playlistContinuationString }).then((response) => {
console.log(response)
this.latestPlaylists = this.latestPlaylists.concat(response.items)
this.playlistContinuationString = response.continuation
@ -466,6 +503,40 @@ export default Vue.extend({
},
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) {
console.log('There are no more playlists available for this channel')
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 () {
switch (this.currentTab) {
case 'videos':
@ -598,7 +679,7 @@ export default Vue.extend({
this.getPlaylistsLocalMore()
break
case 'invidious':
this.getPlaylistsInvidious()
this.getPlaylistsInvidiousMore()
break
}
break
@ -638,7 +719,7 @@ export default Vue.extend({
searchChannelLocal: function () {
if (this.searchContinuationString === '') {
ytch.searchChannel(this.id, this.lastSearchQuery).then((response) => {
ytch.searchChannel({ channelId: this.id, query: this.lastSearchQuery }).then((response) => {
console.log(response)
this.searchResults = response.items
this.isElementListLoading = false
@ -663,7 +744,7 @@ export default Vue.extend({
}
})
} else {
ytch.searchChannelMore(this.searchContinuationString).then((response) => {
ytch.searchChannelMore({ continuation: this.searchContinuationString }).then((response) => {
console.log(response)
this.searchResults = this.searchResults.concat(response.items)
this.isElementListLoading = false
@ -721,7 +802,8 @@ export default Vue.extend({
'showToast',
'updateProfile',
'invidiousGetChannelInfo',
'invidiousAPICall'
'invidiousAPICall',
'updateSubscriptionDetails'
])
}
})

View File

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

View File

@ -20,19 +20,19 @@ export default Vue.extend({
return {
isLoading: false,
dataLimit: 100,
hasQuery: false
searchDataLimit: 100,
showLoadMoreButton: false,
hasQuery: false,
query: '',
activeData: []
}
},
computed: {
historyCache: function () {
if (!this.hasQuery) {
return this.$store.getters.getHistoryCache
} else {
return this.$store.getters.getSearchHistoryCache
}
return this.$store.getters.getHistoryCache
},
activeData: function () {
fullData: function () {
if (this.historyCache.length < this.dataLimit) {
return this.historyCache
} 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 () {
console.log(this.historyCache)
const limit = sessionStorage.getItem('historyLimit')
if (limit !== null) {
this.dataLimit = limit
}
this.activeData = this.fullData
if (this.activeData.length < this.historyCache.length) {
this.showLoadMoreButton = true
} else {
this.showLoadMoreButton = false
}
},
methods: {
increaseLimit: function () {
this.dataLimit += 100
sessionStorage.setItem('historyLimit', this.dataLimit)
if (this.query !== '') {
this.searchDataLimit += 100
this.filterHistory()
} else {
this.dataLimit += 100
sessionStorage.setItem('historyLimit', this.dataLimit)
}
},
filterHistory: function(query) {
this.hasQuery = query !== ''
this.$store.dispatch('searchHistory', query)
if (this.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
setTimeout(() => {
Vue.nextTick(() => {
this.isLoading = false
}, 100)
Vue.nextTick(() => {
window.scrollTo(0, scrollPos)
})
})
}
}
})

View File

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

View File

@ -1,18 +1,42 @@
.routerView {
display: flex;
}
.playlistInfo {
background-color: var(--card-bg-color);
padding: 10px;
float: left;
position: fixed;
top: 60px;
width: 30%;
height: 100%;
box-sizing: border-box;
height: calc(100vh - 96px);
margin-right: 1em;
overflow-y: auto;
padding: 10px;
position: sticky;
top: 78px;
width: 30%;
}
.playlistItems {
float: right;
width: 60%;
padding: 10px;
display: grid;
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 { mapActions } from 'vuex'
import dateFormat from 'dateformat'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import PlaylistInfo from '../../components/playlist-info/playlist-info.vue'
import FtListVideo from '../../components/ft-list-video/ft-list-video.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import i18n from '../../i18n/index'
export default Vue.extend({
name: 'Playlist',
@ -36,6 +36,9 @@ export default Vue.extend({
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
currentLocale: function () {
return i18n.locale.replace('_', '-')
}
},
watch: {
@ -81,6 +84,12 @@ export default Vue.extend({
infoSource: 'local'
}
this.updateSubscriptionDetails({
channelThumbnailUrl: this.infoData.channelThumbnail,
channelName: this.infoData.channelName,
channelId: this.infoData.channelId
})
this.playlistItems = result.items.map((video) => {
if (typeof video.author !== 'undefined') {
const channelName = video.author.name
@ -133,9 +142,14 @@ export default Vue.extend({
infoSource: 'invidious'
}
this.updateSubscriptionDetails({
channelThumbnailUrl: result.authorThumbnails[2].url,
channelName: this.infoData.channelName,
channelId: this.infoData.channelId
})
const dateString = new Date(result.updated * 1000)
dateString.setDate(dateString.getDate() + 1)
this.infoData.lastUpdated = dateFormat(dateString, 'mmm dS, yyyy')
this.infoData.lastUpdated = dateString.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' })
this.playlistItems = this.playlistItems.concat(result.videos)
@ -172,7 +186,8 @@ export default Vue.extend({
...mapActions([
'ytGetPlaylistInfo',
'invidiousGetPlaylistInfo'
'invidiousGetPlaylistInfo',
'updateSubscriptionDetails'
])
}
})

View File

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

View File

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

View File

@ -8,6 +8,22 @@
v-else
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>
<ft-flex-box
v-if="activeVideoList.length === 0"

View File

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

View File

@ -22,19 +22,19 @@ export default Vue.extend({
return {
isLoading: false,
dataLimit: 100,
hasQuery: false
searchDataLimit: 100,
showLoadMoreButton: false,
query: '',
hasQuery: false,
activeData: []
}
},
computed: {
favoritesPlaylist: function () {
if (!this.hasQuery) {
return this.$store.getters.getFavorites
} else {
return this.$store.getters.getSearchPlaylistCache
}
return this.$store.getters.getFavorites
},
activeData: function () {
fullData: function () {
const data = [].concat(this.favoritesPlaylist.videos).reverse()
if (this.favoritesPlaylist.videos.length < this.dataLimit) {
return data
@ -44,16 +44,16 @@ export default Vue.extend({
}
},
watch: {
query() {
this.searchDataLimit = 100
this.filterPlaylist()
},
activeData() {
const scrollPos = window.scrollY || window.scrollTop || document.getElementsByTagName('html')[0].scrollTop
this.isLoading = true
setTimeout(() => {
this.isLoading = false
// This is kinda ugly, but should fix a few existing issues
setTimeout(() => {
window.scrollTo(0, scrollPos)
}, 100)
}, 100)
this.refreshPage()
},
fullData() {
this.activeData = this.fullData
this.filterPlaylist()
}
},
mounted: function () {
@ -62,15 +62,60 @@ export default Vue.extend({
if (limit !== null) {
this.dataLimit = limit
}
if (this.activeData.length < this.favoritesPlaylist.videos.length) {
this.showLoadMoreButton = true
} else {
this.showLoadMoreButton = false
}
this.activeData = this.fullData
},
methods: {
increaseLimit: function () {
this.dataLimit += 100
sessionStorage.setItem('favoritesLimit', this.dataLimit)
if (this.query !== '') {
this.searchDataLimit += 100
this.filterPlaylist()
} else {
this.dataLimit += 100
sessionStorage.setItem('favoritesLimit', this.dataLimit)
}
},
filterPlaylist: function(query) {
this.hasQuery = query !== ''
this.$store.dispatch('searchFavoritePlaylist', query)
filterPlaylist: function() {
if (this.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>
<div>
<ft-loader
v-if="isLoading"
v-show="isLoading"
:fullscreen="true"
/>
<ft-card
v-else
v-show="!isLoading"
class="card"
>
<h3>
@ -17,25 +17,34 @@
/>
</h3>
<ft-input
v-show="fullData.length > 0"
ref="searchBar"
:placeholder="$t('User Playlists.Search bar placeholder')"
:show-clear-text-button="true"
:show-action-button="false"
@input="filterPlaylist"
@input="(input) => query = input"
@clear="query = ''"
/>
<ft-flex-box
v-if="activeData.length === 0"
v-show="fullData.length === 0"
>
<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']") }}
</p>
</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
v-else
v-if="activeData.length > 0 && !isLoading"
:data="activeData"
/>
<ft-flex-box
v-if="activeData.length < favoritesPlaylist.videos.length"
v-if="showLoadMoreButton"
>
<ft-button
label="Load More"

View File

@ -29,11 +29,11 @@ export default Vue.extend({
'watch-video-recommendations': WatchVideoRecommendations
},
beforeRouteLeave: function (to, from, next) {
this.handleRouteChange()
this.handleRouteChange(this.videoId)
window.removeEventListener('beforeunload', this.handleWatchProgress)
next()
},
data: function() {
data: function () {
return {
isLoading: false,
firstLoad: true,
@ -75,7 +75,9 @@ export default Vue.extend({
playlistId: '',
timestamp: null,
playNextTimeout: null,
playNextCountDownIntervalId: null
playNextCountDownIntervalId: null,
pictureInPictureButtonInverval: null,
infoAreaSticky: true
}
},
computed: {
@ -127,6 +129,9 @@ export default Vue.extend({
playNextVideo: function () {
return this.$store.getters.getPlayNextVideo
},
autoplayPlaylists: function () {
return this.$store.getters.getAutoplayPlaylists
},
hideRecommendedVideos: function () {
return this.$store.getters.getHideRecommendedVideos
},
@ -143,13 +148,13 @@ export default Vue.extend({
hideVideoLikesAndDislikes: function () {
return this.$store.getters.getHideVideoLikesAndDislikes
},
theatrePossible: function() {
theatrePossible: function () {
return !this.hideRecommendedVideos || (!this.hideLiveChat && this.isLive) || this.watchingPlaylist
}
},
watch: {
$route() {
this.handleRouteChange()
this.handleRouteChange(this.videoId)
// react to route changes...
this.videoId = this.$route.params.id
@ -174,6 +179,25 @@ export default Vue.extend({
}
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 () {
@ -200,14 +224,14 @@ export default Vue.extend({
window.addEventListener('beforeunload', this.handleWatchProgress)
},
methods: {
changeTimestamp: function(timestamp) {
changeTimestamp: function (timestamp) {
this.$refs.videoPlayer.player.currentTime(timestamp)
},
toggleTheatreMode: function() {
toggleTheatreMode: function () {
this.useTheatreMode = !this.useTheatreMode
},
getVideoInformationLocal: function() {
getVideoInformationLocal: function () {
if (this.firstLoad) {
this.isLoading = true
}
@ -256,6 +280,12 @@ export default Vue.extend({
this.channelName = result.player_response.videoDetails.author
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.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.videoSourceList = result.formats.filter((format) => {
@ -402,8 +432,9 @@ export default Vue.extend({
)
if (!standardLocale.startsWith('en') && noLocaleCaption) {
const baseUrl = result.player_response.captions.playerCaptionsRenderer.baseUrl
this.tryAddingTranslatedLocaleCaption(captionTracks, standardLocale, baseUrl)
captionTracks.forEach((caption) => {
this.tryAddingTranslatedLocaleCaption(captionTracks, standardLocale, caption.baseUrl)
})
}
}
@ -508,7 +539,7 @@ export default Vue.extend({
})
},
getVideoInformationInvidious: function() {
getVideoInformationInvidious: function () {
if (this.firstLoad) {
this.isLoading = true
}
@ -540,7 +571,14 @@ export default Vue.extend({
}
this.channelId = result.authorId
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.videoDescriptionHtml = result.descriptionHtml
this.recommendedVideos = result.recommendedVideos
@ -880,7 +918,7 @@ export default Vue.extend({
},
handleVideoEnded: function () {
if (!this.watchingPlaylist && !this.playNextVideo) {
if ((!this.watchingPlaylist || !this.autoplayPlaylists) && !this.playNextVideo) {
return
}
@ -933,7 +971,11 @@ export default Vue.extend({
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)
clearInterval(this.playNextCountDownIntervalId)
@ -943,14 +985,13 @@ export default Vue.extend({
const player = this.$refs.videoPlayer.player
if (player !== null && !player.paused() && player.isInPictureInPicture()) {
const playerId = this.videoId
setTimeout(() => {
player.play()
player.on('leavepictureinpicture', (event) => {
const watchTime = player.currentTime()
if (this.$route.fullPath.includes('/watch')) {
const routeId = this.$route.params.id
if (routeId === playerId) {
if (routeId === videoId) {
const activePlayer = $('.ftVideoPlayer video').get(0)
activePlayer.currentTime = watchTime
}
@ -966,23 +1007,23 @@ export default Vue.extend({
if (this.removeVideoMetaFiles) {
const userData = await this.getUserDataPath()
if (this.isDev) {
const dashFileLocation = `static/dashFiles/${this.videoId}.xml`
const vttFileLocation = `static/storyboards/${this.videoId}.vtt`
const dashFileLocation = `static/dashFiles/${videoId}.xml`
const vttFileLocation = `static/storyboards/${videoId}.vtt`
// only delete the file it actually exists
if (fs.existsSync('static/dashFiles/') && fs.existsSync(dashFileLocation)) {
if (fs.existsSync(dashFileLocation)) {
fs.rmSync(dashFileLocation)
}
if (fs.existsSync('static/storyboards/') && fs.existsSync(vttFileLocation)) {
if (fs.existsSync(vttFileLocation)) {
fs.rmSync(vttFileLocation)
}
} else {
const dashFileLocation = `${userData}/dashFiles/${this.videoId}.xml`
const vttFileLocation = `${userData}/storyboards/${this.videoId}.vtt`
const dashFileLocation = `${userData}/dashFiles/${videoId}.xml`
const vttFileLocation = `${userData}/storyboards/${videoId}.vtt`
if (fs.existsSync(`${userData}/dashFiles/`) && fs.existsSync(dashFileLocation)) {
if (fs.existsSync(dashFileLocation)) {
fs.rmSync(dashFileLocation)
}
if (fs.existsSync(`${userData}/storyboards/`) && fs.existsSync(vttFileLocation)) {
if (fs.existsSync(vttFileLocation)) {
fs.rmSync(vttFileLocation)
}
}
@ -1136,6 +1177,13 @@ export default Vue.extend({
label = `${this.$t('Locale Name')} (translated from English)`
}
const indexTranslated = captionTracks.findIndex((item) => {
return item.name.simpleText === label
})
if (indexTranslated !== -1) {
return
}
if (enCaptionExists) {
url = new URL(captionTracks[enCaptionIdx].baseUrl)
} else {
@ -1240,7 +1288,8 @@ export default Vue.extend({
'updateWatchProgress',
'getUserDataPath',
'ytGetVideoInformation',
'invidiousGetVideoInformation'
'invidiousGetVideoInformation',
'updateSubscriptionDetails'
])
}
})

View File

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