Merge branch 'development' into piped-support

This commit is contained in:
ChunkyProgrammer 2023-08-21 06:06:17 -07:00
commit 863c1d2572
113 changed files with 1994 additions and 1272 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', () => {

View File

@ -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'
]),

View File

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

View File

@ -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'
]),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@
:channel-name="channel.name"
:channel-thumbnail="channel.thumbnail"
:show-selected="true"
role="button"
@click="handleChannelClick(index)"
/>
</ft-flex-box>

View File

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

View File

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

View File

@ -24,6 +24,7 @@
:channel-name="channel.name"
:channel-thumbnail="channel.thumbnail"
:show-selected="true"
role="button"
@click="handleChannelClick(index)"
/>
</ft-flex-box>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,2 @@
import Vue from 'vue'
const events = new Vue()
const events = new EventTarget()
export default events

View File

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

View File

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

View File

@ -22,7 +22,7 @@ export default defineComponent({
title: '',
channelThumbnail: '',
channelName: '',
channelId: '',
channelId: null,
videoCount: 0,
viewCount: null,
lastUpdated: '',

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
ref="sideNav"
class="sideNav"
:class="[{closed: !isOpen}, applyHiddenLabels]"
role="navigation"
>
<div
class="inner"

View File

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

View File

@ -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 []
}

View File

@ -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 []
}

View File

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

View File

@ -14,7 +14,6 @@
:channel-name="channel.name"
:channel-id="channel.id"
:channel-thumbnail="channel.thumbnail"
class="channelBubble"
@click="goToChannel(channel.id)"
/>
</div>

View File

@ -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 []
}

View File

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

View File

@ -2,6 +2,7 @@
<div
class="topNav"
:class="{ topNavBarColor: barColor }"
role="navigation"
>
<div class="side">
<font-awesome-icon

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('"', '&quot;')
.replaceAll('\'', '&apos;')
}
/**
* Performs a deep copy of a javascript object
* @param {Object} obj
* @returns {Object}
*/
export function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj))
}

View File

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

View File

@ -63,10 +63,6 @@ const actions = {
} catch (errMessage) {
console.error(errMessage)
}
},
compactHistory(_) {
DBHistoryHandlers.persist()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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%;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
v-else
class="card"
>
<h3>{{ $t("Most Popular") }}</h3>
<h2>{{ $t("Most Popular") }}</h2>
<ft-element-list
:data="shownResults"
/>

View File

@ -25,4 +25,3 @@
</template>
<script src="./ProfileEdit.js" />
<style scoped src="./ProfileEdit.css" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -585,6 +585,7 @@ Profile:
Subscription List: قائمة الاشتراكات
Profile Filter: مرشح الملف الشخصي
Profile Settings: إعدادات الملف الشخصي
Toggle Profile List: تبديل قائمة الملف الشخصي
Channel:
Subscriber: 'مُشترِك'
Subscribers: 'مُشترِكين'

View File

@ -601,6 +601,7 @@ Profile:
#On Channel Page
Profile Filter: Профилен филтър
Profile Settings: Настройки на профил
Toggle Profile List: Превключване на списъка с профили
Channel:
Subscriber: 'Абонат'
Subscribers: 'Абонати'

View File

@ -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: ভিডিও

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -587,6 +587,7 @@ Profile:
#On Channel Page
Profile Filter: מסנן פרופילים
Profile Settings: הגדרת הפרופיל
Toggle Profile List: החלפת תצוגת רשימת פרופילים
Channel:
Subscriber: 'מנוי'
Subscribers: 'מנויים'

View File

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

View File

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

View File

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