mirror of https://github.com/FreeTubeApp/FreeTube
Merge branch 'development' into piped-support
This commit is contained in:
commit
863c1d2572
|
@ -47,7 +47,7 @@ module.exports = {
|
|||
'plugin:vue/recommended',
|
||||
'standard',
|
||||
'plugin:jsonc/recommended-with-json',
|
||||
// 'plugin:vuejs-accessibility/recommended' // uncomment once issues are fixed
|
||||
'plugin:vuejs-accessibility/recommended'
|
||||
],
|
||||
|
||||
// https://eslint.org/docs/user-guide/configuring#configuring-plugins
|
||||
|
@ -70,6 +70,7 @@ module.exports = {
|
|||
some: ['nesting', 'id']
|
||||
}
|
||||
}],
|
||||
'vuejs-accessibility/no-static-element-interactions': 'off',
|
||||
'n/no-callback-literal': 'warn',
|
||||
'n/no-path-concat': 'warn',
|
||||
'unicorn/better-regex': 'error',
|
||||
|
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Auto Merge PR
|
||||
if: github.event.pull_request.draft == false && (contains(${{ github.event.pull_request.base.ref }}, 'development') || contains(${{ github.event.pull_request.base.ref }}, 'RC'))
|
||||
if: ${{ !github.event.pull_request.draft && (contains(github.event.pull_request.base.ref, 'development') || contains(github.event.pull_request.base.ref, 'RC')) }}
|
||||
run: |
|
||||
echo ${{ secrets.PUSH_TOKEN }} >> auth.txt
|
||||
gh auth login --with-token < auth.txt
|
||||
|
|
|
@ -97,7 +97,7 @@ These builds are maintained by the community. While they should be safe, downloa
|
|||
|
||||
* Scoop (Windows Only): [Usage](https://github.com/ScoopInstaller/Scoop)
|
||||
|
||||
* Snap: [Download](https://snapcraft.io/freetube-snap) and [Source Code](https://github.com/CapeCrusader321/Freetube-Snap)
|
||||
* Snap: [Download](https://snapcraft.io/freetube-snap) and [Source Code](https://launchpad.net/freetube)
|
||||
|
||||
* Windows Package Manager (winget): [Usage](https://docs.microsoft.com/en-us/windows/package-manager/winget/)
|
||||
|
||||
|
|
|
@ -21,21 +21,30 @@ class ProcessLocalesPlugin {
|
|||
}
|
||||
this.outputDir = options.outputDir
|
||||
|
||||
this.locales = []
|
||||
this.localeNames = []
|
||||
|
||||
this.cache = []
|
||||
|
||||
this.loadLocales()
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('ProcessLocalesPlugin', (compilation) => {
|
||||
|
||||
const { RawSource } = compiler.webpack.sources;
|
||||
const IS_DEV_SERVER = !!compiler.watching
|
||||
const { CachedSource, RawSource } = compiler.webpack.sources;
|
||||
|
||||
compilation.hooks.processAssets.tapPromise({
|
||||
name: 'process-locales-plugin',
|
||||
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
|
||||
},
|
||||
async (_assets) => {
|
||||
compilation.hooks.additionalAssets.tapPromise('process-locales-plugin', async (_assets) => {
|
||||
|
||||
// While running in the webpack dev server, this hook gets called for every incrememental build.
|
||||
// For incremental builds we can return the already processed versions, which saves time
|
||||
// and makes webpack treat them as cached
|
||||
if (IS_DEV_SERVER && this.cache.length > 0) {
|
||||
for (const { filename, source } of this.cache) {
|
||||
compilation.emitAsset(filename, source, { minimized: true })
|
||||
}
|
||||
} else {
|
||||
const promises = []
|
||||
|
||||
for (const { locale, data } of this.locales) {
|
||||
|
@ -54,24 +63,32 @@ class ProcessLocalesPlugin {
|
|||
output = await this.compressLocale(output)
|
||||
}
|
||||
|
||||
compilation.emitAsset(
|
||||
filename,
|
||||
new RawSource(output),
|
||||
{ minimized: true }
|
||||
)
|
||||
let source = new RawSource(output)
|
||||
|
||||
if (IS_DEV_SERVER) {
|
||||
source = new CachedSource(source)
|
||||
this.cache.push({ filename, source })
|
||||
}
|
||||
|
||||
compilation.emitAsset(filename, source, { minimized: true })
|
||||
|
||||
resolve()
|
||||
}))
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
})
|
||||
|
||||
if (IS_DEV_SERVER) {
|
||||
// we don't need the unmodified sources anymore, as we use the cache `this.cache`
|
||||
// so we can clear this to free some memory
|
||||
delete this.locales
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
loadLocales() {
|
||||
this.locales = []
|
||||
|
||||
const activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`))
|
||||
|
||||
for (const locale of activeLocales) {
|
||||
|
|
44
package.json
44
package.json
|
@ -2,7 +2,7 @@
|
|||
"name": "freetube",
|
||||
"productName": "FreeTube",
|
||||
"description": "A private YouTube client",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"main": "./dist/main.js",
|
||||
"private": true,
|
||||
|
@ -54,16 +54,16 @@
|
|||
"ci": "yarn install --silent --frozen-lockfile"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/vue-fontawesome": "^2.0.10",
|
||||
"@seald-io/nedb": "^4.0.2",
|
||||
"@silvermine/videojs-quality-selector": "^1.3.0",
|
||||
"autolinker": "^4.0.0",
|
||||
"electron-context-menu": "^3.6.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"marked": "^4.3.0",
|
||||
"nedb-promises": "^6.2.1",
|
||||
"marked": "^7.0.4",
|
||||
"path-browserify": "^1.0.1",
|
||||
"process": "^0.11.10",
|
||||
"video.js": "7.21.5",
|
||||
|
@ -78,48 +78,48 @@
|
|||
"vue-router": "^3.6.5",
|
||||
"vue-tiny-slider": "^0.1.39",
|
||||
"vuex": "^3.6.2",
|
||||
"youtubei.js": "^5.6.0"
|
||||
"youtubei.js": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.9",
|
||||
"@babel/eslint-parser": "^7.22.9",
|
||||
"@babel/core": "^7.22.10",
|
||||
"@babel/eslint-parser": "^7.22.10",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/preset-env": "^7.22.9",
|
||||
"@babel/preset-env": "^7.22.10",
|
||||
"@double-great/stylelint-a11y": "^2.0.2",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"css-minimizer-webpack-plugin": "^5.0.1",
|
||||
"electron": "^22.3.18",
|
||||
"electron-builder": "^23.6.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"electron-builder": "^24.6.3",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-jsonc": "^2.9.0",
|
||||
"eslint-plugin-n": "^16.0.1",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.1.0",
|
||||
"eslint-plugin-unicorn": "^48.0.1",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.2.0",
|
||||
"eslint-plugin-yml": "^1.8.0",
|
||||
"html-webpack-plugin": "^5.5.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-minimizer-webpack-plugin": "^4.0.0",
|
||||
"lefthook": "^1.4.6",
|
||||
"lefthook": "^1.4.9",
|
||||
"mini-css-extract-plugin": "^2.7.6",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.26",
|
||||
"postcss-scss": "^4.0.6",
|
||||
"postcss-scss": "^4.0.7",
|
||||
"prettier": "^2.8.8",
|
||||
"rimraf": "^5.0.1",
|
||||
"sass": "^1.64.1",
|
||||
"sass": "^1.66.1",
|
||||
"sass-loader": "^13.3.2",
|
||||
"stylelint": "^15.10.2",
|
||||
"stylelint": "^15.10.3",
|
||||
"stylelint-config-sass-guidelines": "^10.0.0",
|
||||
"stylelint-config-standard": "^34.0.0",
|
||||
"stylelint-high-performance-animation": "^1.8.0",
|
||||
"stylelint-high-performance-animation": "^1.9.0",
|
||||
"tree-kill": "1.2.2",
|
||||
"vue-devtools": "^5.1.4",
|
||||
"vue-eslint-parser": "^9.3.1",
|
||||
|
|
|
@ -70,6 +70,11 @@ const SyncEvents = {
|
|||
}
|
||||
}
|
||||
|
||||
// https://v2.vuejs.org/v2/api/#provide-inject
|
||||
const Injectables = {
|
||||
SHOW_OUTLINES: 'showOutlines'
|
||||
}
|
||||
|
||||
// Utils
|
||||
const MAIN_PROFILE_ID = 'allChannels'
|
||||
|
||||
|
@ -77,5 +82,6 @@ export {
|
|||
IpcChannels,
|
||||
DBActions,
|
||||
SyncEvents,
|
||||
Injectables,
|
||||
MAIN_PROFILE_ID
|
||||
}
|
||||
|
|
|
@ -2,17 +2,21 @@ import db from '../index'
|
|||
|
||||
class Settings {
|
||||
static find() {
|
||||
return db.settings.find({ _id: { $ne: 'bounds' } })
|
||||
return db.settings.findAsync({ _id: { $ne: 'bounds' } })
|
||||
}
|
||||
|
||||
static upsert(_id, value) {
|
||||
return db.settings.update({ _id }, { _id, value }, { upsert: true })
|
||||
return db.settings.updateAsync({ _id }, { _id, value }, { upsert: true })
|
||||
}
|
||||
|
||||
static persist() {
|
||||
return db.settings.compactDatafileAsync()
|
||||
}
|
||||
|
||||
// ******************** //
|
||||
// Unique Electron main process handlers
|
||||
static _findAppReadyRelatedSettings() {
|
||||
return db.settings.find({
|
||||
return db.settings.findAsync({
|
||||
$or: [
|
||||
{ _id: 'disableSmoothScrolling' },
|
||||
{ _id: 'useProxy' },
|
||||
|
@ -24,92 +28,92 @@ class Settings {
|
|||
}
|
||||
|
||||
static _findBounds() {
|
||||
return db.settings.findOne({ _id: 'bounds' })
|
||||
return db.settings.findOneAsync({ _id: 'bounds' })
|
||||
}
|
||||
|
||||
static _findTheme() {
|
||||
return db.settings.findOne({ _id: 'baseTheme' })
|
||||
return db.settings.findOneAsync({ _id: 'baseTheme' })
|
||||
}
|
||||
|
||||
static _findSidenavSettings() {
|
||||
return {
|
||||
hideTrendingVideos: db.settings.findOne({ _id: 'hideTrendingVideos' }),
|
||||
hidePopularVideos: db.settings.findOne({ _id: 'hidePopularVideos' }),
|
||||
backendFallback: db.settings.findOne({ _id: 'backendFallback' }),
|
||||
backendPreference: db.settings.findOne({ _id: 'backendPreference' }),
|
||||
hidePlaylists: db.settings.findOne({ _id: 'hidePlaylists' }),
|
||||
hideTrendingVideos: db.settings.findOneAsync({ _id: 'hideTrendingVideos' }),
|
||||
hidePopularVideos: db.settings.findOneAsync({ _id: 'hidePopularVideos' }),
|
||||
backendFallback: db.settings.findOneAsync({ _id: 'backendFallback' }),
|
||||
backendPreference: db.settings.findOneAsync({ _id: 'backendPreference' }),
|
||||
hidePlaylists: db.settings.findOneAsync({ _id: 'hidePlaylists' }),
|
||||
}
|
||||
}
|
||||
|
||||
static _updateBounds(value) {
|
||||
return db.settings.update({ _id: 'bounds' }, { _id: 'bounds', value }, { upsert: true })
|
||||
return db.settings.updateAsync({ _id: 'bounds' }, { _id: 'bounds', value }, { upsert: true })
|
||||
}
|
||||
// ******************** //
|
||||
}
|
||||
|
||||
class History {
|
||||
static find() {
|
||||
return db.history.find({}).sort({ timeWatched: -1 })
|
||||
return db.history.findAsync({}).sort({ timeWatched: -1 })
|
||||
}
|
||||
|
||||
static upsert(record) {
|
||||
return db.history.update({ videoId: record.videoId }, record, { upsert: true })
|
||||
return db.history.updateAsync({ videoId: record.videoId }, record, { upsert: true })
|
||||
}
|
||||
|
||||
static updateWatchProgress(videoId, watchProgress) {
|
||||
return db.history.update({ videoId }, { $set: { watchProgress } }, { upsert: true })
|
||||
return db.history.updateAsync({ videoId }, { $set: { watchProgress } }, { upsert: true })
|
||||
}
|
||||
|
||||
static updateLastViewedPlaylist(videoId, lastViewedPlaylistId) {
|
||||
return db.history.update({ videoId }, { $set: { lastViewedPlaylistId } }, { upsert: true })
|
||||
return db.history.updateAsync({ videoId }, { $set: { lastViewedPlaylistId } }, { upsert: true })
|
||||
}
|
||||
|
||||
static delete(videoId) {
|
||||
return db.history.remove({ videoId })
|
||||
return db.history.removeAsync({ videoId })
|
||||
}
|
||||
|
||||
static deleteAll() {
|
||||
return db.history.remove({}, { multi: true })
|
||||
return db.history.removeAsync({}, { multi: true })
|
||||
}
|
||||
|
||||
static persist() {
|
||||
db.history.persistence.compactDatafile()
|
||||
return db.history.compactDatafileAsync()
|
||||
}
|
||||
}
|
||||
|
||||
class Profiles {
|
||||
static create(profile) {
|
||||
return db.profiles.insert(profile)
|
||||
return db.profiles.insertAsync(profile)
|
||||
}
|
||||
|
||||
static find() {
|
||||
return db.profiles.find({})
|
||||
return db.profiles.findAsync({})
|
||||
}
|
||||
|
||||
static upsert(profile) {
|
||||
return db.profiles.update({ _id: profile._id }, profile, { upsert: true })
|
||||
return db.profiles.updateAsync({ _id: profile._id }, profile, { upsert: true })
|
||||
}
|
||||
|
||||
static delete(id) {
|
||||
return db.profiles.remove({ _id: id })
|
||||
return db.profiles.removeAsync({ _id: id })
|
||||
}
|
||||
|
||||
static persist() {
|
||||
db.profiles.persistence.compactDatafile()
|
||||
return db.profiles.compactDatafileAsync()
|
||||
}
|
||||
}
|
||||
|
||||
class Playlists {
|
||||
static create(playlists) {
|
||||
return db.playlists.insert(playlists)
|
||||
return db.playlists.insertAsync(playlists)
|
||||
}
|
||||
|
||||
static find() {
|
||||
return db.playlists.find({})
|
||||
return db.playlists.findAsync({})
|
||||
}
|
||||
|
||||
static upsertVideoByPlaylistName(playlistName, videoData) {
|
||||
return db.playlists.update(
|
||||
return db.playlists.updateAsync(
|
||||
{ playlistName },
|
||||
{ $push: { videos: videoData } },
|
||||
{ upsert: true }
|
||||
|
@ -117,7 +121,7 @@ class Playlists {
|
|||
}
|
||||
|
||||
static upsertVideoIdsByPlaylistId(_id, videoIds) {
|
||||
return db.playlists.update(
|
||||
return db.playlists.updateAsync(
|
||||
{ _id },
|
||||
{ $push: { videos: { $each: videoIds } } },
|
||||
{ upsert: true }
|
||||
|
@ -125,11 +129,11 @@ class Playlists {
|
|||
}
|
||||
|
||||
static delete(_id) {
|
||||
return db.playlists.remove({ _id, protected: { $ne: true } })
|
||||
return db.playlists.removeAsync({ _id, protected: { $ne: true } })
|
||||
}
|
||||
|
||||
static deleteVideoIdByPlaylistName(playlistName, videoId) {
|
||||
return db.playlists.update(
|
||||
return db.playlists.updateAsync(
|
||||
{ playlistName },
|
||||
{ $pull: { videos: { videoId } } },
|
||||
{ upsert: true }
|
||||
|
@ -137,7 +141,7 @@ class Playlists {
|
|||
}
|
||||
|
||||
static deleteVideoIdsByPlaylistName(playlistName, videoIds) {
|
||||
return db.playlists.update(
|
||||
return db.playlists.updateAsync(
|
||||
{ playlistName },
|
||||
{ $pull: { videos: { $in: videoIds } } },
|
||||
{ upsert: true }
|
||||
|
@ -145,7 +149,7 @@ class Playlists {
|
|||
}
|
||||
|
||||
static deleteAllVideosByPlaylistName(playlistName) {
|
||||
return db.playlists.update(
|
||||
return db.playlists.updateAsync(
|
||||
{ playlistName },
|
||||
{ $set: { videos: [] } },
|
||||
{ upsert: true }
|
||||
|
@ -153,19 +157,34 @@ class Playlists {
|
|||
}
|
||||
|
||||
static deleteMultiple(ids) {
|
||||
return db.playlists.remove({ _id: { $in: ids }, protected: { $ne: true } })
|
||||
return db.playlists.removeAsync({ _id: { $in: ids }, protected: { $ne: true } })
|
||||
}
|
||||
|
||||
static deleteAll() {
|
||||
return db.playlists.remove({ protected: { $ne: true } })
|
||||
return db.playlists.removeAsync({ protected: { $ne: true } })
|
||||
}
|
||||
|
||||
static persist() {
|
||||
return db.playlists.compactDatafileAsync()
|
||||
}
|
||||
}
|
||||
|
||||
function compactAllDatastores() {
|
||||
return Promise.allSettled([
|
||||
Settings.persist(),
|
||||
History.persist(),
|
||||
Profiles.persist(),
|
||||
Playlists.persist()
|
||||
])
|
||||
}
|
||||
|
||||
const baseHandlers = {
|
||||
settings: Settings,
|
||||
history: History,
|
||||
profiles: Profiles,
|
||||
playlists: Playlists
|
||||
playlists: Playlists,
|
||||
|
||||
compactAllDatastores
|
||||
}
|
||||
|
||||
export default baseHandlers
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Datastore from 'nedb-promises'
|
||||
import Datastore from '@seald-io/nedb'
|
||||
|
||||
let dbPath = null
|
||||
|
||||
|
@ -23,9 +23,9 @@ if (process.env.IS_ELECTRON_MAIN) {
|
|||
}
|
||||
|
||||
const db = {}
|
||||
db.settings = Datastore.create({ filename: dbPath('settings'), autoload: true })
|
||||
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.settings = new Datastore({ filename: dbPath('settings'), autoload: true })
|
||||
db.profiles = new Datastore({ filename: dbPath('profiles'), autoload: true })
|
||||
db.playlists = new Datastore({ filename: dbPath('playlists'), autoload: true })
|
||||
db.history = new Datastore({ filename: dbPath('history'), autoload: true })
|
||||
|
||||
export default db
|
||||
|
|
|
@ -289,7 +289,7 @@ function runApp() {
|
|||
// InnerTube rejects requests if the referer isn't YouTube or empty
|
||||
const innertubeAndMediaRequestFilter = { urls: ['https://www.youtube.com/youtubei/*', 'https://*.googlevideo.com/videoplayback?*'] }
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(innertubeAndMediaRequestFilter, ({ requestHeaders, url }, callback) => {
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(innertubeAndMediaRequestFilter, ({ requestHeaders, url, resourceType }, callback) => {
|
||||
requestHeaders.Referer = 'https://www.youtube.com/'
|
||||
requestHeaders.Origin = 'https://www.youtube.com'
|
||||
|
||||
|
@ -300,6 +300,38 @@ function runApp() {
|
|||
delete requestHeaders['Content-Type']
|
||||
}
|
||||
|
||||
// YouTube throttles the adaptive formats if you request a chunk larger than 10MiB.
|
||||
// For the DASH formats we are fine as video.js doesn't seem to ever request chunks that big.
|
||||
// The legacy formats don't have any chunk size limits.
|
||||
// For the audio formats we need to handle it ourselves, as the browser requests the entire audio file,
|
||||
// which means that for most videos that are loger than 10 mins, we get throttled, as the audio track file sizes surpass that 10MiB limit.
|
||||
|
||||
// This code checks if the file is larger than the limit, by checking the `clen` query param,
|
||||
// which YouTube helpfully populates with the content length for us.
|
||||
// If it does surpass that limit, it then checks if the requested range is larger than the limit
|
||||
// (seeking right at the end of the video, would result in a small enough range to be under the chunk limit)
|
||||
// if that surpasses the limit too, it then limits the requested range to 10MiB, by setting the range to `start-${start + 10MiB}`.
|
||||
if (resourceType === 'media' && url.includes('&mime=audio') && requestHeaders.Range) {
|
||||
const TEN_MIB = 10 * 1024 * 1024
|
||||
|
||||
const contentLength = parseInt(new URL(url).searchParams.get('clen'))
|
||||
|
||||
if (contentLength > TEN_MIB) {
|
||||
const [startStr, endStr] = requestHeaders.Range.split('=')[1].split('-')
|
||||
|
||||
const start = parseInt(startStr)
|
||||
|
||||
// handle open ended ranges like `0-` and `1234-`
|
||||
const end = endStr.length === 0 ? contentLength : parseInt(endStr)
|
||||
|
||||
if (end - start > TEN_MIB) {
|
||||
const newEnd = start + TEN_MIB
|
||||
|
||||
requestHeaders.Range = `bytes=${start}-${newEnd}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
callback({ requestHeaders })
|
||||
})
|
||||
|
@ -993,28 +1025,33 @@ function runApp() {
|
|||
// ************************************************* //
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Clear cache and storage if it's the last window
|
||||
session.defaultSession.clearCache()
|
||||
session.defaultSession.clearStorageData({
|
||||
storages: [
|
||||
'appcache',
|
||||
'cookies',
|
||||
'filesystem',
|
||||
'indexdb',
|
||||
'shadercache',
|
||||
'websql',
|
||||
'serviceworkers',
|
||||
'cachestorage'
|
||||
]
|
||||
// Clean up resources (datastores' compaction + Electron cache and storage data clearing)
|
||||
cleanUpResources().finally(() => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// For MacOS the app would still "run in background"
|
||||
// and create new window on event `activate`
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
function cleanUpResources() {
|
||||
return Promise.allSettled([
|
||||
baseHandlers.compactAllDatastores(),
|
||||
session.defaultSession.clearCache(),
|
||||
session.defaultSession.clearStorageData({
|
||||
storages: [
|
||||
'appcache',
|
||||
'cookies',
|
||||
'filesystem',
|
||||
'indexdb',
|
||||
'shadercache',
|
||||
'websql',
|
||||
'serviceworkers',
|
||||
'cachestorage'
|
||||
]
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
// MacOS event
|
||||
// https://www.electronjs.org/docs/latest/api/app#event-activate-macos
|
||||
app.on('activate', () => {
|
||||
|
|
|
@ -10,7 +10,7 @@ import FtButton from './components/ft-button/ft-button.vue'
|
|||
import FtToast from './components/ft-toast/ft-toast.vue'
|
||||
import FtProgressBar from './components/ft-progress-bar/ft-progress-bar.vue'
|
||||
import { marked } from 'marked'
|
||||
import { IpcChannels } from '../constants'
|
||||
import { Injectables, IpcChannels } from '../constants'
|
||||
import packageDetails from '../../package.json'
|
||||
import { openExternalLink, openInternalPath, showToast } from './helpers/utils'
|
||||
|
||||
|
@ -30,6 +30,11 @@ export default defineComponent({
|
|||
FtToast,
|
||||
FtProgressBar
|
||||
},
|
||||
provide: function () {
|
||||
return {
|
||||
[Injectables.SHOW_OUTLINES]: this.showOutlines
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
dataReady: false,
|
||||
|
@ -55,7 +60,7 @@ export default defineComponent({
|
|||
return this.$store.getters.getShowProgressBar
|
||||
},
|
||||
isRightAligned: function () {
|
||||
return this.$i18n.locale === 'ar'
|
||||
return this.locale === 'ar' || this.locale === 'he'
|
||||
},
|
||||
checkForUpdates: function () {
|
||||
return this.$store.getters.getCheckForUpdates
|
||||
|
@ -100,6 +105,10 @@ export default defineComponent({
|
|||
return this.$store.getters.getSecColor
|
||||
},
|
||||
|
||||
locale: function() {
|
||||
return this.$i18n.locale.replace('_', '-')
|
||||
},
|
||||
|
||||
systemTheme: function () {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
},
|
||||
|
@ -124,6 +133,8 @@ export default defineComponent({
|
|||
|
||||
secColor: 'checkThemeSettings',
|
||||
|
||||
locale: 'setLocale',
|
||||
|
||||
$route () {
|
||||
// react to route changes...
|
||||
// Hide top nav filter panel on page change
|
||||
|
@ -133,6 +144,7 @@ export default defineComponent({
|
|||
created () {
|
||||
this.checkThemeSettings()
|
||||
this.setWindowTitle()
|
||||
this.setLocale()
|
||||
},
|
||||
mounted: function () {
|
||||
this.grabUserSettings().then(async () => {
|
||||
|
@ -503,6 +515,19 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
setLocale: function() {
|
||||
document.documentElement.setAttribute('lang', this.locale)
|
||||
},
|
||||
|
||||
/**
|
||||
* provided to all child components, see `provide` near the top of this file
|
||||
* after injecting it, they can show outlines during keyboard navigation
|
||||
* e.g. cycling through tabs with the arrow keys
|
||||
*/
|
||||
showOutlines: function () {
|
||||
this.hideOutlines = false
|
||||
},
|
||||
|
||||
...mapMutations([
|
||||
'setInvidiousInstancesList'
|
||||
]),
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
<side-nav ref="sideNav" />
|
||||
<ft-flex-box
|
||||
class="flexBox routerView"
|
||||
role="main"
|
||||
>
|
||||
<div
|
||||
v-if="showUpdatesBanner || showBlogBanner"
|
||||
|
@ -40,7 +41,6 @@
|
|||
<RouterView
|
||||
ref="router"
|
||||
class="routerView"
|
||||
@showOutlines="hideOutlines = false"
|
||||
/>
|
||||
<!-- </keep-alive> -->
|
||||
</transition>
|
||||
|
|
|
@ -9,6 +9,7 @@ import { MAIN_PROFILE_ID } from '../../../constants'
|
|||
import { calculateColorLuminance, getRandomColor } from '../../helpers/colors'
|
||||
import {
|
||||
copyToClipboard,
|
||||
deepCopy,
|
||||
escapeHTML,
|
||||
getTodayDateStrLocalTimezone,
|
||||
readFileFromDialog,
|
||||
|
@ -73,7 +74,7 @@ export default defineComponent({
|
|||
]
|
||||
},
|
||||
primaryProfile: function () {
|
||||
return JSON.parse(JSON.stringify(this.profileList[0]))
|
||||
return deepCopy(this.profileList[0])
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -178,7 +179,7 @@ export default defineComponent({
|
|||
})
|
||||
|
||||
if (existingProfileIndex !== -1) {
|
||||
const existingProfile = JSON.parse(JSON.stringify(this.profileList[existingProfileIndex]))
|
||||
const existingProfile = deepCopy(this.profileList[existingProfileIndex])
|
||||
existingProfile.subscriptions = existingProfile.subscriptions.concat(profileObject.subscriptions)
|
||||
existingProfile.subscriptions = existingProfile.subscriptions.filter((sub, index) => {
|
||||
const profileIndex = existingProfile.subscriptions.findIndex((x) => {
|
||||
|
@ -425,7 +426,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
const newPipeSubscriptions = newPipeData.subscriptions.filter((channel, index) => {
|
||||
return channel.service_id === 0
|
||||
return new URL(channel.url).hostname === 'www.youtube.com'
|
||||
})
|
||||
|
||||
const subscriptions = []
|
||||
|
@ -1039,10 +1040,9 @@ export default defineComponent({
|
|||
invidiousAPICall(subscriptionsPayload).then((response) => {
|
||||
resolve(response)
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
const errorMessage = this.$t('Invidious API Error (Click to copy)')
|
||||
showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => {
|
||||
copyToClipboard(err.responseJSON.error)
|
||||
showToast(`${errorMessage}: ${err}`, 10000, () => {
|
||||
copyToClipboard(err)
|
||||
})
|
||||
|
||||
if (process.env.IS_ELECTRON && this.backendFallback && this.backendPreference === 'invidious') {
|
||||
|
@ -1060,7 +1060,7 @@ export default defineComponent({
|
|||
const channel = await getLocalChannel(channelId)
|
||||
|
||||
if (channel.alert) {
|
||||
return undefined
|
||||
return []
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -1142,10 +1142,8 @@ export default defineComponent({
|
|||
|
||||
...mapActions([
|
||||
'updateProfile',
|
||||
'compactProfiles',
|
||||
'updateShowProgressBar',
|
||||
'updateHistory',
|
||||
'compactHistory',
|
||||
'addPlaylist',
|
||||
'addVideo'
|
||||
]),
|
||||
|
|
|
@ -11,35 +11,38 @@
|
|||
@change="updateDownloadBehavior"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<ft-flex-box
|
||||
<template
|
||||
v-if="downloadBehavior === 'download'"
|
||||
class="settingsFlexStart500px"
|
||||
>
|
||||
<ft-toggle-switch
|
||||
:label="$t('Settings.Download Settings.Ask Download Path')"
|
||||
:default-value="askForDownloadPath"
|
||||
@change="handleDownloadingSettingChange"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<ft-flex-box
|
||||
v-if="!askForDownloadPath && downloadBehavior === 'download'"
|
||||
>
|
||||
<ft-input
|
||||
class="folderDisplay"
|
||||
:placeholder="downloadPath"
|
||||
:show-action-button="false"
|
||||
:show-label="false"
|
||||
:disabled="true"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<ft-flex-box
|
||||
v-if="!askForDownloadPath && downloadBehavior === 'download'"
|
||||
>
|
||||
<ft-button
|
||||
:label="$t('Settings.Download Settings.Choose Path')"
|
||||
@click="chooseDownloadingFolder"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<ft-flex-box
|
||||
class="settingsFlexStart500px"
|
||||
>
|
||||
<ft-toggle-switch
|
||||
:label="$t('Settings.Download Settings.Ask Download Path')"
|
||||
:default-value="askForDownloadPath"
|
||||
@change="handleDownloadingSettingChange"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<template
|
||||
v-if="!askForDownloadPath"
|
||||
>
|
||||
<ft-flex-box>
|
||||
<ft-input
|
||||
class="folderDisplay"
|
||||
:placeholder="downloadPath"
|
||||
:show-action-button="false"
|
||||
:show-label="false"
|
||||
:disabled="true"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<ft-flex-box>
|
||||
<ft-button
|
||||
:label="$t('Settings.Download Settings.Choose Path')"
|
||||
@click="chooseDownloadingFolder"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
</template>
|
||||
</template>
|
||||
</ft-settings-section>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { defineComponent } from 'vue'
|
||||
import FtTooltip from '../ft-tooltip/ft-tooltip.vue'
|
||||
import { mapActions } from 'vuex'
|
||||
import { isKeyboardEventKeyPrintableChar, isNullOrEmpty } from '../../helpers/strings'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FtInput',
|
||||
|
@ -73,7 +74,8 @@ export default defineComponent({
|
|||
searchState: {
|
||||
showOptions: false,
|
||||
selectedOption: -1,
|
||||
isPointerInList: false
|
||||
isPointerInList: false,
|
||||
keyboardSelectedOptionIndex: -1,
|
||||
},
|
||||
visibleDataList: this.dataList,
|
||||
// This button should be invisible on app start
|
||||
|
@ -98,7 +100,23 @@ export default defineComponent({
|
|||
|
||||
inputDataPresent: function () {
|
||||
return this.inputData.length > 0
|
||||
}
|
||||
},
|
||||
inputDataDisplayed() {
|
||||
if (!this.isSearch) { return this.inputData }
|
||||
|
||||
const selectedOptionValue = this.searchStateKeyboardSelectedOptionValue
|
||||
if (selectedOptionValue != null && selectedOptionValue !== '') {
|
||||
return selectedOptionValue
|
||||
}
|
||||
|
||||
return this.inputData
|
||||
},
|
||||
|
||||
searchStateKeyboardSelectedOptionValue() {
|
||||
if (this.searchState.keyboardSelectedOptionIndex === -1) { return null }
|
||||
|
||||
return this.visibleDataList[this.searchState.keyboardSelectedOptionIndex]
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dataList(val, oldVal) {
|
||||
|
@ -128,11 +146,15 @@ export default defineComponent({
|
|||
if (!this.inputDataPresent) { return }
|
||||
|
||||
this.searchState.showOptions = false
|
||||
this.searchState.selectedOption = -1
|
||||
this.searchState.keyboardSelectedOptionIndex = -1
|
||||
this.$emit('input', this.inputData)
|
||||
this.$emit('click', this.inputData, { event: e })
|
||||
},
|
||||
|
||||
handleInput: function (val) {
|
||||
this.inputData = val
|
||||
|
||||
if (this.isSearch &&
|
||||
this.searchState.selectedOption !== -1 &&
|
||||
this.inputData === this.visibleDataList[this.searchState.selectedOption]) { return }
|
||||
|
@ -212,6 +234,9 @@ export default defineComponent({
|
|||
this.handleClick()
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
handleKeyDown: function (event) {
|
||||
if (event.key === 'Enter') {
|
||||
// Update Input box value if enter key was pressed and option selected
|
||||
|
@ -229,25 +254,32 @@ export default defineComponent({
|
|||
|
||||
this.searchState.showOptions = true
|
||||
const isArrow = event.key === 'ArrowDown' || event.key === 'ArrowUp'
|
||||
if (isArrow) {
|
||||
event.preventDefault()
|
||||
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--
|
||||
}
|
||||
if (!isArrow) {
|
||||
const selectedOptionValue = this.searchStateKeyboardSelectedOptionValue
|
||||
// Keyboard selected & is char
|
||||
if (!isNullOrEmpty(selectedOptionValue) && isKeyboardEventKeyPrintableChar(event.key)) {
|
||||
// Update input based on KB selected suggestion value instead of current input value
|
||||
event.preventDefault()
|
||||
this.handleInput(`${selectedOptionValue}${event.key}`)
|
||||
return
|
||||
}
|
||||
if (this.searchState.selectedOption < 0) {
|
||||
this.searchState.selectedOption = this.visibleDataList.length
|
||||
} else if (this.searchState.selectedOption > this.visibleDataList.length - 1) {
|
||||
this.searchState.selectedOption = 0
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.searchState.selectedOption++
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
this.searchState.selectedOption--
|
||||
}
|
||||
// Allow deselecting suggestion
|
||||
if (this.searchState.selectedOption < -1) {
|
||||
this.searchState.selectedOption = this.visibleDataList.length - 1
|
||||
} else if (this.searchState.selectedOption > this.visibleDataList.length - 1) {
|
||||
this.searchState.selectedOption = -1
|
||||
}
|
||||
// Update displayed value
|
||||
this.searchState.keyboardSelectedOptionIndex = this.searchState.selectedOption
|
||||
},
|
||||
|
||||
handleInputBlur: function () {
|
||||
|
@ -260,21 +292,19 @@ export default defineComponent({
|
|||
|
||||
updateVisibleDataList: function () {
|
||||
if (this.dataList.length === 0) { return }
|
||||
// Reset selected option before it's updated
|
||||
this.searchState.selectedOption = -1
|
||||
this.searchState.keyboardSelectedOptionIndex = -1
|
||||
if (this.inputData === '') {
|
||||
this.visibleDataList = this.dataList
|
||||
return
|
||||
}
|
||||
// get list of items that match input
|
||||
const lowerCaseInputData = this.inputData.toLowerCase()
|
||||
const visList = this.dataList.filter(x => {
|
||||
if (x.toLowerCase().indexOf(lowerCaseInputData) !== -1) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
this.visibleDataList = visList
|
||||
this.visibleDataList = this.dataList.filter(x => {
|
||||
return x.toLowerCase().indexOf(lowerCaseInputData) !== -1
|
||||
})
|
||||
},
|
||||
|
||||
updateInputData: function(text) {
|
||||
|
|
|
@ -42,13 +42,14 @@
|
|||
<input
|
||||
:id="id"
|
||||
ref="input"
|
||||
v-model="inputData"
|
||||
:value="inputDataDisplayed"
|
||||
:list="idDataList"
|
||||
class="ft-input"
|
||||
:type="inputType"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:spellcheck="spellcheck"
|
||||
:aria-label="!showLabel ? placeholder : null"
|
||||
@input="e => handleInput(e.target.value)"
|
||||
@focus="handleFocus"
|
||||
@blur="handleInputBlur"
|
||||
|
@ -77,9 +78,10 @@
|
|||
<li
|
||||
v-for="(list, index) in visibleDataList"
|
||||
:key="index"
|
||||
:class="searchState.selectedOption == index ? 'hover': ''"
|
||||
:class="searchState.selectedOption === index ? 'hover': ''"
|
||||
@click="handleOptionClick(index)"
|
||||
@mouseenter="searchState.selectedOption = index"
|
||||
@mouseleave="searchState.selectedOption = -1"
|
||||
>
|
||||
{{ list }}
|
||||
</li>
|
||||
|
|
|
@ -10,10 +10,13 @@
|
|||
<div class="channelThumbnail">
|
||||
<router-link
|
||||
:to="`/channel/${id}`"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="thumbnail"
|
||||
class="channelImage"
|
||||
alt=""
|
||||
>
|
||||
</router-link>
|
||||
</div>
|
||||
|
@ -22,7 +25,9 @@
|
|||
class="title"
|
||||
:to="`/channel/${id}`"
|
||||
>
|
||||
{{ channelName }}
|
||||
<h3 class="h3Title">
|
||||
{{ channelName }}
|
||||
</h3>
|
||||
</router-link>
|
||||
<div class="infoLine">
|
||||
<span
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
<router-link
|
||||
class="thumbnailLink"
|
||||
:to="`/playlist/${playlistId}`"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
|
@ -32,7 +34,9 @@
|
|||
class="title"
|
||||
:to="`/playlist/${playlistId}`"
|
||||
>
|
||||
{{ title }}
|
||||
<h3 class="h3Title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
</router-link>
|
||||
<div class="infoLine">
|
||||
<router-link
|
||||
|
|
|
@ -316,10 +316,17 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
displayTitle: function () {
|
||||
if (this.showDistractionFreeTitles) {
|
||||
return toDistractionFreeTitle(this.title)
|
||||
let title
|
||||
if (this.useDeArrowTitles && this.deArrowCache?.title) {
|
||||
title = this.deArrowCache.title
|
||||
} else {
|
||||
return this.title
|
||||
title = this.title
|
||||
}
|
||||
|
||||
if (this.showDistractionFreeTitles) {
|
||||
return toDistractionFreeTitle(title)
|
||||
} else {
|
||||
return title
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -354,7 +361,7 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
deArrowCache: function () {
|
||||
return this.$store.getters.getDeArrowCache(this.id)
|
||||
return this.$store.getters.getDeArrowCache[this.id]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -365,15 +372,13 @@ export default defineComponent({
|
|||
created: function () {
|
||||
this.parseVideoData()
|
||||
this.checkIfWatched()
|
||||
|
||||
if (this.useDeArrowTitles && !this.deArrowCache) {
|
||||
this.fetchDeArrowData()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDeArrowDataEntry: async function() {
|
||||
// Read from local cache or remote
|
||||
// Write to cache if read from remote
|
||||
if (!this.useDeArrowTitles) { return null }
|
||||
|
||||
if (this.deArrowCache) { return this.deArrowCache }
|
||||
|
||||
fetchDeArrowData: async function() {
|
||||
const videoId = this.id
|
||||
const data = await deArrowData(this.id)
|
||||
const cacheData = { videoId, title: null }
|
||||
|
@ -383,7 +388,6 @@ export default defineComponent({
|
|||
|
||||
// Save data to cache whether data available or not to prevent duplicate requests
|
||||
this.$store.commit('addVideoToDeArrowCache', cacheData)
|
||||
return cacheData
|
||||
},
|
||||
|
||||
handleExternalPlayer: function () {
|
||||
|
@ -456,14 +460,20 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
parseVideoData: async function () {
|
||||
parseVideoData: function () {
|
||||
this.id = this.data.videoId
|
||||
this.title = (await this.getDeArrowDataEntry())?.title ?? this.data.title
|
||||
this.title = this.data.title
|
||||
// this.thumbnail = this.data.videoThumbnails[4].url
|
||||
|
||||
this.channelName = this.data.author ?? null
|
||||
this.channelId = this.data.authorId ?? null
|
||||
this.duration = formatDurationAsTimestamp(this.data.lengthSeconds)
|
||||
|
||||
if (this.data.isRSS && this.historyIndex !== -1) {
|
||||
this.duration = formatDurationAsTimestamp(this.historyCache[this.historyIndex].lengthSeconds)
|
||||
} else {
|
||||
this.duration = formatDurationAsTimestamp(this.data.lengthSeconds)
|
||||
}
|
||||
|
||||
this.description = this.data.description
|
||||
this.isLive = this.data.liveNow || this.data.lengthSeconds === 'undefined'
|
||||
this.isUpcoming = this.data.isUpcoming || this.data.premiere
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
<router-link
|
||||
class="thumbnailLink"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
:to="{
|
||||
path: `/watch/${id}`,
|
||||
query: playlistIdFinal ? {playlistId: playlistIdFinal} : {}
|
||||
|
@ -22,6 +23,7 @@
|
|||
<img
|
||||
:src="thumbnail"
|
||||
class="thumbnailImage"
|
||||
alt=""
|
||||
>
|
||||
</router-link>
|
||||
<div
|
||||
|
@ -74,7 +76,9 @@
|
|||
query: playlistIdFinal ? {playlistId: playlistIdFinal} : {}
|
||||
}"
|
||||
>
|
||||
{{ displayTitle }}
|
||||
<h3 class="h3Title">
|
||||
{{ displayTitle }}
|
||||
</h3>
|
||||
</router-link>
|
||||
<div class="infoLine">
|
||||
<router-link
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { defineComponent } from 'vue'
|
||||
import { sanitizeForHtmlId } from '../../helpers/accessibility'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FtProfileBubble',
|
||||
|
@ -21,12 +22,20 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
sanitizedId: function() {
|
||||
return 'profileBubble' + sanitizeForHtmlId(this.profileId)
|
||||
},
|
||||
profileInitial: function () {
|
||||
return this?.profileName?.length > 0 ? Array.from(this.profileName)[0].toUpperCase() : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToProfile: function () {
|
||||
goToProfile: function (event) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
if (event.target.getAttribute('role') === 'link' && event.key !== 'Enter') {
|
||||
return
|
||||
}
|
||||
}
|
||||
this.$router.push({
|
||||
path: `/settings/profile/edit/${this.profileId}`
|
||||
})
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
<template>
|
||||
<div
|
||||
class="bubblePadding"
|
||||
tabindex="0"
|
||||
:aria-labelledby="sanitizedId"
|
||||
@click="goToProfile"
|
||||
@keydown.space.prevent="goToProfile($event)"
|
||||
@keydown.enter.prevent="goToProfile($event)"
|
||||
>
|
||||
<div
|
||||
class="bubble"
|
||||
|
@ -11,7 +15,10 @@
|
|||
{{ profileInitial }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="profileName">
|
||||
<div
|
||||
:id="sanitizedId"
|
||||
class="profileName"
|
||||
>
|
||||
{{ profileName }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,7 @@ import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
|
|||
import FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
|
||||
import FtButton from '../../components/ft-button/ft-button.vue'
|
||||
import FtPrompt from '../../components/ft-prompt/ft-prompt.vue'
|
||||
import { showToast } from '../../helpers/utils'
|
||||
import { deepCopy, showToast } from '../../helpers/utils'
|
||||
import { youtubeImageUrlToInvidious } from '../../helpers/api/invidious'
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -68,48 +68,38 @@ export default defineComponent({
|
|||
this.$t('Yes'),
|
||||
this.$t('No')
|
||||
]
|
||||
}
|
||||
},
|
||||
locale: function () {
|
||||
return this.$i18n.locale.replace('_', '-')
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
profile: function () {
|
||||
this.subscriptions = JSON.parse(JSON.stringify(this.profile.subscriptions)).sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase()
|
||||
const nameB = b.name.toLowerCase()
|
||||
if (nameA < nameB) {
|
||||
return -1
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}).map((channel) => {
|
||||
const subscriptions = deepCopy(this.profile.subscriptions).sort((a, b) => {
|
||||
return a.name?.toLowerCase().localeCompare(b.name?.toLowerCase(), this.locale)
|
||||
})
|
||||
subscriptions.forEach((channel) => {
|
||||
if (this.backendPreference === 'invidious') {
|
||||
channel.thumbnail = youtubeImageUrlToInvidious(channel.thumbnail, this.currentInvidiousInstance)
|
||||
}
|
||||
channel.selected = false
|
||||
return channel
|
||||
})
|
||||
|
||||
this.subscriptions = subscriptions
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
if (typeof this.profile.subscriptions !== 'undefined') {
|
||||
this.subscriptions = JSON.parse(JSON.stringify(this.profile.subscriptions)).sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase()
|
||||
const nameB = b.name.toLowerCase()
|
||||
if (nameA < nameB) {
|
||||
return -1
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}).map((channel) => {
|
||||
const subscriptions = deepCopy(this.profile.subscriptions).sort((a, b) => {
|
||||
return a.name?.toLowerCase().localeCompare(b.name?.toLowerCase(), this.locale)
|
||||
})
|
||||
subscriptions.forEach((channel) => {
|
||||
if (this.backendPreference === 'invidious') {
|
||||
channel.thumbnail = youtubeImageUrlToInvidious(channel.thumbnail, this.currentInvidiousInstance)
|
||||
}
|
||||
channel.selected = false
|
||||
return channel
|
||||
})
|
||||
this.subscriptions = subscriptions
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -133,7 +123,7 @@ export default defineComponent({
|
|||
})
|
||||
|
||||
this.profileList.forEach((x) => {
|
||||
const profile = JSON.parse(JSON.stringify(x))
|
||||
const profile = deepCopy(x)
|
||||
profile.subscriptions = profile.subscriptions.filter((channel) => {
|
||||
const index = channelsToRemove.findIndex((y) => {
|
||||
return y.id === channel.id
|
||||
|
@ -147,7 +137,7 @@ export default defineComponent({
|
|||
showToast(this.$t('Profile.Profile has been updated'))
|
||||
this.selectNone()
|
||||
} else {
|
||||
const profile = JSON.parse(JSON.stringify(this.profile))
|
||||
const profile = deepCopy(this.profile)
|
||||
|
||||
this.subscriptions = this.subscriptions.filter((channel) => {
|
||||
return !channel.selected
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
:channel-name="channel.name"
|
||||
:channel-thumbnail="channel.thumbnail"
|
||||
:show-selected="true"
|
||||
role="button"
|
||||
@click="handleChannelClick(index)"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
|
|
|
@ -19,8 +19,12 @@
|
|||
v-for="(color, index) in colorValues"
|
||||
:key="index"
|
||||
class="colorOption"
|
||||
:title="color + ' ' + $t('Profile.Custom Color')"
|
||||
:style="{ background: color }"
|
||||
tabindex="0"
|
||||
@click="profileBgColor = color"
|
||||
@keydown.space.prevent="profileBgColor = color"
|
||||
@keydown.enter.prevent="profileBgColor = color"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<ft-flex-box
|
||||
|
@ -66,23 +70,25 @@
|
|||
:label="$t('Profile.Create Profile')"
|
||||
@click="saveProfile"
|
||||
/>
|
||||
<ft-button
|
||||
v-if="!isNew"
|
||||
:label="$t('Profile.Update Profile')"
|
||||
@click="saveProfile"
|
||||
/>
|
||||
<ft-button
|
||||
v-if="!isNew"
|
||||
:label="$t('Profile.Make Default Profile')"
|
||||
@click="setDefaultProfile"
|
||||
/>
|
||||
<ft-button
|
||||
v-if="!isMainProfile && !isNew"
|
||||
:label="$t('Profile.Delete Profile')"
|
||||
text-color="var(--text-with-main-color)"
|
||||
background-color="var(--primary-color)"
|
||||
@click="openDeletePrompt"
|
||||
/>
|
||||
<template
|
||||
v-else
|
||||
>
|
||||
<ft-button
|
||||
:label="$t('Profile.Update Profile')"
|
||||
@click="saveProfile"
|
||||
/>
|
||||
<ft-button
|
||||
:label="$t('Profile.Make Default Profile')"
|
||||
@click="setDefaultProfile"
|
||||
/>
|
||||
<ft-button
|
||||
v-if="!isMainProfile"
|
||||
:label="$t('Profile.Delete Profile')"
|
||||
text-color="var(--text-with-main-color)"
|
||||
background-color="var(--primary-color)"
|
||||
@click="openDeletePrompt"
|
||||
/>
|
||||
</template>
|
||||
</ft-flex-box>
|
||||
</ft-card>
|
||||
<ft-prompt
|
||||
|
|
|
@ -6,7 +6,7 @@ import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
|
|||
import FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
|
||||
import FtButton from '../../components/ft-button/ft-button.vue'
|
||||
import FtSelect from '../ft-select/ft-select.vue'
|
||||
import { showToast } from '../../helpers/utils'
|
||||
import { deepCopy, showToast } from '../../helpers/utils'
|
||||
import { youtubeImageUrlToInvidious } from '../../helpers/api/invidious'
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -50,7 +50,10 @@ export default defineComponent({
|
|||
},
|
||||
selectedText: function () {
|
||||
return this.$t('Profile.{number} selected', { number: this.selectedLength })
|
||||
}
|
||||
},
|
||||
locale: function () {
|
||||
return this.$i18n.locale.replace('_', '-')
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
profile: 'updateChannelList',
|
||||
|
@ -58,16 +61,8 @@ export default defineComponent({
|
|||
},
|
||||
mounted: function () {
|
||||
if (typeof this.profile.subscriptions !== 'undefined') {
|
||||
this.channels = JSON.parse(JSON.stringify(this.profileList[this.filteredProfileIndex].subscriptions)).sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase()
|
||||
const nameB = b.name.toLowerCase()
|
||||
if (nameA < nameB) {
|
||||
return -1
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
this.channels = deepCopy(this.profileList[this.filteredProfileIndex].subscriptions).sort((a, b) => {
|
||||
return a.name?.toLowerCase().localeCompare(b.name?.toLowerCase(), this.locale)
|
||||
}).filter((channel) => {
|
||||
const index = this.profile.subscriptions.findIndex((sub) => {
|
||||
return sub.id === channel.id
|
||||
|
@ -85,16 +80,8 @@ export default defineComponent({
|
|||
},
|
||||
methods: {
|
||||
updateChannelList () {
|
||||
this.channels = JSON.parse(JSON.stringify(this.profileList[this.filteredProfileIndex].subscriptions)).sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase()
|
||||
const nameB = b.name.toLowerCase()
|
||||
if (nameA < nameB) {
|
||||
return -1
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
this.channels = deepCopy(this.profileList[this.filteredProfileIndex].subscriptions).sort((a, b) => {
|
||||
return a.name?.toLowerCase().localeCompare(b.name?.toLowerCase(), this.locale)
|
||||
}).filter((channel) => {
|
||||
const index = this.profile.subscriptions.findIndex((sub) => {
|
||||
return sub.id === channel.id
|
||||
|
@ -129,7 +116,7 @@ export default defineComponent({
|
|||
return channel.selected
|
||||
})
|
||||
|
||||
const profile = JSON.parse(JSON.stringify(this.profile))
|
||||
const profile = deepCopy(this.profile)
|
||||
profile.subscriptions = profile.subscriptions.concat(subscriptions)
|
||||
this.updateProfile(profile)
|
||||
showToast(this.$t('Profile.Profile has been updated'))
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
:channel-name="channel.name"
|
||||
:channel-thumbnail="channel.thumbnail"
|
||||
:show-selected="true"
|
||||
role="button"
|
||||
@click="handleChannelClick(index)"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
|
|
|
@ -2,9 +2,14 @@
|
|||
<div>
|
||||
<div
|
||||
class="colorOption"
|
||||
:title="$t('Profile.Toggle Profile List')"
|
||||
:style="{ background: activeProfile.bgColor, color: activeProfile.textColor }"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
@click="toggleProfileList"
|
||||
@mousedown="handleIconMouseDown"
|
||||
@keydown.space.prevent="toggleProfileList"
|
||||
@keydown.enter.prevent="toggleProfileList"
|
||||
>
|
||||
<div
|
||||
class="initial"
|
||||
|
@ -20,6 +25,7 @@
|
|||
@focusout="handleProfileListFocusOut"
|
||||
>
|
||||
<h3
|
||||
id="profileListTitle"
|
||||
class="profileListTitle"
|
||||
>
|
||||
{{ $t("Profile.Profile Select") }}
|
||||
|
@ -31,12 +37,20 @@
|
|||
/>
|
||||
<div
|
||||
class="profileWrapper"
|
||||
role="listbox"
|
||||
aria-labelledby="profileListTitle"
|
||||
>
|
||||
<div
|
||||
v-for="(profile, index) in profileList"
|
||||
:id="'profile-' + index"
|
||||
:key="index"
|
||||
class="profile"
|
||||
:aria-labelledby="'profile-' + index + '-name'"
|
||||
aria-selected="false"
|
||||
tabindex="0"
|
||||
role="option"
|
||||
@click="setActiveProfile(profile)"
|
||||
@keydown.enter.prevent="setActiveProfile(profile, $event)"
|
||||
>
|
||||
<div
|
||||
class="colorOption"
|
||||
|
@ -49,6 +63,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<p
|
||||
:id="'profile-' + index + '-name'"
|
||||
class="profileName"
|
||||
>
|
||||
{{ profile.name }}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { defineComponent } from 'vue'
|
|||
import FtCard from '../../components/ft-card/ft-card.vue'
|
||||
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
|
||||
import FtButton from '../../components/ft-button/ft-button.vue'
|
||||
import { Injectables } from '../../../constants'
|
||||
import { sanitizeForHtmlId } from '../../helpers/accessibility'
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -11,6 +12,9 @@ export default defineComponent({
|
|||
'ft-flex-box': FtFlexBox,
|
||||
'ft-button': FtButton
|
||||
},
|
||||
inject: {
|
||||
showOutlines: Injectables.SHOW_OUTLINES
|
||||
},
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
|
@ -77,7 +81,8 @@ export default defineComponent({
|
|||
index = 0
|
||||
}
|
||||
if (index >= 0 && index < this.promptButtons.length) {
|
||||
this.promptButtons[index].focus({ focusVisible: true })
|
||||
this.promptButtons[index].focus()
|
||||
this.showOutlines()
|
||||
}
|
||||
},
|
||||
// close on escape key and unfocus
|
||||
|
|
|
@ -5,7 +5,7 @@ import { mapActions } from 'vuex'
|
|||
import FtButton from '../../components/ft-button/ft-button.vue'
|
||||
|
||||
import { MAIN_PROFILE_ID } from '../../../constants'
|
||||
import { showToast } from '../../helpers/utils'
|
||||
import { deepCopy, showToast } from '../../helpers/utils'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FtSubscribeButton',
|
||||
|
@ -69,7 +69,7 @@ export default defineComponent({
|
|||
return
|
||||
}
|
||||
|
||||
const currentProfile = JSON.parse(JSON.stringify(this.activeProfile))
|
||||
const currentProfile = deepCopy(this.activeProfile)
|
||||
|
||||
if (this.isSubscribed) {
|
||||
currentProfile.subscriptions = currentProfile.subscriptions.filter((channel) => {
|
||||
|
@ -108,9 +108,9 @@ export default defineComponent({
|
|||
showToast(this.$t('Channel.Added channel to your subscriptions'))
|
||||
|
||||
if (this.activeProfile._id !== MAIN_PROFILE_ID) {
|
||||
const primaryProfile = JSON.parse(JSON.stringify(this.profileList.find(prof => {
|
||||
const primaryProfile = deepCopy(this.profileList.find(prof => {
|
||||
return prof._id === MAIN_PROFILE_ID
|
||||
})))
|
||||
}))
|
||||
|
||||
const index = primaryProfile.subscriptions.findIndex((channel) => {
|
||||
return channel.id === this.channelId
|
||||
|
@ -125,7 +125,7 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
unsubscribe: function(profile, channelId) {
|
||||
const parsedProfile = JSON.parse(JSON.stringify(profile))
|
||||
const parsedProfile = deepCopy(profile)
|
||||
const index = parsedProfile.subscriptions.findIndex((channel) => {
|
||||
return channel.id === channelId
|
||||
})
|
||||
|
|
|
@ -9,4 +9,4 @@
|
|||
</template>
|
||||
|
||||
<script src="./ft-subscribe-button.js" />
|
||||
<style scoped src="./ft-subscribe-button.scss" lang="scss" />
|
||||
<style src="./ft-subscribe-button.css" />
|
||||
|
|
|
@ -1,4 +1,2 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
const events = new Vue()
|
||||
const events = new EventTarget()
|
||||
export default events
|
||||
|
|
|
@ -9,10 +9,10 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
mounted: function () {
|
||||
FtToastEvents.$on('toast-open', this.open)
|
||||
FtToastEvents.addEventListener('toast-open', this.open)
|
||||
},
|
||||
beforeDestroy: function () {
|
||||
FtToastEvents.$off('toast-open', this.open)
|
||||
FtToastEvents.removeEventListener('toast-open', this.open)
|
||||
},
|
||||
methods: {
|
||||
performAction: function (index) {
|
||||
|
@ -25,7 +25,7 @@ export default defineComponent({
|
|||
|
||||
toast.isOpen = false
|
||||
},
|
||||
open: function (message, time, action) {
|
||||
open: function ({ detail: { message, time, action } }) {
|
||||
const toast = { message: message, action: action || (() => { }), isOpen: false, timeout: null }
|
||||
toast.timeout = setTimeout(this.close, time || 3000, toast)
|
||||
setTimeout(() => { toast.isOpen = true })
|
||||
|
|
|
@ -25,6 +25,13 @@ import {
|
|||
import { getProxyUrl } from '../../helpers/api/invidious'
|
||||
import store from '../../store'
|
||||
|
||||
const EXPECTED_PLAY_RELATED_ERROR_MESSAGES = [
|
||||
// This is thrown when `play()` called but user already viewing another page
|
||||
'The play() request was interrupted by a new load request.',
|
||||
// This is thrown when `pause()` called before video started playing on load
|
||||
'The play() request was interrupted by a call to pause()',
|
||||
]
|
||||
|
||||
// YouTube now throttles if you use the `Range` header for the DASH formats, instead of the range query parameter
|
||||
// videojs-http-streaming calls this hook everytime it makes a request,
|
||||
// so we can use it to convert the Range header into the range query parameter for the streaming URLs
|
||||
|
@ -93,9 +100,21 @@ export default defineComponent({
|
|||
type: Array,
|
||||
default: () => { return [] }
|
||||
},
|
||||
currentChapterIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
audioTracks: {
|
||||
type: Array,
|
||||
default: () => ([])
|
||||
},
|
||||
theatrePossible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
useTheatreMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
|
@ -160,7 +179,7 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
currentLocale: function () {
|
||||
return this.$i18n.locale
|
||||
return this.$i18n.locale.replace('_', '-')
|
||||
},
|
||||
|
||||
defaultPlayback: function () {
|
||||
|
@ -1424,11 +1443,11 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
createToggleTheatreModeButton: function() {
|
||||
if (!this.$parent.theatrePossible) {
|
||||
if (!this.theatrePossible) {
|
||||
return
|
||||
}
|
||||
|
||||
const theatreModeActive = this.$parent.useTheatreMode ? ' vjs-icon-theatre-active' : ''
|
||||
const theatreModeActive = this.useTheatreMode ? ' vjs-icon-theatre-active' : ''
|
||||
|
||||
const toggleTheatreMode = this.toggleTheatreMode
|
||||
|
||||
|
@ -1458,14 +1477,14 @@ export default defineComponent({
|
|||
toggleTheatreMode: function() {
|
||||
if (!this.player.isFullscreen_) {
|
||||
const toggleTheatreModeButton = document.getElementById('toggleTheatreModeButton')
|
||||
if (!this.$parent.useTheatreMode) {
|
||||
if (!this.useTheatreMode) {
|
||||
toggleTheatreModeButton.classList.add('vjs-icon-theatre-active')
|
||||
} else {
|
||||
toggleTheatreModeButton.classList.remove('vjs-icon-theatre-active')
|
||||
}
|
||||
}
|
||||
|
||||
this.$parent.toggleTheatreMode()
|
||||
this.$emit('toggle-theatre-mode')
|
||||
},
|
||||
|
||||
createScreenshotButton: function () {
|
||||
|
@ -1736,7 +1755,7 @@ export default defineComponent({
|
|||
const bCode = captionB.language_code.split('-')
|
||||
const aName = (captionA.label) // ex: english (auto-generated)
|
||||
const bName = (captionB.label)
|
||||
const userLocale = this.currentLocale.split(/-|_/) // ex. [en,US]
|
||||
const userLocale = this.currentLocale.split('-') // ex. [en,US]
|
||||
if (aCode[0] === userLocale[0]) { // caption a has same language as user's locale
|
||||
if (bCode[0] === userLocale[0]) { // caption b has same language as user's locale
|
||||
if (bName.search('auto') !== -1) {
|
||||
|
@ -1767,7 +1786,7 @@ export default defineComponent({
|
|||
return 1
|
||||
}
|
||||
// sort alphabetically
|
||||
return aName.localeCompare(bName)
|
||||
return aName.localeCompare(bName, this.currentLocale)
|
||||
})
|
||||
},
|
||||
|
||||
|
@ -1949,7 +1968,7 @@ export default defineComponent({
|
|||
* @returns {boolean}
|
||||
*/
|
||||
canChapterJump: function (event, direction) {
|
||||
const currentChapter = this.$parent.videoCurrentChapterIndex
|
||||
const currentChapter = this.currentChapterIndex
|
||||
return this.chapters.length > 0 &&
|
||||
(direction === 'previous' ? currentChapter > 0 : this.chapters.length - 1 !== currentChapter) &&
|
||||
((process.platform !== 'darwin' && event.ctrlKey) ||
|
||||
|
@ -1967,9 +1986,8 @@ export default defineComponent({
|
|||
}
|
||||
promise
|
||||
.catch(err => {
|
||||
if (err.message.includes('The play() request was interrupted by a new load request.')) {
|
||||
if (EXPECTED_PLAY_RELATED_ERROR_MESSAGES.some(msg => err.message.includes(msg))) {
|
||||
// Ignoring expected exception
|
||||
// This is thrown when `play()` called but user already viewing another page
|
||||
// console.debug('Ignoring expected error')
|
||||
// console.debug(err)
|
||||
return
|
||||
|
@ -2075,7 +2093,7 @@ export default defineComponent({
|
|||
event.preventDefault()
|
||||
if (this.canChapterJump(event, 'previous')) {
|
||||
// Jump to the previous chapter
|
||||
this.player.currentTime(this.chapters[this.$parent.videoCurrentChapterIndex - 1].startSeconds)
|
||||
this.player.currentTime(this.chapters[this.currentChapterIndex - 1].startSeconds)
|
||||
} else {
|
||||
// Rewind by the time-skip interval (in seconds)
|
||||
this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate())
|
||||
|
@ -2085,7 +2103,7 @@ export default defineComponent({
|
|||
event.preventDefault()
|
||||
if (this.canChapterJump(event, 'next')) {
|
||||
// Jump to the next chapter
|
||||
this.player.currentTime(this.chapters[this.$parent.videoCurrentChapterIndex + 1].startSeconds)
|
||||
this.player.currentTime(this.chapters[this.currentChapterIndex + 1].startSeconds)
|
||||
} else {
|
||||
// Fast-Forward by the time-skip interval (in seconds)
|
||||
this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate())
|
||||
|
|
|
@ -22,7 +22,7 @@ export default defineComponent({
|
|||
title: '',
|
||||
channelThumbnail: '',
|
||||
channelName: '',
|
||||
channelId: '',
|
||||
channelId: null,
|
||||
videoCount: 0,
|
||||
viewCount: null,
|
||||
lastUpdated: '',
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
class="channelShareWrapper"
|
||||
>
|
||||
<router-link
|
||||
v-if="channelId"
|
||||
class="playlistChannel"
|
||||
:to="`/channel/${channelId}`"
|
||||
>
|
||||
|
@ -60,6 +61,16 @@
|
|||
{{ channelName }}
|
||||
</h3>
|
||||
</router-link>
|
||||
<div
|
||||
v-else
|
||||
class="playlistChannel"
|
||||
>
|
||||
<h3
|
||||
class="channelName"
|
||||
>
|
||||
{{ channelName }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ft-share-button
|
||||
v-if="!hideSharingActions"
|
||||
|
|
|
@ -94,24 +94,24 @@ export default defineComponent({
|
|||
|
||||
this.updateActiveProfile(MAIN_PROFILE_ID)
|
||||
|
||||
if (option === 'yes') {
|
||||
this.profileList.forEach((profile) => {
|
||||
if (profile._id === MAIN_PROFILE_ID) {
|
||||
const newProfile = {
|
||||
_id: MAIN_PROFILE_ID,
|
||||
name: profile.name,
|
||||
bgColor: profile.bgColor,
|
||||
textColor: profile.textColor,
|
||||
subscriptions: []
|
||||
}
|
||||
this.updateProfile(newProfile)
|
||||
} else {
|
||||
this.removeProfile(profile._id)
|
||||
}
|
||||
})
|
||||
if (option !== 'yes') { return }
|
||||
|
||||
this.clearSubscriptionsCache()
|
||||
}
|
||||
this.profileList.forEach((profile) => {
|
||||
if (profile._id === MAIN_PROFILE_ID) {
|
||||
const newProfile = {
|
||||
_id: MAIN_PROFILE_ID,
|
||||
name: profile.name,
|
||||
bgColor: profile.bgColor,
|
||||
textColor: profile.textColor,
|
||||
subscriptions: []
|
||||
}
|
||||
this.updateProfile(newProfile)
|
||||
} else {
|
||||
this.removeProfile(profile._id)
|
||||
}
|
||||
})
|
||||
|
||||
this.clearSubscriptionsCache()
|
||||
},
|
||||
|
||||
...mapActions([
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
@change="handleUpdateProxy"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<div
|
||||
<template
|
||||
v-if="useProxy"
|
||||
>
|
||||
<ft-flex-box>
|
||||
|
@ -72,7 +72,7 @@
|
|||
{{ $t('Settings.Proxy Settings.City') }}: {{ proxyCity }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ft-settings-section>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { defineComponent } from 'vue'
|
|||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import SideNavMoreOptions from '../side-nav-more-options/side-nav-more-options.vue'
|
||||
import { youtubeImageUrlToInvidious } from '../../helpers/api/invidious'
|
||||
import { deepCopy } from '../../helpers/utils'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SideNav',
|
||||
|
@ -32,19 +33,14 @@ export default defineComponent({
|
|||
activeProfile: function () {
|
||||
return this.$store.getters.getActiveProfile
|
||||
},
|
||||
locale: function () {
|
||||
return this.$i18n.locale.replace('_', '-')
|
||||
},
|
||||
activeSubscriptions: function () {
|
||||
const subscriptions = JSON.parse(JSON.stringify(this.activeProfile.subscriptions))
|
||||
const subscriptions = deepCopy(this.activeProfile.subscriptions)
|
||||
|
||||
subscriptions.sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase()
|
||||
const nameB = b.name.toLowerCase()
|
||||
if (nameA < nameB) {
|
||||
return -1
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return a.name?.toLowerCase().localeCompare(b.name?.toLowerCase(), this.locale)
|
||||
})
|
||||
|
||||
if (this.backendPreference === 'invidious') {
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
ref="sideNav"
|
||||
class="sideNav"
|
||||
:class="[{closed: !isOpen}, applyHiddenLabels]"
|
||||
role="navigation"
|
||||
>
|
||||
<div
|
||||
class="inner"
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
@change="handleUpdateUseDeArrowTitles"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
<div
|
||||
<template
|
||||
v-if="useSponsorBlock || useDeArrowTitles"
|
||||
>
|
||||
<ft-flex-box
|
||||
|
@ -46,7 +46,7 @@
|
|||
:category-name="category"
|
||||
/>
|
||||
</ft-flex-box>
|
||||
</div>
|
||||
</template>
|
||||
</ft-settings-section>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -220,6 +220,17 @@ export default defineComponent({
|
|||
const response = await fetch(feedUrl)
|
||||
|
||||
if (response.status === 404) {
|
||||
// playlists don't exist if the channel was terminated but also if it doesn't have the tab,
|
||||
// so we need to check the channel feed too before deciding it errored, as that only 404s if the channel was terminated
|
||||
|
||||
const response2 = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channel.id}`, {
|
||||
method: 'HEAD'
|
||||
})
|
||||
|
||||
if (response2.status === 404) {
|
||||
this.errorChannels.push(channel)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
|
|
|
@ -161,6 +161,17 @@ export default defineComponent({
|
|||
const response = await fetch(feedUrl)
|
||||
|
||||
if (response.status === 404) {
|
||||
// playlists don't exist if the channel was terminated but also if it doesn't have the tab,
|
||||
// so we need to check the channel feed too before deciding it errored, as that only 404s if the channel was terminated
|
||||
|
||||
const response2 = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channel.id}`, {
|
||||
method: 'HEAD'
|
||||
})
|
||||
|
||||
if (response2.status === 404) {
|
||||
this.errorChannels.push(channel)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
|
|
|
@ -14,10 +14,6 @@
|
|||
right: 10px;
|
||||
}
|
||||
|
||||
.channelBubble {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 350px) {
|
||||
.floatingTopButton {
|
||||
position: absolute
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
:channel-name="channel.name"
|
||||
:channel-id="channel.id"
|
||||
:channel-thumbnail="channel.thumbnail"
|
||||
class="channelBubble"
|
||||
@click="goToChannel(channel.id)"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -220,7 +220,17 @@ export default defineComponent({
|
|||
const response = await fetch(feedUrl)
|
||||
|
||||
if (response.status === 404) {
|
||||
this.errorChannels.push(channel)
|
||||
// playlists don't exist if the channel was terminated but also if it doesn't have the tab,
|
||||
// so we need to check the channel feed too before deciding it errored, as that only 404s if the channel was terminated
|
||||
|
||||
const response2 = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channel.id}`, {
|
||||
method: 'HEAD'
|
||||
})
|
||||
|
||||
if (response2.status === 404) {
|
||||
this.errorChannels.push(channel)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
|
|
|
@ -54,11 +54,6 @@
|
|||
|
||||
@include top-nav-is-colored {
|
||||
color: var(--text-with-main-color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.fa-arrow-left,
|
||||
|
@ -69,10 +64,13 @@
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
&:hover {
|
||||
background-color: var(--side-nav-hover-color);
|
||||
transition: background 0.2s ease-in;
|
||||
|
||||
@include top-nav-is-colored {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
<div
|
||||
class="topNav"
|
||||
:class="{ topNavBarColor: barColor }"
|
||||
role="navigation"
|
||||
>
|
||||
<div class="side">
|
||||
<font-awesome-icon
|
||||
|
|
|
@ -49,8 +49,8 @@
|
|||
float: left;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 200px 200px 200px 200px;
|
||||
-webkit-border-radius: 200px 200px 200px 200px;
|
||||
border-radius: 200px;
|
||||
-webkit-border-radius: 200px;
|
||||
}
|
||||
|
||||
.commentAuthorWrapper {
|
||||
|
|
|
@ -242,13 +242,13 @@ export default defineComponent({
|
|||
})
|
||||
|
||||
this.$watch('$refs.downloadButton.dropdownShown', (dropdownShown) => {
|
||||
this.$parent.infoAreaSticky = !dropdownShown
|
||||
this.$emit('set-info-area-sticky', !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()
|
||||
setTimeout(() => {
|
||||
this.$emit('scroll-to-info-area')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
@ -279,20 +279,6 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
handleFormatChange: function (format) {
|
||||
switch (format) {
|
||||
case 'dash':
|
||||
this.$parent.enableDashFormat()
|
||||
break
|
||||
case 'legacy':
|
||||
this.$parent.enableLegacyFormat()
|
||||
break
|
||||
case 'audio':
|
||||
this.$parent.enableAudioFormat()
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
handleDownload: function (index) {
|
||||
const selectedDownloadLinkOption = this.downloadLinkOptions[index]
|
||||
const url = selectedDownloadLinkOption.value
|
||||
|
|
|
@ -115,7 +115,7 @@
|
|||
theme="secondary"
|
||||
:icon="['fas', 'file-video']"
|
||||
:dropdown-options="formatTypeOptions"
|
||||
@click="handleFormatChange"
|
||||
@click="$emit('change-format', $event)"
|
||||
/>
|
||||
<ft-share-button
|
||||
v-if="!hideSharingActions"
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
height: 30px;
|
||||
cursor: pointer;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 200px 200px 200px 200px;
|
||||
-webkit-border-radius: 200px 200px 200px 200px;
|
||||
border-radius: 200px;
|
||||
-webkit-border-radius: 200px;
|
||||
}
|
||||
|
||||
.superChatContent {
|
||||
|
@ -124,8 +124,8 @@
|
|||
margin-top: 25px;
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 5px 5px 5px 5px;
|
||||
-webkit-border-radius: 5px 5px 5px 5px;
|
||||
border-radius: 5px;
|
||||
-webkit-border-radius: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
@ -215,8 +215,8 @@
|
|||
left: 45%;
|
||||
bottom: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 200px 200px 200px 200px;
|
||||
-webkit-border-radius: 200px 200px 200px 200px;
|
||||
border-radius: 200px;
|
||||
-webkit-border-radius: 200px;
|
||||
text-align: center;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
|
|
@ -134,6 +134,15 @@ export default defineComponent({
|
|||
nextTick(() => this.scrollToCurrentVideo())
|
||||
}
|
||||
},
|
||||
playlistId: function (newVal, oldVal) {
|
||||
if (oldVal !== newVal) {
|
||||
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
|
||||
this.getPlaylistInformationInvidious()
|
||||
} else {
|
||||
this.getPlaylistInformationLocal()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
const cachedPlaylist = this.$store.getters.getCachedPlaylist
|
||||
|
@ -355,8 +364,19 @@ export default defineComponent({
|
|||
try {
|
||||
let playlist = await getLocalPlaylist(this.playlistId)
|
||||
|
||||
let channelName
|
||||
|
||||
if (playlist.info.author) {
|
||||
channelName = playlist.info.author.name
|
||||
} else {
|
||||
const subtitle = playlist.info.subtitle.toString()
|
||||
|
||||
const index = subtitle.lastIndexOf('•')
|
||||
channelName = subtitle.substring(0, index).trim()
|
||||
}
|
||||
|
||||
this.playlistTitle = playlist.info.title
|
||||
this.channelName = playlist.info.author?.name
|
||||
this.channelName = channelName
|
||||
this.channelId = playlist.info.author?.id
|
||||
|
||||
const videos = playlist.items.map(parseLocalPlaylistVideo)
|
||||
|
|
|
@ -15,11 +15,18 @@
|
|||
</router-link>
|
||||
</h3>
|
||||
<router-link
|
||||
v-if="channelId"
|
||||
class="channelName"
|
||||
:to="`/channel/${channelId}`"
|
||||
>
|
||||
{{ channelName }}
|
||||
</router-link>
|
||||
<span
|
||||
v-else
|
||||
class="channelName"
|
||||
>
|
||||
{{ channelName }}
|
||||
</span>
|
||||
<span
|
||||
class="playlistIndex"
|
||||
>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import store from '../../store/index'
|
||||
import { isNullOrEmpty, stripHTML, toLocalePublicationString } from '../utils'
|
||||
import { stripHTML, toLocalePublicationString } from '../utils'
|
||||
import { isNullOrEmpty } from '../strings'
|
||||
import autolinker from 'autolinker'
|
||||
|
||||
function getCurrentInstance() {
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* This will return true if a string is null, undefined or empty.
|
||||
* @param {string|null|undefined} _string the string to process
|
||||
* @returns {boolean} whether the string is empty or not
|
||||
*/
|
||||
export function isNullOrEmpty(_string) {
|
||||
return _string == null || _string === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Is KeyboardEvent.key a printable char
|
||||
* @param {string} eventKey the string from KeyboardEvent.key to process
|
||||
* @returns {boolean} whether the string from KeyboardEvent.key is a printable char or not
|
||||
*/
|
||||
export function isKeyboardEventKeyPrintableChar(eventKey) {
|
||||
// Most printable chars are all strings with length 1 (except Unicode)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
|
||||
// https://www.w3.org/TR/DOM-Level-3-Events-key/
|
||||
if (eventKey.length === 1) { return true }
|
||||
// Emoji
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape
|
||||
if (/\p{Emoji_Presentation}/u.test(eventKey)) { return true }
|
||||
|
||||
return false
|
||||
}
|
|
@ -189,7 +189,13 @@ export async function getFormatsFromHLSManifest(manifestUrl) {
|
|||
}
|
||||
|
||||
export function showToast(message, time = null, action = null) {
|
||||
FtToastEvents.$emit('toast-open', message, time, action)
|
||||
FtToastEvents.dispatchEvent(new CustomEvent('toast-open', {
|
||||
detail: {
|
||||
message,
|
||||
time,
|
||||
action
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -627,15 +633,6 @@ export function formatNumber(number, options = undefined) {
|
|||
return Intl.NumberFormat([i18n.locale.replace('_', '-'), 'en'], options).format(number)
|
||||
}
|
||||
|
||||
/**
|
||||
* This will return true if a string is null, undefined or empty.
|
||||
* @param {string} _string the string to process
|
||||
* @returns {bool} whether the string is empty or not
|
||||
*/
|
||||
export function isNullOrEmpty(_string) {
|
||||
return _string == null || _string === ''
|
||||
}
|
||||
|
||||
export function getTodayDateStrLocalTimezone() {
|
||||
const timeNow = new Date()
|
||||
// `Date#getTimezoneOffset` returns the difference, in minutes
|
||||
|
@ -659,3 +656,12 @@ export function escapeHTML(untrusted) {
|
|||
.replaceAll('"', '"')
|
||||
.replaceAll('\'', ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a deep copy of a javascript object
|
||||
* @param {Object} obj
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function deepCopy(obj) {
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
}
|
||||
|
|
|
@ -304,3 +304,11 @@ $watched-transition-duration: 0.5s;
|
|||
.upcoming {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// we use h3 for semantic reasons but don't want to keep the h3 style
|
||||
.h3Title {
|
||||
margin-block-start: inherit;
|
||||
margin-block-end: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
|
|
@ -63,10 +63,6 @@ const actions = {
|
|||
} catch (errMessage) {
|
||||
console.error(errMessage)
|
||||
}
|
||||
},
|
||||
|
||||
compactHistory(_) {
|
||||
DBHistoryHandlers.persist()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { MAIN_PROFILE_ID } from '../../../constants'
|
||||
import { DBProfileHandlers } from '../../../datastores/handlers/index'
|
||||
import { calculateColorLuminance, getRandomColor } from '../../helpers/colors'
|
||||
import { deepCopy } from '../../helpers/utils'
|
||||
|
||||
const state = {
|
||||
profileList: [{
|
||||
|
@ -94,19 +95,20 @@ const actions = {
|
|||
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 currentProfileCopy = deepCopy(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
|
||||
}
|
||||
if (channel.name !== channelName && channelName != null) {
|
||||
channel.name = channelName
|
||||
updated = true
|
||||
}
|
||||
if (channel.thumbnail !== thumbnail && thumbnail != null) {
|
||||
channel.thumbnail = thumbnail
|
||||
updated = true
|
||||
}
|
||||
if (updated) {
|
||||
await dispatch('updateProfile', currentProfileCopy)
|
||||
} else { // channel has not been updated, stop iterating through profiles
|
||||
|
@ -142,10 +144,6 @@ const actions = {
|
|||
}
|
||||
},
|
||||
|
||||
compactProfiles(_) {
|
||||
DBProfileHandlers.persist()
|
||||
},
|
||||
|
||||
updateActiveProfile({ commit }, id) {
|
||||
commit('setActiveProfile', id)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { deepCopy } from '../../helpers/utils'
|
||||
|
||||
const defaultCacheEntryValueForForOneChannel = {
|
||||
videos: null,
|
||||
}
|
||||
|
||||
function deepCopy(obj) {
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
const state = {
|
||||
videoCache: {},
|
||||
liveCache: {},
|
||||
|
@ -35,33 +33,27 @@ const getters = {
|
|||
|
||||
getLiveCacheByChannel: (state) => (channelId) => {
|
||||
return state.liveCache[channelId]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const actions = {
|
||||
clearSubscriptionVideosCache: ({ commit }) => {
|
||||
commit('clearVideoCache')
|
||||
},
|
||||
|
||||
updateSubscriptionVideosCacheByChannel: ({ commit }, payload) => {
|
||||
commit('updateVideoCacheByChannel', payload)
|
||||
},
|
||||
|
||||
clearSubscriptionShortsCache: ({ commit }) => {
|
||||
commit('clearShortsCache')
|
||||
},
|
||||
|
||||
updateSubscriptionShortsCacheByChannel: ({ commit }, payload) => {
|
||||
commit('updateShortsCacheByChannel', payload)
|
||||
},
|
||||
|
||||
clearSubscriptionLiveCache: ({ commit }) => {
|
||||
commit('clearLiveCache')
|
||||
},
|
||||
|
||||
updateSubscriptionLiveCacheByChannel: ({ commit }, payload) => {
|
||||
commit('updateLiveCacheByChannel', payload)
|
||||
}
|
||||
},
|
||||
|
||||
clearSubscriptionsCache: ({ commit }, payload) => {
|
||||
commit('clearVideoCache', payload)
|
||||
commit('clearShortsCache', payload)
|
||||
commit('clearLiveCache', payload)
|
||||
},
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
|
@ -91,7 +83,7 @@ const mutations = {
|
|||
},
|
||||
clearLiveCache(state) {
|
||||
state.liveCache = {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import i18n from '../../i18n/index'
|
||||
import { set as vueSet } from 'vue'
|
||||
|
||||
import { IpcChannels } from '../../../constants'
|
||||
import { pathExists } from '../../helpers/filesystem'
|
||||
|
@ -58,8 +59,8 @@ const getters = {
|
|||
return state.sessionSearchHistory
|
||||
},
|
||||
|
||||
getDeArrowCache: (state) => (videoId) => {
|
||||
return state.deArrowCache[videoId]
|
||||
getDeArrowCache: (state) => {
|
||||
return state.deArrowCache
|
||||
},
|
||||
|
||||
getPopularCache () {
|
||||
|
@ -323,7 +324,7 @@ const actions = {
|
|||
|
||||
const typePatterns = new Map([
|
||||
['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/],
|
||||
['search', /^\/results\/?$/],
|
||||
['search', /^\/results|search\/?$/],
|
||||
['hashtag', hashtagPattern],
|
||||
['channel', channelPattern]
|
||||
])
|
||||
|
@ -358,13 +359,21 @@ const actions = {
|
|||
}
|
||||
|
||||
case 'search': {
|
||||
if (!url.searchParams.has('search_query')) {
|
||||
let searchQuery = null
|
||||
if (url.searchParams.has('search_query')) {
|
||||
// https://www.youtube.com/results?search_query={QUERY}
|
||||
searchQuery = url.searchParams.get('search_query')
|
||||
url.searchParams.delete('search_query')
|
||||
}
|
||||
if (url.searchParams.has('q')) {
|
||||
// https://redirect.invidious.io/search?q={QUERY}
|
||||
searchQuery = url.searchParams.get('q')
|
||||
url.searchParams.delete('q')
|
||||
}
|
||||
if (searchQuery == null) {
|
||||
throw new Error('Search: "search_query" field not found')
|
||||
}
|
||||
|
||||
const searchQuery = url.searchParams.get('search_query')
|
||||
url.searchParams.delete('search_query')
|
||||
|
||||
const searchSettings = state.searchSettings
|
||||
const query = {
|
||||
sortBy: searchSettings.sortBy,
|
||||
|
@ -529,7 +538,10 @@ const actions = {
|
|||
|
||||
if (payload.watchProgress > 0 && payload.watchProgress < payload.videoLength - 10) {
|
||||
if (typeof cmdArgs.startOffset === 'string') {
|
||||
if (cmdArgs.startOffset.endsWith('=')) {
|
||||
if (cmdArgs.defaultExecutable.startsWith('mpc')) {
|
||||
// For mpc-hc and mpc-be, which require startOffset to be in milliseconds
|
||||
args.push(cmdArgs.startOffset, (Math.trunc(payload.watchProgress) * 1000))
|
||||
} else if (cmdArgs.startOffset.endsWith('=')) {
|
||||
// For players using `=` in arguments
|
||||
// e.g. vlc --start-time=xxxxx
|
||||
args.push(`${cmdArgs.startOffset}${payload.watchProgress}`)
|
||||
|
@ -585,7 +597,12 @@ const actions = {
|
|||
}
|
||||
}
|
||||
|
||||
args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`)
|
||||
// If the player supports opening playlists but not indexes, send only the video URL if an index is specified
|
||||
if (cmdArgs.playlistIndex == null && payload.playlistIndex != null && payload.playlistIndex !== '') {
|
||||
args.push(`${cmdArgs.videoUrl}https://youtube.com/watch?v=${payload.videoId}`)
|
||||
} else {
|
||||
args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`)
|
||||
}
|
||||
} else {
|
||||
if (payload.playlistId != null && payload.playlistId !== '' && !ignoreWarnings) {
|
||||
showExternalPlayerUnsupportedActionToast(externalPlayer, 'opening playlists')
|
||||
|
@ -631,7 +648,9 @@ const mutations = {
|
|||
const sameVideo = state.deArrowCache[payload.videoId]
|
||||
|
||||
if (!sameVideo) {
|
||||
state.deArrowCache[payload.videoId] = payload
|
||||
// setting properties directly doesn't trigger watchers in Vue 2,
|
||||
// so we need to use Vue's set function
|
||||
vueSet(state.deArrowCache, payload.videoId, payload)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -46,8 +46,8 @@
|
|||
.channelThumbnail {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 200px 200px 200px 200px;
|
||||
-webkit-border-radius: 200px 200px 200px 200px;
|
||||
border-radius: 200px;
|
||||
-webkit-border-radius: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,8 @@ import FtSubscribeButton from '../../components/ft-subscribe-button/ft-subscribe
|
|||
import ChannelAbout from '../../components/channel-about/channel-about.vue'
|
||||
|
||||
import autolinker from 'autolinker'
|
||||
import { copyToClipboard, extractNumberFromString, formatNumber, isNullOrEmpty, showToast } from '../../helpers/utils'
|
||||
import { copyToClipboard, extractNumberFromString, formatNumber, showToast } from '../../helpers/utils'
|
||||
import { isNullOrEmpty } from '../../helpers/strings'
|
||||
import packageDetails from '../../../../package.json'
|
||||
import {
|
||||
invidiousAPICall,
|
||||
|
@ -31,6 +32,7 @@ import {
|
|||
parseLocalListVideo,
|
||||
parseLocalSubscriberCount
|
||||
} from '../../helpers/api/local'
|
||||
import { Injectables } from '../../../constants'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Channel',
|
||||
|
@ -46,6 +48,9 @@ export default defineComponent({
|
|||
'ft-subscribe-button': FtSubscribeButton,
|
||||
'channel-about': ChannelAbout
|
||||
},
|
||||
inject: {
|
||||
showOutlines: Injectables.SHOW_OUTLINES
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
isLoading: false,
|
||||
|
@ -594,6 +599,25 @@ export default defineComponent({
|
|||
tags.push(...badges)
|
||||
break
|
||||
}
|
||||
case 'PageHeader': {
|
||||
// example: YouTube Gaming (an A/B test at the time of writing)
|
||||
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
|
||||
|
||||
/**
|
||||
* @type {import('youtubei.js').YTNodes.PageHeader}
|
||||
*/
|
||||
const header = channel.header
|
||||
|
||||
channelName = header.content.title.text
|
||||
channelThumbnailUrl = header.content.image.image[0].url
|
||||
channelId = this.id
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (channelThumbnailUrl.startsWith('//')) {
|
||||
channelThumbnailUrl = `https:${channelThumbnailUrl}`
|
||||
}
|
||||
|
||||
this.channelName = channelName
|
||||
|
@ -1727,7 +1751,8 @@ export default defineComponent({
|
|||
: this.tabInfoValues[(index + 1) % this.tabInfoValues.length]
|
||||
|
||||
const tabNode = document.getElementById(`${tab}Tab`)
|
||||
tabNode.focus({ focusVisible: true })
|
||||
tabNode.focus()
|
||||
this.showOutlines()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -1735,7 +1760,8 @@ export default defineComponent({
|
|||
// `newTabNode` can be `null` when `tab` === "search"
|
||||
const newTabNode = document.getElementById(`${tab}Tab`)
|
||||
this.currentTab = tab
|
||||
newTabNode?.focus({ focusVisible: true })
|
||||
newTabNode?.focus()
|
||||
this.showOutlines()
|
||||
},
|
||||
|
||||
newSearch: function (query) {
|
||||
|
|
|
@ -85,6 +85,7 @@
|
|||
role="tablist"
|
||||
:aria-label="$t('Channel.Channel Tabs')"
|
||||
>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="tabInfoValues.includes('videos')"
|
||||
id="videosTab"
|
||||
|
@ -99,6 +100,7 @@
|
|||
>
|
||||
{{ $t("Channel.Videos.Videos").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="tabInfoValues.includes('shorts') && !hideChannelShorts"
|
||||
id="shortsTab"
|
||||
|
@ -113,6 +115,7 @@
|
|||
>
|
||||
{{ $t("Global.Shorts").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="tabInfoValues.includes('live') && !hideLiveStreams"
|
||||
id="liveTab"
|
||||
|
@ -127,6 +130,7 @@
|
|||
>
|
||||
{{ $t("Channel.Live.Live").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="tabInfoValues.includes('releases') && !hideChannelReleases"
|
||||
id="releasesTab"
|
||||
|
@ -141,6 +145,7 @@
|
|||
>
|
||||
{{ $t("Channel.Releases.Releases").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="tabInfoValues.includes('podcasts') && !hideChannelPodcasts"
|
||||
id="podcastsTab"
|
||||
|
@ -155,6 +160,7 @@
|
|||
>
|
||||
{{ $t("Channel.Podcasts.Podcasts").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="tabInfoValues.includes('playlists') && !hideChannelPlaylists"
|
||||
id="playlistsTab"
|
||||
|
@ -169,6 +175,7 @@
|
|||
>
|
||||
{{ $t("Channel.Playlists.Playlists").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="tabInfoValues.includes('community') && !hideChannelCommunity"
|
||||
id="communityTab"
|
||||
|
@ -183,6 +190,7 @@
|
|||
>
|
||||
{{ $t("Channel.Community.Community").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
id="aboutTab"
|
||||
class="tab"
|
||||
|
|
|
@ -11,9 +11,14 @@
|
|||
.getNextPage:hover, .getNextPage:focus {
|
||||
background-color: var(--side-nav-hover-color);
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto 20px;
|
||||
position: relative;
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 680px) {
|
||||
.card {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,8 @@ import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
|
|||
import FtLoader from '../../components/ft-loader/ft-loader.vue'
|
||||
import packageDetails from '../../../../package.json'
|
||||
import { getHashtagLocal, parseLocalListVideo } from '../../helpers/api/local'
|
||||
import { copyToClipboard, isNullOrEmpty, showToast } from '../../helpers/utils'
|
||||
import { copyToClipboard, showToast } from '../../helpers/utils'
|
||||
import { isNullOrEmpty } from '../../helpers/strings'
|
||||
import { getHashtagInvidious } from '../../helpers/api/invidious'
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -96,12 +97,26 @@ export default defineComponent({
|
|||
getLocalHashtag: async function(hashtag) {
|
||||
try {
|
||||
const hashtagData = await getHashtagLocal(hashtag)
|
||||
this.hashtag = hashtagData.header.hashtag
|
||||
this.videos = hashtagData.contents.contents.filter(item =>
|
||||
item.type !== 'ContinuationItem'
|
||||
).map(item =>
|
||||
parseLocalListVideo(item.content)
|
||||
)
|
||||
|
||||
const header = hashtagData.header
|
||||
if (header) {
|
||||
switch (header.type) {
|
||||
case 'HashtagHeader':
|
||||
this.hashtag = header.hashtag.toString()
|
||||
break
|
||||
case 'PageHeader':
|
||||
this.hashtag = header.content.title.text
|
||||
break
|
||||
default:
|
||||
console.error(`Unknown hashtag header type: ${header.type}, falling back to query parameter.`)
|
||||
this.hashtag = `#${hashtag}`
|
||||
}
|
||||
} else {
|
||||
console.error(' Hashtag header missing, probably a layout change, falling back to query parameter.')
|
||||
this.hashtag = `#${hashtag}`
|
||||
}
|
||||
|
||||
this.videos = hashtagData.videos.map(parseLocalListVideo)
|
||||
this.apiUsed = 'local'
|
||||
this.hashtagContinuationData = hashtagData.has_continuation ? hashtagData : null
|
||||
this.isLoading = false
|
||||
|
@ -123,12 +138,8 @@ export default defineComponent({
|
|||
|
||||
getLocalHashtagMore: async function() {
|
||||
try {
|
||||
const continuation = await this.hashtagContinuationData.getContinuationData()
|
||||
const newVideos = continuation.on_response_received_actions[0].contents.filter(item =>
|
||||
item.type !== 'ContinuationItem'
|
||||
).map(item =>
|
||||
parseLocalListVideo(item.content)
|
||||
)
|
||||
const continuation = await this.hashtagContinuationData.getContinuation()
|
||||
const newVideos = continuation.videos.map(parseLocalListVideo)
|
||||
this.hashtagContinuationData = continuation.has_continuation ? continuation : null
|
||||
this.videos = this.videos.concat(newVideos)
|
||||
} catch (error) {
|
||||
|
|
|
@ -4,39 +4,36 @@
|
|||
v-if="isLoading"
|
||||
:fullscreen="true"
|
||||
/>
|
||||
<div v-else>
|
||||
<ft-card>
|
||||
<h3>{{ hashtag }}</h3>
|
||||
<div
|
||||
class="elementList"
|
||||
<ft-card
|
||||
v-else
|
||||
class="card"
|
||||
>
|
||||
<h2>{{ hashtag }}</h2>
|
||||
<ft-element-list
|
||||
v-if="videos.length > 0"
|
||||
:data="videos"
|
||||
/>
|
||||
<ft-flex-box
|
||||
v-else
|
||||
>
|
||||
<p
|
||||
class="message"
|
||||
>
|
||||
<ft-element-list
|
||||
v-if="videos.length > 0"
|
||||
:data="videos"
|
||||
/>
|
||||
<ft-flex-box
|
||||
v-else-if="videos.length === 0"
|
||||
>
|
||||
<p
|
||||
class="message"
|
||||
>
|
||||
{{ $t("Hashtag.This hashtag does not currently have any videos") }}
|
||||
</p>
|
||||
</ft-flex-box>
|
||||
</div>
|
||||
<div
|
||||
v-if="showFetchMoreButton"
|
||||
class="getNextPage"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleFetchMore"
|
||||
@keydown.space.prevent="handleFetchMore"
|
||||
@keydown.enter.prevent="handleFetchMore"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'search']" /> {{ $t("Search Filters.Fetch more results") }}
|
||||
</div>
|
||||
</ft-card>
|
||||
</div>
|
||||
{{ $t("Hashtag.This hashtag does not currently have any videos") }}
|
||||
</p>
|
||||
</ft-flex-box>
|
||||
<div
|
||||
v-if="showFetchMoreButton"
|
||||
class="getNextPage"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleFetchMore"
|
||||
@keydown.space.prevent="handleFetchMore"
|
||||
@keydown.enter.prevent="handleFetchMore"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'search']" /> {{ $t("Search Filters.Fetch more results") }}
|
||||
</div>
|
||||
</ft-card>
|
||||
</div>
|
||||
</template>
|
||||
<script src="./Hashtag.js" />
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
v-show="!isLoading"
|
||||
class="card"
|
||||
>
|
||||
<h3>{{ $t("History.History") }}</h3>
|
||||
<h2>{{ $t("History.History") }}</h2>
|
||||
<ft-input
|
||||
v-show="fullData.length > 0"
|
||||
ref="searchBar"
|
||||
|
|
|
@ -89,6 +89,17 @@ export default defineComponent({
|
|||
this.isLoading = true
|
||||
|
||||
getLocalPlaylist(this.playlistId).then((result) => {
|
||||
let channelName
|
||||
|
||||
if (result.info.author) {
|
||||
channelName = result.info.author.name
|
||||
} else {
|
||||
const subtitle = result.info.subtitle.toString()
|
||||
|
||||
const index = subtitle.lastIndexOf('•')
|
||||
channelName = subtitle.substring(0, index).trim()
|
||||
}
|
||||
|
||||
this.infoData = {
|
||||
id: this.playlistId,
|
||||
title: result.info.title,
|
||||
|
@ -98,7 +109,7 @@ export default defineComponent({
|
|||
viewCount: extractNumberFromString(result.info.views),
|
||||
videoCount: extractNumberFromString(result.info.total_items),
|
||||
lastUpdated: result.info.last_updated ?? '',
|
||||
channelName: result.info.author?.name ?? '',
|
||||
channelName,
|
||||
channelThumbnail: result.info.author?.best_thumbnail?.url ?? '',
|
||||
channelId: result.info.author?.id,
|
||||
infoSource: 'local'
|
||||
|
|
|
@ -5,6 +5,7 @@ import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
|
|||
import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue'
|
||||
|
||||
import { invidiousAPICall } from '../../helpers/api/invidious'
|
||||
import { copyToClipboard, showToast } from '../../helpers/utils'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Popular',
|
||||
|
@ -28,7 +29,7 @@ export default defineComponent({
|
|||
mounted: function () {
|
||||
document.addEventListener('keydown', this.keyboardShortcutHandler)
|
||||
|
||||
this.shownResults = this.popularCache
|
||||
this.shownResults = this.popularCache || []
|
||||
if (!this.shownResults || this.shownResults.length < 1) {
|
||||
this.fetchPopularInfo()
|
||||
}
|
||||
|
@ -47,7 +48,11 @@ export default defineComponent({
|
|||
this.isLoading = true
|
||||
const result = await invidiousAPICall(searchPayload)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
const errorMessage = this.$t('Invidious API Error (Click to copy)')
|
||||
showToast(`${errorMessage}: ${err}`, 10000, () => {
|
||||
copyToClipboard(err)
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
v-else
|
||||
class="card"
|
||||
>
|
||||
<h3>{{ $t("Most Popular") }}</h3>
|
||||
<h2>{{ $t("Most Popular") }}</h2>
|
||||
<ft-element-list
|
||||
:data="shownResults"
|
||||
/>
|
||||
|
|
|
@ -25,4 +25,3 @@
|
|||
</template>
|
||||
|
||||
<script src="./ProfileEdit.js" />
|
||||
<style scoped src="./ProfileEdit.css" />
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<ft-card class="card">
|
||||
<h3>{{ $t("Profile.Profile Manager") }}</h3>
|
||||
<h2>{{ $t("Profile.Profile Manager") }}</h2>
|
||||
<ft-flex-box
|
||||
class="profileList"
|
||||
>
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
v-else
|
||||
class="card"
|
||||
>
|
||||
<h3>{{ $t("Search Filters.Search Results") }}</h3>
|
||||
<h2>{{ $t("Search Filters.Search Results") }}</h2>
|
||||
<ft-element-list
|
||||
:data="shownResults"
|
||||
/>
|
||||
|
|
|
@ -87,7 +87,7 @@ export default defineComponent({
|
|||
methods: {
|
||||
getSubscription: function () {
|
||||
this.subscribedChannels = this.activeSubscriptionList.slice().sort((a, b) => {
|
||||
return a.name.localeCompare(b.name, this.locale)
|
||||
return a.name?.toLowerCase().localeCompare(b.name?.toLowerCase(), this.locale)
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<ft-card class="card">
|
||||
<h3>{{ $t('Channels.Title') }}</h3>
|
||||
<h2>{{ $t('Channels.Title') }}</h2>
|
||||
<ft-input
|
||||
v-show="subscribedChannels.length > 0"
|
||||
ref="searchBarChannels"
|
||||
|
|
|
@ -6,6 +6,7 @@ import SubscriptionsShorts from '../../components/subscriptions-shorts/subscript
|
|||
|
||||
import FtCard from '../../components/ft-card/ft-card.vue'
|
||||
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
|
||||
import { Injectables } from '../../../constants'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Subscriptions',
|
||||
|
@ -16,6 +17,9 @@ export default defineComponent({
|
|||
'ft-card': FtCard,
|
||||
'ft-flex-box': FtFlexBox
|
||||
},
|
||||
inject: {
|
||||
showOutlines: Injectables.SHOW_OUTLINES
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
currentTab: 'videos'
|
||||
|
@ -102,7 +106,7 @@ export default defineComponent({
|
|||
const visibleTabs = this.visibleTabs
|
||||
|
||||
if (visibleTabs.length === 1) {
|
||||
this.$emit('showOutlines')
|
||||
this.showOutlines()
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -121,7 +125,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
this.$refs[visibleTabs[index]].focus()
|
||||
this.$emit('showOutlines')
|
||||
this.showOutlines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<template>
|
||||
<div>
|
||||
<ft-card class="card">
|
||||
<h3>{{ $t("Subscriptions.Subscriptions") }}</h3>
|
||||
<h2>{{ $t("Subscriptions.Subscriptions") }}</h2>
|
||||
<ft-flex-box
|
||||
class="subscriptionTabs"
|
||||
role="tablist"
|
||||
:aria-label="$t('Subscriptions.Subscriptions Tabs')"
|
||||
>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="!hideSubscriptionsVideos"
|
||||
ref="videos"
|
||||
|
@ -22,6 +23,7 @@
|
|||
>
|
||||
{{ $t("Global.Videos").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="!hideSubscriptionsShorts"
|
||||
ref="shorts"
|
||||
|
@ -37,6 +39,7 @@
|
|||
>
|
||||
{{ $t("Global.Shorts").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
v-if="!hideSubscriptionsLive"
|
||||
ref="live"
|
||||
|
@ -59,16 +62,16 @@
|
|||
role="tabpanel"
|
||||
/>
|
||||
<subscriptions-shorts
|
||||
v-if="currentTab === 'shorts'"
|
||||
v-else-if="currentTab === 'shorts'"
|
||||
id="subscriptionsPanel"
|
||||
role="tabpanel"
|
||||
/>
|
||||
<subscriptions-live
|
||||
v-if="currentTab === 'live'"
|
||||
v-else-if="currentTab === 'live'"
|
||||
id="subscriptionsPanel"
|
||||
role="tabpanel"
|
||||
/>
|
||||
<p v-if="currentTab === null">
|
||||
<p v-else-if="currentTab === null">
|
||||
{{ $t("Subscriptions.All Subscription Tabs Hidden", {
|
||||
subsection: $t('Settings.Distraction Free Settings.Sections.Subscriptions Page'),
|
||||
settingsSection: $t('Settings.Distraction Free Settings.Distraction Free Settings')
|
||||
|
|
|
@ -8,6 +8,7 @@ import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
|
|||
import { copyToClipboard, showToast } from '../../helpers/utils'
|
||||
import { getLocalTrending } from '../../helpers/api/local'
|
||||
import { invidiousAPICall } from '../../helpers/api/invidious'
|
||||
import { Injectables } from '../../../constants'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Trending',
|
||||
|
@ -18,6 +19,9 @@ export default defineComponent({
|
|||
'ft-icon-button': FtIconButton,
|
||||
'ft-flex-box': FtFlexBox
|
||||
},
|
||||
inject: {
|
||||
showOutlines: Injectables.SHOW_OUTLINES
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
isLoading: false,
|
||||
|
@ -78,7 +82,7 @@ export default defineComponent({
|
|||
if (!event.altKey) {
|
||||
event.preventDefault()
|
||||
this.$refs[tab].focus()
|
||||
this.$emit('showOutlines')
|
||||
this.showOutlines()
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -8,12 +8,13 @@
|
|||
v-else
|
||||
class="card"
|
||||
>
|
||||
<h3>{{ $t("Trending.Trending") }}</h3>
|
||||
<h2>{{ $t("Trending.Trending") }}</h2>
|
||||
<ft-flex-box
|
||||
class="trendingInfoTabs"
|
||||
role="tablist"
|
||||
:aria-label="$t('Trending.Trending Tabs')"
|
||||
>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
ref="default"
|
||||
class="tab"
|
||||
|
@ -29,6 +30,7 @@
|
|||
>
|
||||
{{ $t("Trending.Default").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
ref="music"
|
||||
class="tab"
|
||||
|
@ -44,6 +46,7 @@
|
|||
>
|
||||
{{ $t("Trending.Music").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
ref="gaming"
|
||||
class="tab"
|
||||
|
@ -59,6 +62,7 @@
|
|||
>
|
||||
{{ $t("Trending.Gaming").toUpperCase() }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
ref="movies"
|
||||
class="tab"
|
||||
|
|
|
@ -8,14 +8,14 @@
|
|||
v-show="!isLoading"
|
||||
class="card"
|
||||
>
|
||||
<h3>
|
||||
<h2>
|
||||
{{ $t("User Playlists.Your Playlists") }}
|
||||
<ft-tooltip
|
||||
class="selectTooltip"
|
||||
position="bottom"
|
||||
:tooltip="$t('User Playlists.Playlist Message')"
|
||||
/>
|
||||
</h3>
|
||||
</h2>
|
||||
<ft-input
|
||||
v-show="fullData.length > 0"
|
||||
ref="searchBar"
|
||||
|
|
|
@ -80,6 +80,7 @@ export default defineComponent({
|
|||
liveChat: null,
|
||||
isLiveContent: false,
|
||||
isUpcoming: false,
|
||||
isPostLiveDvr: false,
|
||||
upcomingTimestamp: null,
|
||||
upcomingTimeLeft: null,
|
||||
activeFormat: 'legacy',
|
||||
|
@ -260,9 +261,6 @@ export default defineComponent({
|
|||
changeTimestamp: function (timestamp) {
|
||||
this.$refs.videoPlayer.player.currentTime(timestamp)
|
||||
},
|
||||
toggleTheatreMode: function () {
|
||||
this.useTheatreMode = !this.useTheatreMode
|
||||
},
|
||||
|
||||
getVideoInformationLocal: async function () {
|
||||
if (this.firstLoad) {
|
||||
|
@ -360,6 +358,7 @@ export default defineComponent({
|
|||
this.isLive = !!result.basic_info.is_live
|
||||
this.isUpcoming = !!result.basic_info.is_upcoming
|
||||
this.isLiveContent = !!result.basic_info.is_live_content
|
||||
this.isPostLiveDvr = !!result.basic_info.is_post_live_dvr
|
||||
|
||||
const subCount = !result.secondary_info.owner.subscriber_count.isEmpty() ? parseLocalSubscriberCount(result.secondary_info.owner.subscriber_count.text) : NaN
|
||||
|
||||
|
@ -425,7 +424,7 @@ export default defineComponent({
|
|||
result = bypassedResult
|
||||
}
|
||||
|
||||
if (this.isLive && !this.isUpcoming) {
|
||||
if ((this.isLive || this.isPostLiveDvr) && !this.isUpcoming) {
|
||||
try {
|
||||
const formats = await getFormatsFromHLSManifest(result.streaming_data.hls_manifest_url)
|
||||
|
||||
|
@ -459,6 +458,8 @@ export default defineComponent({
|
|||
this.showDashPlayer = false
|
||||
this.activeFormat = 'legacy'
|
||||
this.activeSourceList = this.videoSourceList
|
||||
this.audioSourceList = null
|
||||
this.dashSrc = null
|
||||
} else if (this.isUpcoming) {
|
||||
const upcomingTimestamp = result.basic_info.start_timestamp
|
||||
|
||||
|
@ -603,7 +604,14 @@ export default defineComponent({
|
|||
/** @type {import('youtubei.js').Misc.Format[][]} */
|
||||
const sourceLists = []
|
||||
|
||||
audioFormats.forEach(format => {
|
||||
for (const format of audioFormats) {
|
||||
// Some videos with multiple audio tracks, have a broken one, that doesn't have any audio track information
|
||||
// It seems to be the same as default audio track but broken
|
||||
// At the time of writing, this video has a broken audio track: https://youtu.be/UJeSWbR6W04
|
||||
if (!format.audio_track) {
|
||||
continue
|
||||
}
|
||||
|
||||
const index = ids.indexOf(format.audio_track.id)
|
||||
if (index === -1) {
|
||||
ids.push(format.audio_track.id)
|
||||
|
@ -635,7 +643,7 @@ export default defineComponent({
|
|||
} else {
|
||||
sourceLists[index].push(format)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < audioTracks.length; i++) {
|
||||
audioTracks[i].sourceList = this.createLocalAudioSourceList(sourceLists[i])
|
||||
|
@ -1116,12 +1124,26 @@ export default defineComponent({
|
|||
})
|
||||
},
|
||||
|
||||
handleFormatChange: function (format) {
|
||||
switch (format) {
|
||||
case 'dash':
|
||||
this.enableDashFormat()
|
||||
break
|
||||
case 'legacy':
|
||||
this.enableLegacyFormat()
|
||||
break
|
||||
case 'audio':
|
||||
this.enableAudioFormat()
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
enableDashFormat: function () {
|
||||
if (this.activeFormat === 'dash' || this.isLive) {
|
||||
if (this.activeFormat === 'dash') {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.dashSrc === null) {
|
||||
if (this.dashSrc === null || this.isLive || this.isPostLiveDvr) {
|
||||
showToast(this.$t('Change Format.Dash formats are not available for this video'))
|
||||
return
|
||||
}
|
||||
|
|
|
@ -30,12 +30,16 @@
|
|||
:video-id="videoId"
|
||||
:length-seconds="videoLengthSeconds"
|
||||
:chapters="videoChapters"
|
||||
:current-chapter-index="videoCurrentChapterIndex"
|
||||
:theatre-possible="theatrePossible"
|
||||
:use-theatre-mode="useTheatreMode"
|
||||
class="videoPlayer"
|
||||
:class="{ theatrePlayer: useTheatreMode }"
|
||||
@ready="handleVideoReady"
|
||||
@ended="handleVideoEnded"
|
||||
@error="handleVideoError"
|
||||
@store-caption-list="captionHybridList = $event"
|
||||
@toggle-theatre-mode="useTheatreMode = !useTheatreMode"
|
||||
v-on="!hideChapters && videoChapters.length > 0 ? { timeupdate: updateCurrentChapter } : {}"
|
||||
/>
|
||||
<div
|
||||
|
@ -113,12 +117,14 @@
|
|||
:get-playlist-reverse="getPlaylistReverse"
|
||||
:get-playlist-shuffle="getPlaylistShuffle"
|
||||
:get-playlist-loop="getPlaylistLoop"
|
||||
:theatre-possible="theatrePossible"
|
||||
:length-seconds="videoLengthSeconds"
|
||||
:video-thumbnail="thumbnail"
|
||||
class="watchVideo"
|
||||
:class="{ theatreWatchVideo: useTheatreMode }"
|
||||
@change-format="handleFormatChange"
|
||||
@pause-player="pausePlayer"
|
||||
@set-info-area-sticky="infoAreaSticky = $event"
|
||||
@scroll-to-info-area="$refs.infoArea.scrollIntoView()"
|
||||
/>
|
||||
<watch-video-chapters
|
||||
v-if="!hideChapters && !isLoading && videoChapters.length > 0"
|
||||
|
|
|
@ -76,5 +76,59 @@
|
|||
"playlistShuffle": null,
|
||||
"playlistLoop": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MPC-BE",
|
||||
"nameTranslationKey": "Settings.External Player Settings.Players.MPC-BE.Name",
|
||||
"value": "mpc-be",
|
||||
"cmdArguments": {
|
||||
"defaultExecutable": "mpc-be64",
|
||||
"defaultCustomArguments": null,
|
||||
"supportsYtdlProtocol": true,
|
||||
"videoUrl": "",
|
||||
"playlistUrl": "",
|
||||
"startOffset": "/start",
|
||||
"playbackRate": null,
|
||||
"playlistIndex": null,
|
||||
"playlistReverse": null,
|
||||
"playlistShuffle": null,
|
||||
"playlistLoop": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MPC-HC",
|
||||
"nameTranslationKey": "Settings.External Player Settings.Players.MPC-HC.Name",
|
||||
"value": "mpc-hc",
|
||||
"cmdArguments": {
|
||||
"defaultExecutable": "mpc-hc64",
|
||||
"defaultCustomArguments": null,
|
||||
"supportsYtdlProtocol": true,
|
||||
"videoUrl": "",
|
||||
"playlistUrl": "",
|
||||
"startOffset": "/start",
|
||||
"playbackRate": null,
|
||||
"playlistIndex": null,
|
||||
"playlistReverse": null,
|
||||
"playlistShuffle": null,
|
||||
"playlistLoop": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "PotPlayer",
|
||||
"nameTranslationKey": "Settings.External Player Settings.Players.PotPlayer.Name",
|
||||
"value": "potplayer",
|
||||
"cmdArguments": {
|
||||
"defaultExecutable": "potplayermini64",
|
||||
"defaultCustomArguments": null,
|
||||
"supportsYtdlProtocol": false,
|
||||
"videoUrl": "",
|
||||
"playlistUrl": null,
|
||||
"startOffset": "/seek=",
|
||||
"playbackRate": null,
|
||||
"playlistIndex": null,
|
||||
"playlistReverse": null,
|
||||
"playlistShuffle": null,
|
||||
"playlistLoop": null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -585,6 +585,7 @@ Profile:
|
|||
Subscription List: قائمة الاشتراكات
|
||||
Profile Filter: مرشح الملف الشخصي
|
||||
Profile Settings: إعدادات الملف الشخصي
|
||||
Toggle Profile List: تبديل قائمة الملف الشخصي
|
||||
Channel:
|
||||
Subscriber: 'مُشترِك'
|
||||
Subscribers: 'مُشترِكين'
|
||||
|
|
|
@ -601,6 +601,7 @@ Profile:
|
|||
#On Channel Page
|
||||
Profile Filter: Профилен филтър
|
||||
Profile Settings: Настройки на профил
|
||||
Toggle Profile List: Превключване на списъка с профили
|
||||
Channel:
|
||||
Subscriber: 'Абонат'
|
||||
Subscribers: 'Абонати'
|
||||
|
|
|
@ -60,12 +60,14 @@ Search Filters:
|
|||
Videos: 'ভিডিও'
|
||||
Channels: 'চ্যানেল'
|
||||
#& Playlists
|
||||
Movies: চলচিত্র
|
||||
Duration:
|
||||
Duration: 'সময়কাল'
|
||||
All Durations: 'সকল দৈর্ঘ্যের'
|
||||
Short (< 4 minutes): 'ছোট (<৪ মিনিট)'
|
||||
Long (> 20 minutes): 'লম্বা (> ২০ মিনিট)'
|
||||
# On Search Page
|
||||
Medium (4 - 20 minutes): মধ্যম (৪ - ২০ মিনিট)
|
||||
Search Results: 'অনুসন্ধান ফলাফল'
|
||||
Fetching results. Please wait: 'ফলাফল আনা হচ্ছে, একটু অপেক্ষা করো'
|
||||
Fetch more results: 'আরো ফলাফল আনো'
|
||||
|
@ -75,6 +77,7 @@ Subscriptions:
|
|||
# On Subscriptions Page
|
||||
Subscriptions: 'সদস্যতা'
|
||||
Latest Subscriptions: 'শেষ সদস্যতা'
|
||||
Error Channels: ত্রুটিপূর্ণ চ্যানেল
|
||||
Trending:
|
||||
Trending: 'চলছে'
|
||||
History:
|
||||
|
@ -94,3 +97,18 @@ Channel:
|
|||
Video: {}
|
||||
Tooltips: {}
|
||||
Open New Window: নতুন জানালা খুলো
|
||||
New Window: নতুন জানালা
|
||||
Search Bar:
|
||||
Clear Input: ইনপুট পরিষ্কার করো
|
||||
Global:
|
||||
Videos: ভিডিও
|
||||
Shorts: খাটো
|
||||
Live: সরাসরি
|
||||
External link opening has been disabled in the general settings: সাধারণ পছন্দসমূহে
|
||||
বহিঃসংযোগ খোলা নিষ্ক্রিয় রাখা হয়েছে
|
||||
Are you sure you want to open this link?: তুমি কি এই সংযোগটি খোলার ব্যাপারে নিশ্চিত?
|
||||
Preferences: পছন্দসমূহ
|
||||
Age Restricted:
|
||||
Type:
|
||||
Channel: চ্যানেল
|
||||
Video: ভিডিও
|
||||
|
|
|
@ -588,6 +588,7 @@ Profile:
|
|||
#On Channel Page
|
||||
Profile Filter: Filtr profilu
|
||||
Profile Settings: Nastavení profilu
|
||||
Toggle Profile List: Přepnout seznam profilů
|
||||
Channel:
|
||||
Subscriber: 'Odběratel'
|
||||
Subscribers: 'odběratelů'
|
||||
|
|
|
@ -926,6 +926,7 @@ Profile:
|
|||
Profile Select: Profilauswahl
|
||||
Profile Filter: Profilfilter
|
||||
Profile Settings: Profileinstellungen
|
||||
Toggle Profile List: Profilliste umschalten
|
||||
The playlist has been reversed: Die Wiedergabeliste wurde umgedreht
|
||||
A new blog is now available, {blogTitle}. Click to view more: Ein neuer Blogeintrag
|
||||
ist verfügbar, {blogTitle}. Um ihn zu öffnen klicken
|
||||
|
|
|
@ -19,11 +19,11 @@ Delete: 'Διαγραφή'
|
|||
Select all: 'Επιλογή όλων'
|
||||
Reload: 'Ανανέωση'
|
||||
Force Reload: 'Εξαναγκασμένη ανανέωση'
|
||||
Toggle Developer Tools: 'Εμφάνιση Εργαλείων Προγραμματιστών'
|
||||
Toggle Developer Tools: 'Εναλλαγή Εργαλείων Προγραμματιστών'
|
||||
Actual size: 'Πραγματικό μέγεθος'
|
||||
Zoom in: 'Μεγέθυνση'
|
||||
Zoom out: 'Σμίκρυνση'
|
||||
Toggle fullscreen: 'Πλήρης οθόνη'
|
||||
Toggle fullscreen: 'Εναλλαγή Πλήρους οθόνης'
|
||||
Window: 'Παράθυρο'
|
||||
Minimize: 'Ελαχιστοποίηση'
|
||||
Close: 'Κλείσιμο'
|
||||
|
@ -38,13 +38,13 @@ Global:
|
|||
Live: Ζωντανά
|
||||
|
||||
# Search Bar
|
||||
Search / Go to URL: 'Αναζήτηση/Μετάβαση στη URL'
|
||||
Search / Go to URL: 'Αναζήτηση/Μετάβαση σε URL'
|
||||
# In Filter Button
|
||||
Search Filters:
|
||||
Search Filters: 'Φίλτρα αναζήτησης'
|
||||
Sort By:
|
||||
Sort By: 'Ταξινόμηση κατά'
|
||||
Most Relevant: 'Πιο σχετικό'
|
||||
Most Relevant: 'Πιο σχετικά'
|
||||
Rating: 'Αξιολόγηση'
|
||||
Upload Date: 'Ημερομηνία μεταφόρτωσης'
|
||||
View Count: 'Πλήθος Προβολών'
|
||||
|
@ -69,42 +69,42 @@ Search Filters:
|
|||
Short (< 4 minutes): 'Μικρό(<4 λεπτών)'
|
||||
Long (> 20 minutes): 'Μεγάλο (>20 λεπτών)'
|
||||
# On Search Page
|
||||
Medium (4 - 20 minutes): Μεσαία (4 - 20 λεπτά)
|
||||
Medium (4 - 20 minutes): Μεσαίο (4 - 20 λεπτά)
|
||||
Search Results: 'Αποτελέσματα αναζήτησης'
|
||||
Fetching results. Please wait: 'Aνάκτηση αποτελεσμάτων. Παρακαλώ περιμένετε'
|
||||
Fetch more results: 'Λήψη περαιτέρω αποτελεσμάτων'
|
||||
Fetch more results: 'Λήψη περισσότερων αποτελεσμάτων'
|
||||
# Sidebar
|
||||
There are no more results for this search: Δεν υπάρχουν άλλα αποτελέσματα για αυτήν
|
||||
την αναζήτηση
|
||||
Subscriptions:
|
||||
# On Subscriptions Page
|
||||
Subscriptions: 'Εγγραφές'
|
||||
Latest Subscriptions: 'Τελευταίες Εγγραφές'
|
||||
Subscriptions: 'Συνδρομές'
|
||||
Latest Subscriptions: 'Τελευταίες Συνδρομές'
|
||||
'Your Subscription list is currently empty. Start adding subscriptions to see them here.': 'Η
|
||||
λίστα συνδρομών σας είναι προς το παρόν κενή. Προσθέστε συνδρομές για να τις παρακολουθείτε
|
||||
από εδώ.'
|
||||
'Getting Subscriptions. Please wait.': 'Ανάκτηση Εγγραφών/Συνδρομών. Παρακαλώ περιμένετε.'
|
||||
Refresh Subscriptions: 'Ανανέωση Εγγραφών/Συνδρομών'
|
||||
λίστα Συνδρομών σας είναι προς το παρόν κενή. Προσθέστε Συνδρομές για να εμφανιστούν
|
||||
εδώ.'
|
||||
'Getting Subscriptions. Please wait.': 'Ανάκτηση Συνδρομών. Παρακαλώ περιμένετε.'
|
||||
Refresh Subscriptions: 'Ανανέωση Συνδρομών'
|
||||
This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting: Αυτό
|
||||
το προφίλ διαθέτει ήδη ένα μεγάλο αριθμό Εγγραφών. Εξαναγκασμένη εναλλαγή σε λειτουργία
|
||||
RSS για αποφυγή περιορισμού των κλήσεων
|
||||
το προφίλ διαθέτει ήδη ένα μεγάλο αριθμό Συνδρομών. Εξαναγκασμένη εναλλαγή σε
|
||||
λειτουργία RSS για αποφυγή περιορισμού των κλήσεων
|
||||
Load More Videos: Φόρτωση περισσότερων Βίντεο
|
||||
Error Channels: Κανάλια με προβλήματα
|
||||
Disabled Automatic Fetching: Έχετε απενεργοποιήσει την αυτόματη ανάκτηση συνδρομής.
|
||||
Ανανεώστε τις συνδρομές για να τις δείτε εδώ.
|
||||
Disabled Automatic Fetching: Έχετε απενεργοποιήσει την αυτόματη ανάκτηση Συνδρομών.
|
||||
Ανανεώστε τις Συνδρομές για να τις δείτε εδώ.
|
||||
Empty Channels: Τα εγγεγραμμένα κανάλια σας προς το παρόν δεν έχουν βίντεο.
|
||||
Subscriptions Tabs: Καρτέλες Συνδρομών
|
||||
All Subscription Tabs Hidden: Όλες οι καρτέλες συνδρομής είναι κρυφές. Για να δείτε
|
||||
περιεχόμενο εδώ, αποκρύψτε ορισμένες καρτέλες στην ενότητα "{subsection}" στο
|
||||
"{settingsSection}".
|
||||
All Subscription Tabs Hidden: Όλες οι καρτέλες Συνδρομών είναι κρυμμένες. Για να
|
||||
δείτε περιεχόμενο εδώ, εμφανίστε ορισμένες καρτέλες στην ενότητα "{subsection}"
|
||||
στο "{settingsSection}".
|
||||
Trending:
|
||||
Trending: 'Τάσεις'
|
||||
Gaming: Παιχνίδια
|
||||
Music: Μουσική
|
||||
Movies: Ταινίες
|
||||
Default: Προεπιλογή
|
||||
Trending Tabs: Δημοφιλείς Καρτέλες
|
||||
Most Popular: 'Πιο δημοφιλή'
|
||||
Trending Tabs: Καρτέλες Τάσεων
|
||||
Most Popular: 'Δημοφιλέστερα'
|
||||
Playlists: 'Λίστες αναπαραγωγής'
|
||||
User Playlists:
|
||||
Your Playlists: 'Προσωπικές Λίστες αναπαραγωγής'
|
||||
|
@ -112,8 +112,9 @@ User Playlists:
|
|||
αποθηκευμένα βίντεο σας είναι κενά. Κάντε κλικ στο κουμπί αποθήκευσης στη γωνία
|
||||
ενός βίντεο για να το εισάγετε εδώ
|
||||
Playlist Message: Αυτή η σελίδα δεν αντικατοπτρίζει πλήρως λειτουργικές λίστες αναπαραγωγής.
|
||||
Παραθέτει μόνο βίντεο που έχετε αποθηκεύσει ή αγαπήσει. Όταν ολοκληρωθεί η εργασία,
|
||||
όλα τα βίντεο που βρίσκονται εδώ θα μεταφερθούν σε μια λίστα αναπαραγωγής «Αγαπημένα».
|
||||
Παραθέτει μόνο βίντεο που έχετε αποθηκεύσει ή προσθέσει στα αγαπημένα. Όταν ολοκληρωθεί
|
||||
η εργασία, όλα τα βίντεο που βρίσκονται εδώ θα μεταφερθούν στη λίστα αναπαραγωγής
|
||||
«Αγαπημένα».
|
||||
Search bar placeholder: Αναζήτηση στη λίστα αναπαραγωγής
|
||||
Empty Search Message: Δεν υπάρχουν βίντεο σε αυτήν τη λίστα αναπαραγωγής που να
|
||||
ταιριάζουν με την αναζήτησή σας
|
||||
|
@ -131,13 +132,13 @@ Settings:
|
|||
Settings: 'Ρυθμίσεις'
|
||||
General Settings:
|
||||
General Settings: 'Γενικές ρυθμίσεις'
|
||||
Fallback to Non-Preferred Backend on Failure: 'Εναλλαγή σε μη προτιμώμενο συστήμα
|
||||
Fallback to Non-Preferred Backend on Failure: 'Εναλλαγή σε μη προτιμώμενο σύστημα
|
||||
υποστήριξης σε περίπτωση αποτυχίας'
|
||||
Enable Search Suggestions: 'Ενεργοποίηση προτάσεων αναζήτησης'
|
||||
Default Landing Page: 'Προεπιλεγμένη σελίδα προορισμού'
|
||||
Locale Preference: 'Προτιμήσεις γλώσσας'
|
||||
Preferred API Backend:
|
||||
Preferred API Backend: 'Προτιμώμενο συστήμα (API)'
|
||||
Preferred API Backend: 'Προτιμώμενο σύστημα (API)'
|
||||
Local API: 'Τοπική διεπαφή προγραμματισμού εφαρμογών (API)'
|
||||
Invidious API: 'Διεπαφή προγραμματισμού εφαρμογής Invidious (*API)'
|
||||
Video View Type:
|
||||
|
@ -161,7 +162,7 @@ Settings:
|
|||
περιπτώσεων
|
||||
System Default: Προεπιλογή συστήματος
|
||||
Current Invidious Instance: Τρέχων στιγμιότυπο Invidious
|
||||
The currently set default instance is {instance}: 'Το τρέχων προεπιλεγμένο στιγμιότυπο
|
||||
The currently set default instance is {instance}: 'Το τρέχον προεπιλεγμένο στιγμιότυπο
|
||||
είναι {instance}'
|
||||
No default instance has been set: Δεν έχει οριστεί κανένα προεπιλεγμένο στιγμιότυπο
|
||||
External Link Handling:
|
||||
|
@ -614,6 +615,7 @@ Profile:
|
|||
#On Channel Page
|
||||
Profile Filter: Φίλτρο προφίλ
|
||||
Profile Settings: Ρυθμίσεις προφίλ
|
||||
Toggle Profile List: Εναλλαγή Λίστας Προφίλ
|
||||
Channel:
|
||||
Subscriber: 'Συνδρομητής'
|
||||
Subscribers: 'Συνδρομητές'
|
||||
|
@ -928,7 +930,7 @@ Yes: 'Ναι'
|
|||
No: 'Όχι'
|
||||
A new blog is now available, {blogTitle}. Click to view more: Ένα καινούριο ιστολόγιο
|
||||
είναι πλέον διαθέσιμο, {blogTitle}. Για περισσότερες λεπτομέρειες κάντε κλικ εδώ
|
||||
Download From Site: Κάντε λήψη απο την Ιστοσελίδα
|
||||
Download From Site: Κάντε λήψη από την Ιστοσελίδα
|
||||
Version {versionNumber} is now available! Click for more details: Η έκδοση {versionNumber}
|
||||
είναι πλέον διαθέσιμη. Κάντε κλικ για περισσότερες λεπτομέρειες
|
||||
This video is unavailable because of missing formats. This can happen due to country unavailability.: Αυτό
|
||||
|
@ -1039,8 +1041,7 @@ Default Invidious instance has been cleared: Το προεπιλεγμένο σ
|
|||
έχει διαγραφεί
|
||||
External link opening has been disabled in the general settings: Το άνοιγμα εξωτερικών
|
||||
συνδέσμων έχει απενεργοποιηθεί στις γενικές ρυθμίσεις
|
||||
Are you sure you want to open this link?: Είστε σίγουροι ότι θέλετε να ανοίξετε αυτόν
|
||||
τον σύνδεσμο;
|
||||
Are you sure you want to open this link?: Θέλετε σίγουρα να ανοίξετε αυτόν το σύνδεσμο;
|
||||
Downloading failed: Παρουσιάστηκε ένα πρόβλημα κατά τη λήψη του "{videoTitle}"
|
||||
Channels:
|
||||
Channels: Κανάλια
|
||||
|
@ -1049,8 +1050,8 @@ Channels:
|
|||
Unsubscribe: Απεγγραφή
|
||||
Count: $ κανάλι(α) βρέθηκαν.
|
||||
Empty: Η λίστα καναλιών σας είναι άδεια.
|
||||
Unsubscribed: $ έχει αφαιρεθεί απο τις εγγραφές σας
|
||||
Unsubscribe Prompt: Είστε σίγουρος πως επιθυμείτε να απεγγραφείτε από το "$"?
|
||||
Unsubscribed: $ αφαιρέθηκε από τις Συνδρομές σας
|
||||
Unsubscribe Prompt: Θέλετε σίγουρα να απεγγραφείτε από το "$"?
|
||||
New Window: Νέο Παράθυρο
|
||||
Screenshot Error: Λήψη στιγμιότυπου απέτυχε. $
|
||||
Clipboard:
|
||||
|
|
|
@ -493,6 +493,7 @@ About:
|
|||
|
||||
Profile:
|
||||
Profile Settings: Profile Settings
|
||||
Toggle Profile List: Toggle Profile List
|
||||
Profile Select: Profile Select
|
||||
Profile Filter: Profile Filter
|
||||
All Channels: All Channels
|
||||
|
|
|
@ -596,6 +596,7 @@ Profile:
|
|||
Profile Select: Seleccionar perfil
|
||||
Profile Filter: Filtro de perfil
|
||||
Profile Settings: Ajustes del perfil
|
||||
Toggle Profile List: Alternar la lista de los perfiles
|
||||
Channel:
|
||||
Subscriber: 'Suscriptor'
|
||||
Subscribers: 'Suscriptores'
|
||||
|
|
|
@ -443,7 +443,7 @@ Settings:
|
|||
Choose Path: Vali asukoht
|
||||
Download Behavior: Tegevus allalaadimisel
|
||||
Open in web browser: Ava veebibrauseris
|
||||
Download in app: Lae alla rakenduses
|
||||
Download in app: Laadi alla rakenduses
|
||||
Parental Control Settings:
|
||||
Parental Control Settings: Vanemliku järelevalve seadistused
|
||||
Hide Unsubscribe Button: Peida tellimuse tühistamise nupp
|
||||
|
@ -538,6 +538,7 @@ Profile:
|
|||
#On Channel Page
|
||||
Profile Filter: Sirvi profiile
|
||||
Profile Settings: Profiili seadistused
|
||||
Toggle Profile List: Lülita profiilide loend sisse/välja
|
||||
Channel:
|
||||
Subscriber: 'Tellija'
|
||||
Subscribers: 'Tellijad'
|
||||
|
|
|
@ -70,7 +70,7 @@ Search Filters:
|
|||
Medium (4 - 20 minutes): Moyenne (4 - 20 minutes)
|
||||
Search Results: 'Résultats de la recherche'
|
||||
Fetching results. Please wait: 'Récupération des résultats. Veuillez patienter'
|
||||
Fetch more results: 'Montrer plus de résultats'
|
||||
Fetch more results: 'Afficher plus de résultats'
|
||||
# Sidebar
|
||||
There are no more results for this search: Il n'y a plus de résultats pour cette
|
||||
recherche
|
||||
|
@ -655,10 +655,10 @@ Video:
|
|||
Remove From History: 'Retirer de l''historique'
|
||||
Video has been marked as watched: 'La vidéo a été marqué comme Vu'
|
||||
Video has been removed from your history: 'La vidéo a été retiré de votre historique'
|
||||
Open in YouTube: 'Ouvrir sur Youtube'
|
||||
Copy YouTube Link: 'Copier le lien Youtube'
|
||||
Open in YouTube: 'Ouvrir sur YouTube'
|
||||
Copy YouTube Link: 'Copier le lien YouTube'
|
||||
Open YouTube Embedded Player: 'Ouvrir sur Youtube-NoCookie'
|
||||
Copy YouTube Embedded Player Link: 'Copier le lien Youtube-NoCookie'
|
||||
Copy YouTube Embedded Player Link: 'Copier le lien YouTube-NoCookie'
|
||||
Open in Invidious: 'Ouvrir sur Individious'
|
||||
Copy Invidious Link: 'Copier le lien Individious'
|
||||
View: 'Vue'
|
||||
|
@ -836,8 +836,8 @@ Share:
|
|||
Invidious URL copied to clipboard: 'URL Individious copié dans le presse-papier'
|
||||
Invidious Embed URL copied to clipboard: 'URL d''intégration Individious copié dans
|
||||
le presse-papier'
|
||||
YouTube URL copied to clipboard: 'URL Youtube copié dans le presse-papiers'
|
||||
YouTube Embed URL copied to clipboard: 'URL d''intégration Youtube copié dans le
|
||||
YouTube URL copied to clipboard: 'URL YouTube copié dans le presse-papiers'
|
||||
YouTube Embed URL copied to clipboard: 'URL d''intégration YouTube copié dans le
|
||||
presse-papiers'
|
||||
Include Timestamp: Inclure l'horodatage
|
||||
YouTube Channel URL copied to clipboard: L'URL YouTube de la chaîne est copiée dans
|
||||
|
@ -939,6 +939,7 @@ Profile:
|
|||
Other Channels: Autres chaînes
|
||||
Profile Filter: Filtre de profil
|
||||
Profile Settings: Paramètres du profil
|
||||
Toggle Profile List: Afficher la liste des profils
|
||||
The playlist has been reversed: La liste de lecture a été inversée
|
||||
A new blog is now available, {blogTitle}. Click to view more: Un nouveau billet est
|
||||
maintenant disponible, {blogTitle}. Cliquez pour en savoir plus
|
||||
|
|
|
@ -587,6 +587,7 @@ Profile:
|
|||
#On Channel Page
|
||||
Profile Filter: מסנן פרופילים
|
||||
Profile Settings: הגדרת הפרופיל
|
||||
Toggle Profile List: החלפת תצוגת רשימת פרופילים
|
||||
Channel:
|
||||
Subscriber: 'מנוי'
|
||||
Subscribers: 'מנויים'
|
||||
|
|
|
@ -91,6 +91,9 @@ Subscriptions:
|
|||
Empty Channels: Tvoji pretplaćeni kanali trenutačno nemaju videa.
|
||||
Disabled Automatic Fetching: Automatsko dohvaćanje pretplata je deaktivirano. Aktualiziraj
|
||||
pretplate da bi se ovdje prikazale.
|
||||
Subscriptions Tabs: Kartica pretplata
|
||||
All Subscription Tabs Hidden: Sve kartice pretplate su skrivene. Za prikaz sadržaja
|
||||
na ovom mjestu, sakrij neke kartice u odjeljku „{subsection}” u „{settingsSection}”.
|
||||
Trending:
|
||||
Trending: 'U trendu'
|
||||
Trending Tabs: Kartice „U trendu”
|
||||
|
@ -415,8 +418,12 @@ Settings:
|
|||
General: Opće
|
||||
Side Bar: Bočna traka
|
||||
Channel Page: Stranica kanala
|
||||
Subscriptions Page: Stranica pretplata
|
||||
Hide Channel Podcasts: Sakrij kanal podcastova
|
||||
Hide Channel Releases: Sakrij kanal izdanja
|
||||
Hide Subscriptions Shorts: Sakrij kratka videa pretplate
|
||||
Hide Subscriptions Live: Sakrij videa uživo pretplate
|
||||
Hide Subscriptions Videos: Sakrij videa pretplate
|
||||
The app needs to restart for changes to take effect. Restart and apply change?: Promjene
|
||||
će se primijeniti nakon ponovnog pokeretanja programa. Ponovo pokrenuti program?
|
||||
Proxy Settings:
|
||||
|
@ -967,6 +974,8 @@ Tooltips:
|
|||
Hide Channels: Upiši ime kanala ili ID kanala za skrivanje svih videa, zbirki
|
||||
kao i samog kanala u pretrazi, trendovima popularnim i preporučenim. Upisano
|
||||
ime kanala se mora potpuno poklapati i razlikuje velika i mala slova.
|
||||
Hide Subscriptions Live: Ovu postavku nadjačava aplikacijska postavka „{appWideSetting}”,
|
||||
u odjeljku „{subsection}” u „{settingsSection}”
|
||||
SponsorBlock Settings:
|
||||
UseDeArrowTitles: Zamijeni naslove videa koje su poslali korisnici s DeArrow naslovima.
|
||||
Playing Next Video Interval: Trenutna reprodukcija sljedećeg videa. Pritisni za prekid.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Put the name of your locale in the same language
|
||||
Locale Name: 'magyar'
|
||||
FreeTube: 'SzabadCső'
|
||||
FreeTube: 'FreeTube'
|
||||
# Currently on Subscriptions, Playlists, and History
|
||||
'This part of the app is not ready yet. Come back later when progress has been made.': >-
|
||||
Az alkalmazás ezen része még nem áll készen. Térjen vissza később amikor előrelépés
|
||||
|
@ -328,8 +328,8 @@ Settings:
|
|||
Import YouTube: 'YouTube importálása'
|
||||
Import NewPipe: 'NewPipe importálása'
|
||||
Export Subscriptions: 'Feliratkozások exportálása'
|
||||
Export FreeTube: 'SzabadCső exportálása'
|
||||
Export YouTube: 'SzabadCső exportálása'
|
||||
Export FreeTube: 'FreeTube exportálása'
|
||||
Export YouTube: 'YouTube exportálása'
|
||||
Export NewPipe: 'NewPipe exportálása'
|
||||
Import History: 'Előzmények importálása'
|
||||
Export History: 'Előzmények exportálása'
|
||||
|
@ -432,7 +432,7 @@ Settings:
|
|||
Hide Channel Releases: Csatornakiadások elrejtése
|
||||
Hide Subscriptions Shorts: Feliratkozások rövidfilmek elrejtése
|
||||
Hide Subscriptions Videos: Feliratkozási videók elrejtése
|
||||
Hide Subscriptions Live: Feliratkozások élők elrejtése
|
||||
Hide Subscriptions Live: Élő feliratkozások elrejtése
|
||||
The app needs to restart for changes to take effect. Restart and apply change?: Az
|
||||
alkalmazásnak újra kell indulnia, hogy a változtatások életbe lépjenek. Indítsa
|
||||
újra és alkalmazza a módosítást?
|
||||
|
@ -501,7 +501,8 @@ Settings:
|
|||
Password: Jelszó
|
||||
Password Settings:
|
||||
Password Settings: Jelszóbeállítások
|
||||
Set Password To Prevent Access: Beállítások jelszó megadása
|
||||
Set Password To Prevent Access: Jelszó beállítása a beállításokhoz való hozzáférés
|
||||
megakadályozásához
|
||||
Set Password: Jelszó megadása
|
||||
Remove Password: Jelszó eltávolítása
|
||||
About:
|
||||
|
@ -537,7 +538,7 @@ About:
|
|||
Latest FreeTube News: 'Legfrissebb FreeTube hírek'
|
||||
|
||||
these people and projects: ezek emberek és projektek
|
||||
FreeTube is made possible by: 'A SzabadCső engedélyezése'
|
||||
FreeTube is made possible by: 'A FreeTube-ot a következők teszik lehetővé'
|
||||
Donate: Hozzájárulás
|
||||
Credits: Közreműködők
|
||||
Translate: Fordítás
|
||||
|
@ -557,7 +558,7 @@ About:
|
|||
View License: Licenc megtekintése
|
||||
Licensed under the AGPLv3: Az AGPL (GNU Affero General Public License - GNU Affero
|
||||
általános nyilvános licenc) 3. verziója alatt licencelt
|
||||
FreeTube Wiki: SzabadCső Wiki
|
||||
FreeTube Wiki: FreeTube Wiki
|
||||
Source code: Forráskód
|
||||
Beta: Béta
|
||||
FAQ: GyIK
|
||||
|
@ -604,6 +605,7 @@ Profile:
|
|||
#On Channel Page
|
||||
Profile Filter: Profilszűrő
|
||||
Profile Settings: Profilbeállítások
|
||||
Toggle Profile List: Profillista be-/kikapcsolása
|
||||
Channel:
|
||||
Subscriber: 'Feliratkozó'
|
||||
Subscribers: 'Feliratkozók'
|
||||
|
@ -734,7 +736,7 @@ Video:
|
|||
Years: 'évvel'
|
||||
Ago: 'ezelőtt'
|
||||
Upcoming: 'Első előadás dátuma'
|
||||
In less than a minute: Kevesebb, mint egy perce ezelőtt
|
||||
In less than a minute: Kevesebb, mint egy perccel ezelőtt
|
||||
Published on: 'Megjelent'
|
||||
Publicationtemplate: '{number} {unit} ezelőtt'
|
||||
#& Videos
|
||||
|
@ -751,7 +753,7 @@ Video:
|
|||
Open Channel in YouTube: Csatorna megnyitása a YouTube oldalon
|
||||
Open Channel in Invidious: Csatorna megnyitása az Invidious oldalán
|
||||
Started streaming on: Folyamatos átvitel indult
|
||||
Streamed on: 'Adatfolyam dátuma:'
|
||||
Streamed on: 'Közvetítve'
|
||||
Video has been removed from your saved list: A videót eltávolítottuk a mentett listáról
|
||||
Video has been saved: A videó mentve
|
||||
Save Video: Videó mentése
|
||||
|
@ -875,7 +877,7 @@ Comments:
|
|||
Show More Replies: További válaszok megjelenítése
|
||||
From {channelName}: 'forrás: {channelName}'
|
||||
And others: és mások
|
||||
Pinned by: 'Kitűzte:'
|
||||
Pinned by: 'Kitűzve'
|
||||
Member: Tag
|
||||
View {replyCount} replies: '{replyCount} válasz megtekintése'
|
||||
Hearted: Kedvenc
|
||||
|
@ -905,28 +907,28 @@ Yes: 'Igen'
|
|||
No: 'Nem'
|
||||
Tooltips:
|
||||
General Settings:
|
||||
Preferred API Backend: Válassza ki a háttéralkalmazást, amelyet a SzabadCső használ
|
||||
Preferred API Backend: Válassza ki a háttéralkalmazást, amelyet a FreeTube használ
|
||||
az adatok megszerzéséhez. A helyi-API egy beépített kinyerő. Az Invidious-API
|
||||
használatához Invidious-kiszolgáló szükséges.
|
||||
Region for Trending: A népszerűk körzetével kiválaszthatja, mely ország népszerű
|
||||
videóit szeretné megjeleníteni.
|
||||
Invidious Instance: Invidious-példány, amelyhez a SzabadCső csatlakozni fog az
|
||||
Invidious Instance: Invidious-példány, amelyhez a FreeTube csatlakozni fog az
|
||||
API-hívásokhoz.
|
||||
Thumbnail Preference: A SzabadCső összes miniatűrökét az alapértelmezett miniatűr
|
||||
helyett egy képkocka váltja fel.
|
||||
Thumbnail Preference: A FreeTube összes indexképét a videó egy képkockája váltja
|
||||
fel az alapértelmezett miniatűr helyett.
|
||||
Fallback to Non-Preferred Backend on Failure: Ha az Ön által előnyben részesített
|
||||
API-val hibába merül fel, a SzabadCső önműködően megpróbálja a nem előnyben
|
||||
API-val hibába merül fel, a FreeTube önműködően megpróbálja a nem előnyben részesített
|
||||
API-t tartalékként használni, ha engedélyezve van.
|
||||
External Link Handling: "Válassza ki az alapértelmezett viselkedést, ha egy hivatkozásra
|
||||
kattintanak, amely nem nyitható meg a SzabadCsőben.\nA SzabadCső alapértelmezés
|
||||
kattintanak, amely nem nyitható meg FreeTube-ban.\nA FreeTube alapértelmezés
|
||||
szerint megnyitja a kattintott hivatkozást az alapértelmezett böngészőben.\n"
|
||||
Subscription Settings:
|
||||
Fetch Feeds from RSS: Ha engedélyezve van, a SzabadCső az alapértelmezett módszer
|
||||
Fetch Feeds from RSS: Ha engedélyezve van, a FreeTube az alapértelmezett módszer
|
||||
helyett RSS-t fog használni a feliratkozás hírcsatornájának megragadásához.
|
||||
Az RSS gyorsabb és megakadályozza az IP-zárolást, de nem nyújt bizonyos tájékoztatást,
|
||||
például a videó időtartamát vagy az élő állapotot
|
||||
Fetch Automatically: Ha engedélyezve van, a SzabadCső új ablak megnyitásakor és
|
||||
profilváltáskor önműködően lekéri az előfizetési hírfolyamot.
|
||||
Fetch Automatically: Ha engedélyezve van, a FreeTube új ablak megnyitásakor és
|
||||
profilváltáskor önműködően lekéri az feliratkozási hírfolyamot.
|
||||
Player Settings:
|
||||
Default Video Format: Állítsa be a videó lejátszásakor használt formátumokat.
|
||||
A DASH (dinamikus adaptív sávszélességű folyamatos átvitel HTTP-n keresztül)
|
||||
|
@ -954,10 +956,10 @@ Tooltips:
|
|||
Nem minden videónál érhetők el, ilyenkor a lejátszó a DASH H.264 formátumot
|
||||
használja helyette.
|
||||
Privacy Settings:
|
||||
Remove Video Meta Files: Ha engedélyezve van, a SzabadCső önműködően törli a videolejátszás
|
||||
Remove Video Meta Files: Ha engedélyezve van, a FreeTube önműködően törli a videolejátszás
|
||||
során létrehozott metafájlokat, amikor a nézőlap bezár.
|
||||
External Player Settings:
|
||||
Custom External Player Executable: Alapértelmezés szerint a SzabadCső feltételezi,
|
||||
Custom External Player Executable: Alapértelmezés szerint a FreeTube feltételezi,
|
||||
hogy a kiválasztott külső lejátszó megtalálható a PATH (ÚTVONAL) környezeti
|
||||
változón keresztül. Szükség esetén itt egyéni útvonal állítható be.
|
||||
Ignore Warnings: A figyelmeztetések elvetése, ha a jelenlegi külső lejátszó nem
|
||||
|
@ -987,7 +989,7 @@ Playing Next Video Interval: A következő videó lejátszása folyamatban van.
|
|||
másodperc múlva történik. Kattintson a törléshez.
|
||||
More: Több
|
||||
Hashtags have not yet been implemented, try again later: A kettőskeresztescímkék kezelése
|
||||
még nincs megvalósítva. Próbálkozzon a következő verzióban.
|
||||
még nincs implementálva. próbálkozz a következő verzióban
|
||||
Unknown YouTube url type, cannot be opened in app: Ismeretlen YouTube URL-típusa,
|
||||
nem nyitható meg az alkalmazásban
|
||||
Open New Window: Új ablak megnyitása
|
||||
|
|
|
@ -66,12 +66,14 @@ Search Filters:
|
|||
Videos: 'Video'
|
||||
Channels: 'Kanal'
|
||||
#& Playlists
|
||||
Movies: Film
|
||||
Duration:
|
||||
Duration: 'Durasi'
|
||||
All Durations: 'Semua Durasi'
|
||||
Short (< 4 minutes): 'Pendek (< 4 menit)'
|
||||
Long (> 20 minutes): 'Panjang (>20 menit)'
|
||||
# On Search Page
|
||||
Medium (4 - 20 minutes): Medium (4 - 20 minutes)
|
||||
Search Results: 'Hasil Pencarian'
|
||||
Fetching results. Please wait: 'Mengambil hasil. Silakan tunggu'
|
||||
Fetch more results: 'Ambil lebih banyak hasil'
|
||||
|
@ -773,9 +775,9 @@ Tooltips:
|
|||
Preferred API Backend: Pilih layanan yang digunakan oleh FreeTube untuk mengambil
|
||||
data. API lokal adalah ekstraktor bawaan. API Invidious membutuhkan sambungan
|
||||
ke server Invidious.
|
||||
External Link Handling: "Pilih perilaku default ketika tautan, yang tidak dapat\
|
||||
\ dibuka di FreeTube, diklik.\nSecara default FreeTube akan membuka tautan yang\
|
||||
\ diklik dengan browser default Anda.\n"
|
||||
External Link Handling: "Pilih perilaku default ketika tautan, yang tidak dapat
|
||||
dibuka di FreeTube, diklik.\nSecara default FreeTube akan membuka tautan yang
|
||||
diklik dengan browser default Anda.\n"
|
||||
Privacy Settings:
|
||||
Remove Video Meta Files: Saat diaktifkan, FreeTube secara otomatis menghapus file
|
||||
meta yang dibuat selama pemutaran video, saat halaman tonton ditutup.
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue