Merge branch 'development' into iv-rss-detect-error-channels-properly

This commit is contained in:
ChunkyProgrammer 2024-03-07 20:17:20 -05:00
commit e1aa567337
127 changed files with 3505 additions and 1832 deletions

View File

@ -1 +1,17 @@
blank_issues_enabled: false
contact_links:
- name: Discussions
url: https://github.com/FreeTubeApp/FreeTube/discussions/categories/general
about: View discussions or start one yourself
- name: Questions
url: https://github.com/FreeTubeApp/FreeTube/discussions/categories/q-a
about: Ask and answer questions
- name: Matrix Community
url: https://matrix.to/#/+freetube:matrix.org
about: Join our Matrix chatroom - "Note: Bugs and Feature requests should be made on GitHub and not in the Matrix room"
- name: Translate FreeTube
url: https://hosted.weblate.org/engage/free-tube/
about: Help translate FreeTube on Weblate
- name: FreeTube Documentation
url: https://docs.freetubeapp.io/
about: View the Documentation to find all relevant information about FreeTube

View File

@ -108,91 +108,91 @@ jobs:
run: yarn run build:arm64
- name: Upload Linux .zip x64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_x64
path: build/freetube-${{ steps.versionNumber.outputs.result }}.zip
- name: Upload Linux .7z x64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_x64.7z
path: build/freetube-${{ steps.versionNumber.outputs.result }}.7z
- name: Upload Linux .zip ARMv7l Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_armv7l
path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.zip
- name: Upload Linux .7z ARMv7l Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_armv7l.7z
path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.7z
- name: Upload Linux .zip ARM64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_arm64
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.zip
- name: Upload Linux .7z ARM64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_arm64.7z
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.7z
- name: Upload .deb x64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.deb
path: build/freetube_${{ steps.versionNumber.outputs.result }}_amd64.deb
- name: Upload .deb ARMv7l Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_armv7l.deb
path: build/freetube_${{ steps.versionNumber.outputs.result }}_armv7l.deb
- name: Upload .deb ARM64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_arm64.deb
path: build/freetube_${{ steps.versionNumber.outputs.result }}_arm64.deb
- name: Upload AppImage x64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.AppImage
path: build/FreeTube-${{ steps.versionNumber.outputs.result }}.AppImage
- name: Upload AppImage ARMv7l Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_armv7l.AppImage
path: build/FreeTube-${{ steps.versionNumber.outputs.result }}-armv7l.AppImage
- name: Upload AppImage ARM64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_arm64.AppImage
path: build/FreeTube-${{ steps.versionNumber.outputs.result }}-arm64.AppImage
- name: Upload .rpm x64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.rpm
@ -201,133 +201,133 @@ jobs:
# rpm are not built for armv7l
- name: Upload .rpm ARM64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_arm64.rpm
path: build/freetube-${{ steps.versionNumber.outputs.result }}.aarch64.rpm
- name: Upload Alpine .apk x64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_amd64.apk
path: build/freetube-${{ steps.versionNumber.outputs.result }}.apk
- name: Upload Alpine .apk ARMv7l Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_armv7l.apk
path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.apk
- name: Upload Alpine .apk ARM64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_arm64.apk
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.apk
- name: Upload Pacman .pacman x64 Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
with:
name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.pacman
path: build/freetube-${{ steps.versionNumber.outputs.result }}.pacman
# - name: Upload Web Build
# uses: actions/upload-artifact@v3
# uses: actions/upload-artifact@v4
# if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
# with:
# name: freetube_${{ steps.versionNumber.outputs.result }}_static_web
# path: dist/web
- name: Upload Windows x64 .exe Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-setup-x64.exe
path: build/freetube Setup ${{ steps.versionNumber.outputs.result }}.exe
- name: Upload Windows arm64 .exe Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-setup-arm64.exe
path: build/freetube Setup ${{ steps.versionNumber.outputs.result }}.exe
- name: Upload Windows x64 .zip Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-win-x64-portable
path: build/freetube-${{ steps.versionNumber.outputs.result }}-win.zip
- name: Upload Windows x64 .7z Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-win-x64-portable.7z
path: build/freetube-${{ steps.versionNumber.outputs.result }}-win.7z
- name: Upload Windows arm64 .zip Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-win-arm64-portable
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64-win.zip
- name: Upload Windows arm64 .7z Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-win-arm64-portable.7z
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64-win.7z
- name: Upload Windows x64 Portable Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-portable-x64.exe
path: build/freetube ${{ steps.versionNumber.outputs.result }}.exe
- name: Upload Windows arm64 Portable Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-portable-arm64.exe
path: build/freetube ${{ steps.versionNumber.outputs.result }}.exe
- name: Upload Mac x64 .dmg Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.dmg
path: build/freetube-${{ steps.versionNumber.outputs.result }}.dmg
# - name: Upload Mac arm64 .dmg Artifact
# uses: actions/upload-artifact@v3
# uses: actions/upload-artifact@v4
# if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64')
# with:
# name: freetube-${{ steps.versionNumber.outputs.result }}-mac-arm64.dmg
# path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.dmg
- name: Upload Mac x64 .zip Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.zip
path: build/freetube-${{ steps.versionNumber.outputs.result }}-mac.zip
- name: Upload Mac x64 .7z Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64')
with:
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.7z
path: build/freetube-${{ steps.versionNumber.outputs.result }}-mac.7z
# - name: Upload Mac arm64 .zip Artifact
# uses: actions/upload-artifact@v3
# uses: actions/upload-artifact@v4
# if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64')
# with:
# name: freetube-${{ steps.versionNumber.outputs.result }}-mac-arm64.zip

View File

@ -20,7 +20,7 @@ jobs:
compressOnly: true
- name: Create New Pull Request If Needed
if: steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@v5
uses: peter-evans/create-pull-request@v6
with:
title: Compressed Images Nightly
branch-suffix: timestamp

View File

@ -11,7 +11,7 @@ jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: github/issue-labeler@v3.3
- uses: github/issue-labeler@v3.4
with:
configuration-path: .github/issue-labeler.yml
enable-versioned-regex: 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,6 +1,5 @@
process.env.NODE_ENV = 'development'
const open = require('open')
const electron = require('electron')
const webpack = require('webpack')
const WebpackDevServer = require('webpack-dev-server')
@ -161,7 +160,8 @@ function startWeb (callback) {
if (!web) {
startRenderer(startMain)
} else {
startWeb(({ port }) => {
startWeb(async ({ port }) => {
const open = (await import('open')).default
open(`http://localhost:${port}`)
})
}

View File

@ -1,5 +1,5 @@
const path = require('path')
const { readFileSync } = require('fs')
const { readFileSync, readdirSync } = require('fs')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
@ -11,6 +11,8 @@ const CopyWebpackPlugin = require('copy-webpack-plugin')
const isDevMode = process.env.NODE_ENV === 'development'
const { version: swiperVersion } = JSON.parse(readFileSync(path.join(__dirname, '../node_modules/swiper/package.json')))
const processLocalesPlugin = new ProcessLocalesPlugin({
compress: !isDevMode,
inputDir: path.join(__dirname, '../static/locales'),
@ -117,7 +119,9 @@ const config = {
new webpack.DefinePlugin({
'process.env.IS_ELECTRON': true,
'process.env.IS_ELECTRON_MAIN': false,
'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames)
'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames),
'process.env.GEOLOCATION_NAMES': JSON.stringify(readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))),
'process.env.SWIPER_VERSION': `'${swiperVersion}'`
}),
new HtmlWebpackPlugin({
excludeChunks: ['processTaskWorker'],
@ -136,7 +140,7 @@ const config = {
patterns: [
{
from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'),
to: 'swiper.css',
to: `swiper-${swiperVersion}.css`,
context: path.join(__dirname, '../node_modules/swiper/modules'),
transformAll: (assets) => {
return Buffer.concat(assets.map(asset => asset.data))

View File

@ -11,6 +11,8 @@ const ProcessLocalesPlugin = require('./ProcessLocalesPlugin')
const isDevMode = process.env.NODE_ENV === 'development'
const { version: swiperVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '../node_modules/swiper/package.json')))
const config = {
name: 'web',
mode: process.env.NODE_ENV,
@ -114,6 +116,7 @@ const config = {
new webpack.DefinePlugin({
'process.env.IS_ELECTRON': false,
'process.env.IS_ELECTRON_MAIN': false,
'process.env.SWIPER_VERSION': `'${swiperVersion}'`,
// video.js' vhs-utils supports both atob() in web browsers and Buffer in node
// As the FreeTube web build only runs in web browsers, we can override their check for atob() here: https://github.com/videojs/vhs-utils/blob/main/src/decode-b64-to-uint8-array.js#L3
@ -145,7 +148,7 @@ const config = {
patterns: [
{
from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'),
to: 'swiper.css',
to: `swiper-${swiperVersion}.css`,
context: path.join(__dirname, '../node_modules/swiper/modules'),
transformAll: (assets) => {
return Buffer.concat(assets.map(asset => asset.data))

View File

@ -1,5 +1,8 @@
{
"vueCompilerOptions": {
"target": 2.7
},
"compilerOptions": {
"strictNullChecks": true
}
}

View File

@ -62,10 +62,10 @@
"autolinker": "^4.0.0",
"electron-context-menu": "^3.6.1",
"lodash.debounce": "^4.0.8",
"marked": "^11.2.0",
"marked": "^12.0.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"swiper": "^11.0.5",
"swiper": "^11.0.7",
"video.js": "7.21.5",
"videojs-contrib-quality-levels": "^3.0.0",
"videojs-http-source-selector": "^1.1.6",
@ -77,21 +77,21 @@
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
"vuex": "^3.6.2",
"youtubei.js": "^8.2.0"
"youtubei.js": "^9.1.0"
},
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/eslint-parser": "^7.23.9",
"@babel/core": "^7.24.0",
"@babel/eslint-parser": "^7.23.10",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/preset-env": "^7.23.9",
"@double-great/stylelint-a11y": "^3.0.0",
"@babel/preset-env": "^7.24.0",
"@double-great/stylelint-a11y": "^3.0.2",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.9.1",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"electron": "^28.2.0",
"electron-builder": "^24.9.1",
"eslint": "^8.56.0",
"electron": "^29.1.0",
"electron-builder": "^24.13.3",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.1",
@ -99,23 +99,23 @@
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-unicorn": "^50.0.1",
"eslint-plugin-vue": "^9.20.1",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-vue": "^9.22.0",
"eslint-plugin-vuejs-accessibility": "^2.2.1",
"eslint-plugin-yml": "^1.12.2",
"html-webpack-plugin": "^5.6.0",
"js-yaml": "^4.1.0",
"json-minimizer-webpack-plugin": "^5.0.0",
"lefthook": "^1.6.1",
"mini-css-extract-plugin": "^2.7.7",
"lefthook": "^1.6.4",
"mini-css-extract-plugin": "^2.8.1",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.33",
"postcss": "^8.4.35",
"postcss-scss": "^4.0.9",
"prettier": "^2.8.8",
"rimraf": "^5.0.5",
"sass": "^1.70.0",
"sass-loader": "^14.0.0",
"stylelint": "^16.2.0",
"sass": "^1.71.1",
"sass-loader": "^14.1.1",
"stylelint": "^16.2.1",
"stylelint-config-sass-guidelines": "^11.0.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-high-performance-animation": "^1.10.0",
@ -124,9 +124,9 @@
"vue-devtools": "^5.1.4",
"vue-eslint-parser": "^9.4.2",
"vue-loader": "^15.10.0",
"webpack": "^5.90.0",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"webpack-dev-server": "^5.0.2",
"webpack-watch-external-files-plugin": "^3.0.0",
"yaml-eslint-parser": "^1.2.2"
}

View File

@ -64,7 +64,9 @@ function runApp() {
const path = urlParts[1]
if (path) {
visible = ['/playlist', '/channel', '/watch'].some(p => path.startsWith(p))
visible = ['/channel', '/watch'].some(p => path.startsWith(p)) ||
// Only show copy link entry for non user playlists
(path.startsWith('/playlist') && !/playlistType=user/.test(path))
}
} else {
visible = true
@ -103,17 +105,17 @@ function runApp() {
let url
if (toYouTube) {
url = `https://youtu.be/${id}`
url = new URL(`https://youtu.be/${id}`)
} else {
url = `https://redirect.invidious.io/watch?v=${id}`
url = new URL(`https://redirect.invidious.io/watch?v=${id}`)
}
if (query) {
const params = new URLSearchParams(query)
const newParams = new URLSearchParams()
const newParams = new URLSearchParams(url.search)
let hasParams = false
if (params.has('playlistId')) {
if (params.has('playlistId') && params.get('playlistType') !== 'user') {
newParams.set('list', params.get('playlistId'))
hasParams = true
}
@ -124,11 +126,11 @@ function runApp() {
}
if (hasParams) {
url += '?' + newParams.toString()
url.search = newParams.toString()
}
}
return url
return url.toString()
}
}
}
@ -493,6 +495,8 @@ function runApp() {
return '#ffd1dc'
case 'hot-pink':
return '#de1c85'
case 'nordic':
return '#2b2f3a'
case 'system':
default:
return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1'

View File

@ -882,7 +882,23 @@ export default defineComponent({
showToast(`${message}: ${err}`)
return
}
const playlists = JSON.parse(data)
let playlists = null
// for the sake of backwards compatibility,
// check if this is the old JSON array export (used until version 0.19.1),
// that didn't match the actual database format
const trimmedData = data.trim()
if (trimmedData[0] === '[' && trimmedData[trimmedData.length - 1] === ']') {
playlists = JSON.parse(trimmedData)
} else {
// otherwise assume this is the correct database format,
// which is also what we export now (used in 0.20.0 and later versions)
data = data.split('\n')
data.pop()
playlists = data.map(playlistJson => JSON.parse(playlistJson))
}
const requiredKeys = [
'playlistName',
@ -1015,7 +1031,11 @@ export default defineComponent({
]
}
await this.promptAndWriteToFile(options, JSON.stringify(this.allPlaylists), 'All playlists has been successfully exported')
const playlistsDb = this.allPlaylists.map(playlist => {
return JSON.stringify(playlist)
}).join('\n') + '\n'// a trailing line is expected
await this.promptAndWriteToFile(options, playlistsDb, 'All playlists has been successfully exported')
},
exportPlaylistsForOlderVersionsSometimes: function () {
@ -1133,7 +1153,7 @@ export default defineComponent({
})
if (process.env.IS_ELECTRON && this.backendFallback && this.backendPreference === 'invidious') {
showToast(this.$t('Falling back to the local API'))
showToast(this.$t('Falling back to Local API'))
resolve(this.getChannelInfoLocal(channelId))
} else {
resolve([])

View File

@ -3,10 +3,14 @@ import { defineComponent } from 'vue'
export default defineComponent({
name: 'FtAgeRestricted',
props: {
contentTypeString: {
type: String,
required: true
}
isChannel: {
type: Boolean,
default: false,
},
isVideo: {
type: Boolean,
default: false,
},
},
computed: {
emoji: function () {
@ -15,8 +19,11 @@ export default defineComponent({
},
restrictedMessage: function () {
const contentType = this.$t('Age Restricted.Type.' + this.contentTypeString)
return this.$t('Age Restricted.This {videoOrPlaylist} is age restricted', { videoOrPlaylist: contentType })
if (this.isChannel) {
return this.$t('Age Restricted.This channel is age restricted')
}
return this.$t('Age Restricted.This video is age restricted:')
}
}
})

View File

@ -40,7 +40,6 @@ export default defineComponent({
voteCount: '',
postContent: '',
commentCount: '',
isLoading: true,
author: '',
authorId: '',
}
@ -73,7 +72,7 @@ export default defineComponent({
injectStylesUrls: [
// This file is created with the copy webpack plugin in the web and renderer webpack configs.
// If you add more modules, please remember to add their CSS files to the list in webpack config files.
createWebURL('/swiper.css')
createWebURL(`/swiper-${process.env.SWIPER_VERSION}.css`)
],
a11y: true,
@ -132,7 +131,6 @@ export default defineComponent({
this.type = (this.data.postContent !== null && this.data.postContent !== undefined) ? this.data.postContent.type : 'text'
this.author = this.data.author
this.authorId = this.data.authorId
this.isLoading = false
},
getBestQualityImage(imageArray) {

View File

@ -1,6 +1,5 @@
<template>
<div
v-if="!isLoading"
class="ft-list-post ft-list-item outside"
:appearance="appearance"
:class="{ list: listType === 'list', grid: listType === 'grid' }"

View File

@ -128,6 +128,11 @@ export default defineComponent({
handleResize: function () {
this.useModal = window.innerWidth <= 900
}
},
focus() {
// To be called by parent components
this.$refs.iconButton.focus()
},
}
})

View File

@ -1,6 +1,7 @@
<template>
<div class="ftIconButton">
<font-awesome-icon
ref="iconButton"
class="iconButton"
:title="title"
:icon="icon"

View File

@ -0,0 +1,17 @@
/*
Set a height to invisible/unloaded elements, so that lazy loading actually works.
If we don't set a height, they all get a height of 0px (because they have no content),
so they all bunch up together and end up loading all of them in one go.
*/
.placeholder {
block-size: 40px;
}
.videoIndex {
color: var(--tertiary-text-color);
text-align: center;
}
.videoIndexIcon {
font-size: 14px;
}

View File

@ -0,0 +1,124 @@
import { defineComponent } from 'vue'
import FtListVideo from '../ft-list-video/ft-list-video.vue'
export default defineComponent({
name: 'FtListVideoNumbered',
components: {
'ft-list-video': FtListVideo
},
props: {
data: {
type: Object,
required: true
},
playlistId: {
type: String,
default: null
},
playlistType: {
type: String,
default: null
},
playlistIndex: {
type: Number,
default: null
},
playlistReverse: {
type: Boolean,
default: false
},
playlistShuffle: {
type: Boolean,
default: false
},
playlistLoop: {
type: Boolean,
default: false
},
playlistItemId: {
type: String,
default: null,
},
appearance: {
type: String,
required: true
},
initialVisibleState: {
type: Boolean,
default: false,
},
alwaysShowAddToPlaylistButton: {
type: Boolean,
default: false,
},
quickBookmarkButtonEnabled: {
type: Boolean,
default: true,
},
canMoveVideoUp: {
type: Boolean,
default: false,
},
canMoveVideoDown: {
type: Boolean,
default: false,
},
canRemoveFromPlaylist: {
type: Boolean,
default: false,
},
videoIndex: {
type: Number,
default: -1
},
isCurrentVideo: {
type: Boolean,
default: false
},
useChannelsHiddenPreference: {
type: Boolean,
default: false,
}
},
data: function () {
return {
visible: false,
show: true
}
},
computed: {
channelsHidden() {
// Some component users like channel view will have this disabled
if (!this.useChannelsHiddenPreference) { return [] }
return JSON.parse(this.$store.getters.getChannelsHidden).map((ch) => {
// Legacy support
if (typeof ch === 'string') {
return { name: ch, preferredName: '', icon: '' }
}
return ch
})
},
// As we only use this component in Playlist and watch-video-playlist,
// where title filtering is never desired, we don't have any title filtering logic here,
// like we do in ft-list-video-lazy
shouldBeVisible() {
return !(this.channelsHidden.some(ch => ch.name === this.data.authorId) ||
this.channelsHidden.some(ch => ch.name === this.data.author))
}
},
created() {
this.visible = this.initialVisibleState
},
methods: {
onVisibilityChanged: function (visible) {
if (visible && this.shouldBeVisible) {
this.visible = visible
} else if (visible) {
this.show = false
}
}
}
})

View File

@ -0,0 +1,53 @@
<template>
<div
v-show="show"
v-observe-visibility="!initialVisibleState ? {
callback: onVisibilityChanged,
once: true,
} : null"
:class="{ placeholder: !visible }"
>
<template
v-if="visible"
>
<p
class="videoIndex"
>
<font-awesome-icon
v-if="isCurrentVideo"
class="videoIndexIcon"
:icon="['fas', 'play']"
/>
<template
v-else
>
{{ videoIndex + 1 }}
</template>
</p>
<ft-list-video
:data="data"
:playlist-id="playlistId"
:playlist-type="playlistType"
:playlist-index="playlistIndex"
:playlist-reverse="playlistReverse"
:playlist-shuffle="playlistShuffle"
:playlist-loop="playlistLoop"
:playlist-item-id="playlistItemId"
force-list-type="list"
:appearance="appearance"
:always-show-add-to-playlist-button="alwaysShowAddToPlaylistButton"
:quick-bookmark-button-enabled="quickBookmarkButtonEnabled"
:can-move-video-up="canMoveVideoUp"
:can-move-video-down="canMoveVideoDown"
:can-remove-from-playlist="canRemoveFromPlaylist"
@pause-player="$emit('pause-player')"
@move-video-up="$emit('move-video-up')"
@move-video-down="$emit('move-video-down')"
@remove-from-playlist="$emit('remove-from-playlist')"
/>
</template>
</div>
</template>
<script src="./ft-list-video-numbered.js" />
<style scoped src="./ft-list-video-numbered.css" />

View File

@ -97,7 +97,6 @@ export default defineComponent({
lengthSeconds: 0,
duration: '',
description: '',
watched: false,
watchProgress: 0,
publishedText: '',
isLive: false,
@ -223,7 +222,7 @@ export default defineComponent({
dropdownOptions: function () {
const options = [
{
label: this.watched
label: this.historyEntryExists
? this.$t('Video.Remove From History')
: this.$t('Video.Mark As Watched'),
value: 'history'
@ -343,7 +342,7 @@ export default defineComponent({
},
addWatchedStyle: function () {
return this.watched && !this.inHistory
return this.historyEntryExists && !this.inHistory
},
externalPlayer: function () {
@ -576,7 +575,7 @@ export default defineComponent({
}
this.openInExternalPlayer(payload)
if (this.saveWatchedProgress && !this.watched) {
if (this.saveWatchedProgress && !this.historyEntryExists) {
this.markAsWatched()
}
},
@ -584,7 +583,7 @@ export default defineComponent({
handleOptionsClick: function (option) {
switch (option) {
case 'history':
if (this.watched) {
if (this.historyEntryExists) {
this.removeFromWatched()
} else {
this.markAsWatched()
@ -727,8 +726,6 @@ export default defineComponent({
checkIfWatched: function () {
if (this.historyEntryExists) {
this.watched = true
const historyEntry = this.historyEntry
if (this.saveWatchedProgress) {
@ -744,7 +741,6 @@ export default defineComponent({
this.publishedText = ''
}
} else {
this.watched = false
this.watchProgress = 0
}
},
@ -766,8 +762,6 @@ export default defineComponent({
}
this.updateHistory(videoData)
showToast(this.$t('Video.Video has been marked as watched'))
this.watched = true
},
removeFromWatched: function () {
@ -775,7 +769,6 @@ export default defineComponent({
showToast(this.$t('Video.Video has been removed from your history'))
this.watched = false
this.watchProgress = 0
},

View File

@ -3,3 +3,8 @@
.thumbnailLink:hover {
outline: 3px solid var(--side-nav-hover-color);
}
.thumbnailImage {
// Makes img element sized correctly before image loading starts
aspect-ratio: 16/9 auto;
}

View File

@ -104,7 +104,7 @@
{{ $t("Video.Watched") }}
</div>
<div
v-if="watched"
v-if="historyEntryExists"
class="watchedProgressBar"
:style="{inlineSize: progressPercentage + '%'}"
/>
@ -129,12 +129,13 @@
<span v-else-if="channelName !== null">
{{ channelName }}
</span>
<template v-if="!isLive && !isUpcoming && !isPremium && !hideViews && viewCount != null">
<span class="viewCount">
<template v-if="channelId !== null"> </template>
{{ $tc('Global.Counts.View Count', viewCount, {count: parsedViewCount}) }}
</span>
</template>
<span
v-if="!isLive && !isUpcoming && !isPremium && !hideViews && viewCount != null"
class="viewCount"
>
<template v-if="channelId !== null || channelName !== null"> </template>
{{ $tc('Global.Counts.View Count', viewCount, {count: parsedViewCount}) }}
</span>
<span
v-if="uploadedTime !== '' && !isLive && !inHistory"
class="uploadedTime"
@ -151,7 +152,7 @@
<ft-icon-button
class="optionsButton"
:icon="['fas', 'ellipsis-v']"
title="More Options"
:title="$t('Video.More Options')"
theme="base-no-default"
:size="16"
:use-shadow="false"
@ -160,7 +161,7 @@
@click="handleOptionsClick"
/>
<p
v-if="((listType === 'list' || forceListType === 'list') && forceListType !== 'grid') &&
v-if="description && ((listType === 'list' || forceListType === 'list') && forceListType !== 'grid') &&
appearance === 'result'"
class="description"
v-html="description"

View File

@ -38,7 +38,7 @@
>
<ft-playlist-selector
tabindex="0"
:data="playlist"
:playlist="playlist"
:index="index"
:selected="selectedPlaylistIdList.includes(playlist._id)"
@selected="countSelected(playlist._id)"

View File

@ -8,7 +8,7 @@ export default defineComponent({
'ft-icon-button': FtIconButton
},
props: {
data: {
playlist: {
type: Object,
required: true,
},
@ -30,6 +30,8 @@ export default defineComponent({
title: '',
thumbnail: require('../../assets/img/thumbnail_placeholder.svg'),
videoCount: 0,
videoPresenceCountInPlaylistTextShouldBeVisible: false,
}
},
computed: {
@ -39,6 +41,9 @@ export default defineComponent({
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
toBeAddedToPlaylistVideoList: function () {
return this.$store.getters.getToBeAddedToPlaylistVideoList
},
titleForDisplay: function () {
if (typeof this.title !== 'string') { return '' }
@ -46,28 +51,63 @@ export default defineComponent({
return `${this.title.substring(0, 255)}...`
},
loneToBeAddedToPlaylistVideo: function () {
if (this.toBeAddedToPlaylistVideoList.length !== 1) { return null }
return this.toBeAddedToPlaylistVideoList[0]
},
loneVideoPresenceCountInPlaylist() {
if (this.loneToBeAddedToPlaylistVideo == null) { return null }
const v = this.playlist.videos.reduce((accumulator, video) => {
return video.videoId === this.loneToBeAddedToPlaylistVideo.videoId
? accumulator + 1
: accumulator
}, 0)
// Don't display zero value
return v === 0 ? null : v
},
loneVideoPresenceCountInPlaylistText() {
if (this.loneVideoPresenceCountInPlaylist == null) { return null }
return this.$tc('User Playlists.AddVideoPrompt.Added {count} Times', this.loneVideoPresenceCountInPlaylist, {
count: this.loneVideoPresenceCountInPlaylist,
})
},
videoPresenceCountInPlaylistTextVisible() {
if (!this.videoPresenceCountInPlaylistTextShouldBeVisible) { return false }
return this.loneVideoPresenceCountInPlaylistText != null
},
},
created: function () {
this.parseUserData()
},
methods: {
parseUserData: function () {
this.title = this.data.playlistName
if (this.data.videos.length > 0) {
const thumbnailURL = `https://i.ytimg.com/vi/${this.data.videos[0].videoId}/mqdefault.jpg`
this.title = this.playlist.playlistName
if (this.playlist.videos.length > 0) {
const thumbnailURL = `https://i.ytimg.com/vi/${this.playlist.videos[0].videoId}/mqdefault.jpg`
if (this.backendPreference === 'invidious') {
this.thumbnail = thumbnailURL.replace('https://i.ytimg.com', this.currentInvidiousInstance)
} else {
this.thumbnail = thumbnailURL
}
}
this.videoCount = this.data.videos.length
this.videoCount = this.playlist.videos.length
},
toggleSelection: function () {
this.$emit('selected', this.index)
},
onVisibilityChanged(visible) {
if (!visible) { return }
this.videoPresenceCountInPlaylistTextShouldBeVisible = true
},
...mapActions([
'openInExternalPlayer'
])

View File

@ -54,6 +54,10 @@
word-wrap: break-word;
word-break: break-word;
}
.videoPresenceCount {
margin-block-start: 4px;
}
}
&.grid {

View File

@ -29,12 +29,24 @@
</div>
</div>
</div>
<div class="info">
<span
<div
v-observe-visibility="{
callback: onVisibilityChanged,
once: true,
}"
class="info"
>
<div
class="title"
>
{{ titleForDisplay }}
</span>
</div>
<div
v-if="videoPresenceCountInPlaylistTextVisible"
class="videoPresenceCount"
>
{{ loneVideoPresenceCountInPlaylistText }}
</div>
</div>
</div>
</template>

View File

@ -9,18 +9,29 @@ export default defineComponent({
}
},
methods: {
catchTimestampClick: function(event) {
const match = event.detail.match(/(\d+):(\d+):?(\d+)?/)
if (match[3] !== undefined) { // HH:MM:SS
const seconds = 3600 * Number(match[1]) + 60 * Number(match[2]) + Number(match[3])
this.$emit('timestamp-event', seconds)
} else { // MM:SS
const seconds = 60 * Number(match[1]) + Number(match[2])
this.$emit('timestamp-event', seconds)
}
catchTimestampClick: function (event) {
this.$emit('timestamp-event', event.detail)
},
detectTimestamps: function (input) {
return input.replaceAll(/(\d+(:\d+)+)/g, '<a href="#" onclick="this.dispatchEvent(new CustomEvent(\'timestamp-clicked\',{bubbles:true, detail:\'$1\'}))">$1</a>')
const videoId = this.$route.params.id
return input.replaceAll(/(?:(\d+):)?(\d+):(\d+)/g, (timestamp, hours, minutes, seconds) => {
let time = 60 * Number(minutes) + Number(seconds)
if (hours) {
time += 3600 * Number(hours)
}
const url = this.$router.resolve({
path: `/watch/${videoId}`,
query: {
timestamp: time
}
}).href
// Adding the URL lets the user open the video in a new window at this timestamp
return `<a href="${url}" onclick="event.preventDefault();this.dispatchEvent(new CustomEvent('timestamp-clicked',{bubbles:true,detail:${time}}));window.scrollTo(0,0)">${timestamp}</a>`
})
}
}
})

View File

@ -1,6 +1,8 @@
import { defineComponent } from 'vue'
import FtToastEvents from './ft-toast-events.js'
let id = 0
export default defineComponent({
name: 'FtToast',
data: function () {
@ -15,7 +17,9 @@ export default defineComponent({
FtToastEvents.removeEventListener('toast-open', this.open)
},
methods: {
performAction: function (index) {
performAction: function (id) {
const index = this.toasts.findIndex(toast => id === toast.id)
this.toasts[index].action()
this.remove(index)
},
@ -26,7 +30,13 @@ export default defineComponent({
toast.isOpen = false
},
open: function ({ detail: { message, time, action } }) {
const toast = { message: message, action: action || (() => { }), isOpen: false, timeout: null }
const toast = {
message: message,
action: action || (() => { }),
isOpen: false,
timeout: null,
id: id++
}
toast.timeout = setTimeout(this.close, time || 3000, toast)
setTimeout(() => { toast.isOpen = true })
if (this.toasts.length > 4) {

View File

@ -1,15 +1,15 @@
<template>
<div class="toast-holder">
<div
v-for="(toast, index) in toasts"
:key="'toast-' + index"
v-for="toast in toasts"
:key="toast.id"
class="toast"
:class="{ closed: !toast.isOpen, open: toast.isOpen }"
tabindex="0"
role="status"
@click="performAction(index)"
@keydown.enter.prevent="performAction(index)"
@keydown.space.prevent="performAction(index)"
@click="performAction(toast.id)"
@keydown.enter.prevent="performAction(toast.id)"
@keydown.space.prevent="performAction(toast.id)"
>
<p class="message">
{{ toast.message }}

View File

@ -5,9 +5,12 @@ import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
import FtInput from '../ft-input/ft-input.vue'
import FtPrompt from '../ft-prompt/ft-prompt.vue'
import FtButton from '../ft-button/ft-button.vue'
import {
formatNumber,
showToast,
} from '../../helpers/utils'
import debounce from 'lodash.debounce'
export default defineComponent({
name: 'PlaylistInfo',
@ -17,6 +20,7 @@ export default defineComponent({
'ft-icon-button': FtIconButton,
'ft-input': FtInput,
'ft-prompt': FtPrompt,
'ft-button': FtButton,
},
props: {
id: {
@ -82,6 +86,9 @@ export default defineComponent({
},
data: function () {
return {
searchVideoMode: false,
query: '',
updateQueryDebounce: function() {},
editMode: false,
showDeletePlaylistPrompt: false,
showRemoveVideosOnWatchPrompt: false,
@ -145,6 +152,14 @@ export default defineComponent({
return this.firstVideoId !== ''
},
parsedViewCount() {
return formatNumber(this.viewCount)
},
parsedVideoCount() {
return formatNumber(this.videoCount)
},
thumbnail: function () {
if (this.thumbnailPreference === 'hidden' || !this.firstVideoIdExists) {
return require('../../assets/img/thumbnail_placeholder.svg')
@ -223,10 +238,12 @@ export default defineComponent({
created: function () {
this.newTitle = this.title
this.newDescription = this.description
this.updateQueryDebounce = debounce(this.updateQuery, 500)
},
methods: {
toggleCopyVideosPrompt: function (force = false) {
if (this.moreVideoDataAvailable && !force) {
if (this.moreVideoDataAvailable && !this.isUserPlaylist && !force) {
showToast(this.$t('User Playlists.SinglePlaylistView.Toast["Some videos in the playlist are not loaded yet. Click here to copy anyway."]'), 5000, () => {
this.toggleCopyVideosPrompt(true)
})
@ -364,6 +381,30 @@ export default defineComponent({
showToast(this.$t('User Playlists.SinglePlaylistView.Toast.Quick bookmark disabled'))
},
updateQuery(query) {
this.query = query
this.$emit('search-video-query-change', query)
},
enableVideoSearchMode() {
this.searchVideoMode = true
this.$emit('search-video-mode-on')
nextTick(() => {
// Some elements only present after rendering update
this.$refs.searchInput.focus()
})
},
disableVideoSearchMode() {
this.searchVideoMode = false
this.updateQuery('')
this.$emit('search-video-mode-off')
nextTick(() => {
// Some elements only present after rendering update
this.$refs.enableSearchModeButton?.focus()
})
},
...mapActions([
'showAddToPlaylistPromptForManyVideos',
'updatePlaylist',

View File

@ -72,3 +72,20 @@
column-gap: 8px;
justify-content: flex-end;
}
.searchInputsRow {
margin-block-start: 8px;
display: grid;
/* 2 columns */
grid-template-columns: 1fr auto;
column-gap: 8px;
}
@media only screen and (max-width: 1250px) {
:deep(.sharePlaylistIcon .iconDropdown) {
inset-inline-start: auto;
inset-inline-end: auto;
}
}

View File

@ -47,9 +47,9 @@
{{ title }}
</h2>
<p>
{{ videoCount }} {{ $t("Playlist.Videos") }}
{{ $tc('Global.Counts.Video Count', videoCount, {count: parsedVideoCount}) }}
<span v-if="!hideViews && !isUserPlaylist">
- {{ viewCount }} {{ $t("Playlist.Views") }}
- {{ $tc('Global.Counts.View Count', viewCount, {count: parsedViewCount}) }}
</span>
<span>- </span>
<span v-if="infoSource !== 'local'">
@ -76,6 +76,7 @@
<hr>
<div
v-if="!searchVideoMode"
class="channelShareWrapper"
>
<router-link
@ -106,6 +107,14 @@
</div>
<div class="playlistOptions">
<ft-icon-button
v-if="isUserPlaylist && videoCount > 0 && !editMode"
ref="enableSearchModeButton"
:title="$t('User Playlists.SinglePlaylistView.Search for Videos')"
:icon="['fas', 'search']"
theme="secondary"
@click="enableVideoSearchMode"
/>
<ft-icon-button
v-if="editMode"
:title="$t('User Playlists.Save Changes')"
@ -166,6 +175,7 @@
<ft-share-button
v-if="sharePlaylistButtonVisible"
:id="id"
class="sharePlaylistIcon"
:dropdown-position-y="description ? 'top' : 'bottom'"
share-target-type="Playlist"
/>
@ -186,6 +196,28 @@
@click="handleRemoveVideosOnWatchPromptAnswer"
/>
</div>
<div
v-if="isUserPlaylist && searchVideoMode"
class="searchInputsRow"
>
<ft-input
ref="searchInput"
class="searchInput"
:placeholder="$t('User Playlists.SinglePlaylistView.Search for Videos')"
:show-clear-text-button="true"
:show-action-button="false"
@input="(input) => updateQueryDebounce(input)"
@clear="updateQueryDebounce('')"
/>
<ft-icon-button
v-if="isUserPlaylist && searchVideoMode"
:title="$t('User Playlists.Cancel')"
:icon="['fas', 'times']"
theme="secondary"
@click="disableVideoSearchMode"
/>
</div>
</div>
</template>

View File

@ -44,6 +44,7 @@
/>
</div>
<p
v-if="!hideLabelsSideBar"
id="channelLabel"
class="navLabel"
>

View File

@ -121,6 +121,8 @@ export default defineComponent({
this.attemptedFetch = true
this.errorChannels = []
const subscriptionUpdates = []
const postListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let posts = []
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
@ -137,6 +139,32 @@ export default defineComponent({
channelId: channel.id,
posts: posts,
})
if (posts.length > 0) {
const post = posts.find(post => post.authorId === channel.id)
if (post) {
const name = post.author
let thumbnailUrl = post.authorThumbnails?.[0]?.url
if (name || thumbnailUrl) {
if (thumbnailUrl) {
if (thumbnailUrl.startsWith('//')) {
thumbnailUrl = 'https:' + thumbnailUrl
} else if (thumbnailUrl.startsWith(`${this.currentInvidiousInstance}/ggpht`)) {
thumbnailUrl = thumbnailUrl.replace(`${this.currentInvidiousInstance}/ggpht`, 'https://yt3.googleusercontent.com')
}
}
subscriptionUpdates.push({
channelId: channel.id,
channelName: name,
channelThumbnailUrl: thumbnailUrl
})
}
}
}
return posts
}))).flatMap((o) => o)
postList.push(...postListFromRemote)
@ -147,6 +175,8 @@ export default defineComponent({
this.postList = postList
this.isLoading = false
this.updateShowProgressBar(false)
this.batchUpdateSubscriptionDetails(subscriptionUpdates)
},
maybeLoadPostsForSubscriptionsFromRemote: async function () {
@ -200,7 +230,7 @@ export default defineComponent({
copyToClipboard(err)
})
if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
showToast(this.$t('Falling back to Local API'))
resolve(this.getChannelPostsLocal(channel))
} else {
resolve([])
@ -211,6 +241,7 @@ export default defineComponent({
...mapActions([
'updateShowProgressBar',
'batchUpdateSubscriptionDetails',
'updateSubscriptionPostsCacheByChannel',
]),

View File

@ -129,19 +129,23 @@ export default defineComponent({
this.attemptedFetch = true
this.errorChannels = []
const subscriptionUpdates = []
const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let videos = []
let name, thumbnailUrl
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
if (useRss) {
videos = await this.getChannelLiveInvidiousRSS(channel)
({ videos, name, thumbnailUrl } = await this.getChannelLiveInvidiousRSS(channel))
} else {
videos = await this.getChannelLiveInvidious(channel)
({ videos, name, thumbnailUrl } = await this.getChannelLiveInvidious(channel))
}
} else {
if (useRss) {
videos = await this.getChannelLiveLocalRSS(channel)
({ videos, name, thumbnailUrl } = await this.getChannelLiveLocalRSS(channel))
} else {
videos = await this.getChannelLiveLocal(channel)
({ videos, name, thumbnailUrl } = await this.getChannelLiveLocal(channel))
}
}
@ -152,6 +156,15 @@ export default defineComponent({
channelId: channel.id,
videos: videos,
})
if (name || thumbnailUrl) {
subscriptionUpdates.push({
channelId: channel.id,
channelName: name,
channelThumbnailUrl: thumbnailUrl
})
}
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
@ -159,6 +172,8 @@ export default defineComponent({
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
this.updateShowProgressBar(false)
this.batchUpdateSubscriptionDetails(subscriptionUpdates)
},
maybeLoadVideosForSubscriptionsFromRemote: async function () {
@ -174,16 +189,18 @@ export default defineComponent({
getChannelLiveLocal: async function (channel, failedAttempts = 0) {
try {
const entries = await getLocalChannelLiveStreams(channel.id)
const result = await getLocalChannelLiveStreams(channel.id)
if (entries === null) {
if (result === null) {
this.errorChannels.push(channel)
return []
return {
videos: []
}
}
addPublishedDatesLocal(entries)
addPublishedDatesLocal(result.videos)
return entries
return result
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
@ -198,12 +215,16 @@ export default defineComponent({
showToast(this.$t('Falling back to Invidious API'))
return await this.getChannelLiveInvidious(channel, failedAttempts + 1)
} else {
return []
return {
videos: []
}
}
case 2:
return await this.getChannelLiveLocalRSS(channel, failedAttempts + 1)
default:
return []
return {
videos: []
}
}
}
},
@ -227,7 +248,9 @@ export default defineComponent({
this.errorChannels.push(channel)
}
return []
return {
videos: []
}
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
@ -245,12 +268,16 @@ export default defineComponent({
showToast(this.$t('Falling back to Invidious API'))
return this.getChannelLiveInvidiousRSS(channel, failedAttempts + 1)
} else {
return []
return {
videos: []
}
}
case 2:
return this.getChannelLiveLocal(channel, failedAttempts + 1)
default:
return []
return {
videos: []
}
}
}
},
@ -269,7 +296,16 @@ export default defineComponent({
addPublishedDatesInvidious(videos)
resolve(videos)
let name
if (videos.length > 0) {
name = videos.find(video => video.author).author
}
resolve({
name,
videos
})
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
@ -282,17 +318,21 @@ export default defineComponent({
break
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
showToast(this.$t('Falling back to Local API'))
resolve(this.getChannelLiveLocal(channel, failedAttempts + 1))
} else {
resolve([])
resolve({
videos: []
})
}
break
case 2:
resolve(this.getChannelLiveInvidiousRSS(channel, failedAttempts + 1))
break
default:
resolve([])
resolve({
videos: []
})
}
})
})
@ -317,7 +357,9 @@ export default defineComponent({
this.errorChannels.push(channel)
}
return []
return {
videos: []
}
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
@ -332,20 +374,25 @@ export default defineComponent({
return this.getChannelLiveInvidious(channel, failedAttempts + 1)
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
showToast(this.$t('Falling back to Local API'))
return this.getChannelLiveLocalRSS(channel, failedAttempts + 1)
} else {
return []
return {
videos: []
}
}
case 2:
return this.getChannelLiveInvidious(channel, failedAttempts + 1)
default:
return []
return {
videos: []
}
}
}
},
...mapActions([
'batchUpdateSubscriptionDetails',
'updateShowProgressBar',
'updateSubscriptionLiveCacheByChannel',
]),

View File

@ -114,12 +114,16 @@ export default defineComponent({
this.attemptedFetch = true
this.errorChannels = []
const subscriptionUpdates = []
const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let videos = []
let name
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
videos = await this.getChannelShortsInvidious(channel)
({ videos, name } = await this.getChannelShortsInvidious(channel))
} else {
videos = await this.getChannelShortsLocal(channel)
({ videos, name } = await this.getChannelShortsLocal(channel))
}
channelCount++
@ -129,6 +133,14 @@ export default defineComponent({
channelId: channel.id,
videos: videos,
})
if (name) {
subscriptionUpdates.push({
channelId: channel.id,
channelName: name
})
}
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
@ -136,6 +148,8 @@ export default defineComponent({
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
this.updateShowProgressBar(false)
this.batchUpdateSubscriptionDetails(subscriptionUpdates)
},
maybeLoadVideosForSubscriptionsFromRemote: async function () {
@ -168,7 +182,9 @@ export default defineComponent({
this.errorChannels.push(channel)
}
return []
return {
videos: []
}
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
@ -184,10 +200,14 @@ export default defineComponent({
showToast(this.$t('Falling back to Invidious API'))
return this.getChannelShortsInvidious(channel, failedAttempts + 1)
} else {
return []
return {
videos: []
}
}
default:
return []
return {
videos: []
}
}
}
},
@ -211,7 +231,9 @@ export default defineComponent({
this.errorChannels.push(channel)
}
return []
return {
videos: []
}
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
@ -224,18 +246,23 @@ export default defineComponent({
switch (failedAttempts) {
case 0:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
showToast(this.$t('Falling back to Local API'))
return this.getChannelShortsLocal(channel, failedAttempts + 1)
} else {
return []
return {
videos: []
}
}
default:
return []
return {
videos: []
}
}
}
},
...mapActions([
'batchUpdateSubscriptionDetails',
'updateShowProgressBar',
'updateSubscriptionShortsCacheByChannel',
]),

View File

@ -108,7 +108,7 @@ export default defineComponent({
case 'r':
case 'R':
case 'F5':
if (!this.isLoading) {
if (!this.isLoading && this.activeSubscriptionList.length > 0) {
this.$emit('refresh')
}
break

View File

@ -56,7 +56,7 @@
/>
</ft-flex-box>
<ft-icon-button
v-if="!isLoading"
v-if="!isLoading && activeSubscriptionList.length > 0"
:icon="['fas', 'sync']"
class="floatingTopButton"
:title="$t('Subscriptions.Refresh Subscriptions')"

View File

@ -129,19 +129,23 @@ export default defineComponent({
this.attemptedFetch = true
this.errorChannels = []
const subscriptionUpdates = []
const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let videos = []
let name, thumbnailUrl
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
if (useRss) {
videos = await this.getChannelVideosInvidiousRSS(channel)
({ videos, name, thumbnailUrl } = await this.getChannelVideosInvidiousRSS(channel))
} else {
videos = await this.getChannelVideosInvidiousScraper(channel)
({ videos, name, thumbnailUrl } = await this.getChannelVideosInvidiousScraper(channel))
}
} else {
if (useRss) {
videos = await this.getChannelVideosLocalRSS(channel)
({ videos, name, thumbnailUrl } = await this.getChannelVideosLocalRSS(channel))
} else {
videos = await this.getChannelVideosLocalScraper(channel)
({ videos, name, thumbnailUrl } = await this.getChannelVideosLocalScraper(channel))
}
}
@ -152,6 +156,15 @@ export default defineComponent({
channelId: channel.id,
videos: videos,
})
if (name || thumbnailUrl) {
subscriptionUpdates.push({
channelId: channel.id,
channelName: name,
channelThumbnailUrl: thumbnailUrl
})
}
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
@ -159,6 +172,8 @@ export default defineComponent({
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
this.updateShowProgressBar(false)
this.batchUpdateSubscriptionDetails(subscriptionUpdates)
},
maybeLoadVideosForSubscriptionsFromRemote: async function () {
@ -174,16 +189,18 @@ export default defineComponent({
getChannelVideosLocalScraper: async function (channel, failedAttempts = 0) {
try {
const videos = await getLocalChannelVideos(channel.id)
const result = await getLocalChannelVideos(channel.id)
if (videos === null) {
if (result === null) {
this.errorChannels.push(channel)
return []
return {
videos: []
}
}
addPublishedDatesLocal(videos)
addPublishedDatesLocal(result.videos)
return videos
return result
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
@ -198,12 +215,16 @@ export default defineComponent({
showToast(this.$t('Falling back to Invidious API'))
return await this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
} else {
return []
return {
videos: []
}
}
case 2:
return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
default:
return []
return {
videos: []
}
}
}
},
@ -227,7 +248,9 @@ export default defineComponent({
this.errorChannels.push(channel)
}
return []
return {
videos: []
}
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
@ -245,12 +268,16 @@ export default defineComponent({
showToast(this.$t('Falling back to Invidious API'))
return this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1)
} else {
return []
return {
videos: []
}
}
case 2:
return this.getChannelVideosLocalScraper(channel, failedAttempts + 1)
default:
return []
return {
videos: []
}
}
}
},
@ -266,7 +293,16 @@ export default defineComponent({
invidiousAPICall(subscriptionsPayload).then((result) => {
addPublishedDatesInvidious(result.videos)
resolve(result.videos)
let name
if (result.videos.length > 0) {
name = result.videos.find(video => video.type === 'video' && video.author).author
}
resolve({
name,
videos: result.videos
})
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
@ -279,17 +315,21 @@ export default defineComponent({
break
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
showToast(this.$t('Falling back to Local API'))
resolve(this.getChannelVideosLocalScraper(channel, failedAttempts + 1))
} else {
resolve([])
resolve({
videos: []
})
}
break
case 2:
resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1))
break
default:
resolve([])
resolve({
videos: []
})
}
})
})
@ -314,7 +354,9 @@ export default defineComponent({
this.errorChannels.push(channel)
}
return []
return {
videos: []
}
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
@ -329,20 +371,25 @@ export default defineComponent({
return this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
showToast(this.$t('Falling back to Local API'))
return this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
} else {
return []
return {
videos: []
}
}
case 2:
return this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
default:
return []
return {
videos: []
}
}
}
},
...mapActions([
'batchUpdateSubscriptionDetails',
'updateShowProgressBar',
'updateSubscriptionVideosCacheByChannel',
]),

View File

@ -37,7 +37,8 @@ export default defineComponent({
'dracula',
'catppuccinMocha',
'pastelPink',
'hotPink'
'hotPink',
'nordic'
]
}
},
@ -102,7 +103,8 @@ export default defineComponent({
this.$t('Settings.Theme Settings.Base Theme.Dracula'),
this.$t('Settings.Theme Settings.Base Theme.Catppuccin Mocha'),
this.$t('Settings.Theme Settings.Base Theme.Pastel Pink'),
this.$t('Settings.Theme Settings.Base Theme.Hot Pink')
this.$t('Settings.Theme Settings.Base Theme.Hot Pink'),
this.$t('Settings.Theme Settings.Base Theme.Nordic')
]
},

View File

@ -54,7 +54,6 @@
.chapterThumbnail {
grid-area: thumbnail;
inline-size: 130px;
block-size: auto;
margin: 3px;
}

View File

@ -49,6 +49,7 @@ export default defineComponent({
changeChapter: function(index) {
this.currentIndex = index
this.$emit('timestamp-event', this.chapters[index].startSeconds)
window.scrollTo(0, 0)
},
navigateChapters(direction) {

View File

@ -46,13 +46,15 @@
@keydown.space.stop.prevent="changeChapter(index)"
@keydown.enter.stop.prevent="changeChapter(index)"
>
<!-- Setting the aspect ratio avoids layout shifts when the images load -->
<img
v-if="!compact"
alt=""
aria-hidden="true"
class="chapterThumbnail"
loading="lazy"
:src="chapter.thumbnail"
:src="chapter.thumbnail.url"
:style="{ aspectRatio: chapter.thumbnail.width / chapter.thumbnail.height }"
>
<div class="chapterTimestamp">
{{ chapter.timestamp }}

View File

@ -293,7 +293,7 @@ export default defineComponent({
copyToClipboard(err)
})
if (process.env.IS_ELECTRON && this.backendFallback && this.backendPreference === 'invidious') {
showToast(this.$t('Falling back to local API'))
showToast(this.$t('Falling back to Local API'))
this.getCommentDataLocal()
} else {
this.isLoading = false

View File

@ -268,7 +268,7 @@
v-else-if="showComments && !isLoading"
>
<h3 class="noCommentMsg">
{{ $t("There are no comments available for this video") }}
{{ $t("Comments.There are no comments available for this video") }}
</h3>
</div>
<h4

View File

@ -1,27 +1,30 @@
.watchVideoInfo {
column-gap: 15px;
display: grid;
grid-template-columns: auto minmax(min-content, 1fr);
display: flex;
flex-direction: column;
padding: 16px;
@media screen and (max-width: 680px) {
grid-template-columns: auto;
}
gap: 8px;
}
.videoTitle {
display: block;
font-size: 22px;
font-weight: normal;
margin-block: 0 24px;
margin-block: 0;
margin-inline: 0;
margin-block-end: 1em;
word-break: break-word;
}
.channelInformation {
.videoMetrics, .videoButtons {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.videoButtons {
.profileRow {
display: flex;
align-items: center;
}
.channelThumbnail {
@ -36,6 +39,7 @@
cursor: pointer;
display: block;
margin-inline-start: 6px;
margin-block-end: 3px;
position: relative;
text-decoration: inherit;
inset-block-start: -2px;
@ -44,77 +48,80 @@
.subscribeButton {
font-size: 14px;
margin-inline-start: 6px;
margin-block-start: 6px;
margin-block: 0;
padding: 6px;
}
}
.viewCount,
.datePublished {
color: var(--secondary-text-color);
font-size: 15px;
text-align: end;
.videoOptions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
@media screen and (max-width: 680px) {
text-align: start;
}
}
@media screen and (max-width: 680px) {
:deep(.iconDropdown) {
inset-inline-start: auto;
inset-inline-end: calc(50% - 20px);
}
}
.viewCount {
margin-block: 18px 0;
margin-inline: 0;
}
.datePublished {
margin-block: 4px 0;
margin-inline: 0;
@media screen and (max-width: 680px) {
margin-block-start: 16px;
}
}
.likeSection {
color: var(--tertiary-text-color);
display: flex;
flex-direction: column;
font-size: 16px;
margin-inline-start: auto;
margin-block-start: 4px;
max-inline-size: 210px;
text-align: end;
@media screen and (max-width: 680px) {
margin-inline-start: 0;
text-align: start;
}
.likeBar {
border-radius: 4px;
block-size: 8px;
margin-block-end: 4px;
}
.likeCount {
margin-inline-end: 0;
}
}
.videoOptions {
display: flex;
justify-content: flex-end;
margin-block-start: 16px;
.option:not(:first-child) {
margin-inline-start: 4px;
}
@media screen and (max-width: 680px) {
justify-content: flex-start;
:deep(.iconDropdown) {
inset-inline-start: calc(50% - 20px);
inset-inline-end: auto;
@media screen and (max-width: 460px) {
flex-wrap: nowrap;
:deep(.iconDropdown) {
inset-inline-start: auto;
inset-inline-end: auto;
}
}
}
@media screen and (max-width: 460px) {
flex-direction: column;
align-items: flex-start;
margin-block-start: 10px;
}
}
.videoMetrics {
font-size: 14px;
color: var(--tertiary-text-color);
.likeSection {
color: var(--tertiary-text-color);
display: flex;
flex-direction: column;
margin-inline-start: auto;
margin-block-start: 4px;
max-inline-size: 210px;
text-align: end;
@media screen and (max-width: 680px) {
margin-inline-start: 0;
text-align: start;
}
.likeBar {
border-radius: 4px;
block-size: 8px;
margin-block-end: 4px;
}
.likeCount {
margin-inline-end: 0;
display: flex;
gap: 3px;
}
}
.datePublishedAndViewCount {
@media only screen and (max-width: 460px) {
display: flex;
justify-content: left;
flex-direction: column;
.seperator {
display: none;
}
}
}
.videoViews {
white-space: nowrap;
}
}

View File

@ -6,47 +6,15 @@
>
{{ title }}
</h1>
<div
class="channelInformation"
>
<div
class="profileRow"
>
<div>
<router-link
:to="`/channel/${channelId}`"
>
<img
:src="channelThumbnail"
class="channelThumbnail"
alt=""
>
</router-link>
</div>
<div>
<router-link
:to="`/channel/${channelId}`"
class="channelName"
>
{{ channelName }}
</router-link>
<ft-subscribe-button
v-if="!hideUnsubscribeButton"
:channel-id="channelId"
:channel-name="channelName"
:channel-thumbnail="channelThumbnail"
:subscription-count-text="subscriptionCountText"
/>
</div>
</div>
</div>
</div>
<div>
<div class="datePublished">
<div class="videoMetrics">
<div class="datePublishedAndViewCount">
{{ publishedString }} {{ dateString }}
</div>
<div class="viewCount">
{{ parsedViewCount }}
<template
v-if="!hideVideoViews"
>
<span class="seperator"> </span><span class="videoViews">{{ parsedViewCount }}</span>
</template>
</div>
<div
v-if="!hideVideoLikesAndDislikes"
@ -55,9 +23,7 @@
<div
class="likeSection"
>
<div>
<span class="likeCount"><font-awesome-icon :icon="['fas', 'thumbs-up']" /> {{ parsedLikeCount }}</span>
</div>
<span class="likeCount"><font-awesome-icon :icon="['fas', 'thumbs-up']" /> {{ parsedLikeCount }}</span>
</div>
</div>
<!--
@ -80,6 +46,38 @@
</div>
</div>
-->
</div>
<div class="videoButtons">
<div
class="profileRow"
>
<div>
<router-link
:to="`/channel/${channelId}`"
>
<img
:src="channelThumbnail"
class="channelThumbnail"
alt=""
>
</router-link>
</div>
<div>
<router-link
:to="`/channel/${channelId}`"
class="channelName"
>
{{ channelName }}
</router-link>
<ft-subscribe-button
v-if="!hideUnsubscribeButton"
:channel-id="channelId"
:channel-name="channelName"
:channel-thumbnail="channelThumbnail"
:subscription-count-text="subscriptionCountText"
/>
</div>
</div>
<div class="videoOptions">
<ft-icon-button
v-if="showPlaylists && !isUpcoming"

View File

@ -85,19 +85,6 @@
transition: background 0.2s ease-in;
}
.videoIndexContainer {
text-align: center;
}
.videoIndex {
color: var(--tertiary-text-color);
}
.videoIndexIcon {
font-size: 14px;
color: var(--tertiary-text-color);
}
.videoInfo {
margin-inline-start: 30px;
position: relative;

View File

@ -2,7 +2,7 @@ import { defineComponent, nextTick } from 'vue'
import { mapMutations } from 'vuex'
import FtLoader from '../ft-loader/ft-loader.vue'
import FtCard from '../ft-card/ft-card.vue'
import FtListVideoLazy from '../ft-list-video-lazy/ft-list-video-lazy.vue'
import FtListVideoNumbered from '../ft-list-video-numbered/ft-list-video-numbered.vue'
import { copyToClipboard, showToast } from '../../helpers/utils'
import {
getLocalPlaylist,
@ -16,7 +16,7 @@ export default defineComponent({
components: {
'ft-loader': FtLoader,
'ft-card': FtCard,
'ft-list-video-lazy': FtListVideoLazy,
'ft-list-video-numbered': FtListVideoNumbered
},
props: {
playlistId: {
@ -302,7 +302,8 @@ export default defineComponent({
playNextVideo: function () {
const playlistInfo = {
playlistId: this.playlistId
playlistId: this.playlistId,
playlistType: this.playlistType,
}
const videoIndex = this.videoIndexInPlaylistItems
@ -349,7 +350,8 @@ export default defineComponent({
showToast(this.$t('Playing Previous Video'))
const playlistInfo = {
playlistId: this.playlistId
playlistId: this.playlistId,
playlistType: this.playlistType,
}
const videoIndex = this.videoIndexInPlaylistItems
@ -471,14 +473,13 @@ export default defineComponent({
parseUserPlaylist: function (playlist, { allowPlayingVideoRemoval = true } = {}) {
this.playlistTitle = playlist.playlistName
this.channelName = ''
this.channelThumbnail = ''
this.channelId = ''
if (this.playlistItems.length === 0 || allowPlayingVideoRemoval) {
this.playlistItems = playlist.videos
} else {
// `this.currentVideo` relies on `playlistItems`
const latestPlaylistContainsCurrentVideo = playlist.videos.find(v => v.playlistItemId === this.playlistItemId) != null
const latestPlaylistContainsCurrentVideo = playlist.videos.some(v => v.playlistItemId === this.playlistItemId)
// Only update list of videos if latest video list still contains currently playing video
if (latestPlaylistContainsCurrentVideo) {
this.playlistItems = playlist.videos
@ -511,7 +512,7 @@ export default defineComponent({
const currentVideoItem = (this.$refs.currentVideoItem || [])[0]
if (container != null && currentVideoItem != null) {
// Watch view can be ready sooner than this component
container.scrollTop = currentVideoItem.offsetTop - container.offsetTop
container.scrollTop = currentVideoItem.$el.offsetTop - container.offsetTop
}
},

View File

@ -119,41 +119,25 @@
ref="playlistItems"
class="playlistItems"
>
<div
<ft-list-video-numbered
v-for="(item, index) in playlistItems"
:key="item.playlistItemId || item.videoId"
:ref="currentVideoIndexZeroBased === index ? 'currentVideoItem' : null"
class="playlistItem"
>
<div class="videoIndexContainer">
<font-awesome-icon
v-if="currentVideoIndexZeroBased === index"
class="videoIndexIcon"
:icon="['fas', 'play']"
/>
<p
v-else
class="videoIndex"
>
{{ index + 1 }}
</p>
</div>
<ft-list-video-lazy
:data="item"
:playlist-id="playlistId"
:playlist-type="playlistType"
:playlist-index="reversePlaylist ? playlistItems.length - index - 1 : index"
:playlist-item-id="item.playlistItemId"
:playlist-reverse="reversePlaylist"
:playlist-shuffle="shuffleEnabled"
:playlist-loop="loopEnabled"
:hide-forbidden-titles="false"
appearance="watchPlaylistItem"
force-list-type="list"
:initial-visible-state="index < (currentVideoIndexZeroBased + 4) && index > (currentVideoIndexZeroBased - 4)"
@pause-player="$emit('pause-player')"
/>
</div>
:data="item"
:playlist-id="playlistId"
:playlist-type="playlistType"
:playlist-index="reversePlaylist ? playlistItems.length - index - 1 : index"
:playlist-item-id="item.playlistItemId"
:playlist-reverse="reversePlaylist"
:playlist-shuffle="shuffleEnabled"
:playlist-loop="loopEnabled"
:video-index="index"
:is-current-video="currentVideoIndexZeroBased === index"
appearance="watchPlaylistItem"
:initial-visible-state="index < (currentVideoIndexZeroBased + 4) && index > (currentVideoIndexZeroBased - 4)"
@pause-player="$emit('pause-player')"
/>
</div>
</div>
</ft-card>

View File

@ -367,7 +367,7 @@ export async function generateInvidiousDashManifestLocally(formats) {
return await FormatUtils.toDash({
adaptive_formats: formats
}, urlTransformer, undefined, undefined, player)
}, false, urlTransformer, undefined, undefined, player)
}
export function convertInvidiousToLocalFormat(format) {

View File

@ -35,23 +35,23 @@ const TRACKING_PARAM_NAMES = [
* @param {boolean} options.generateSessionLocally generate the session locally or let YouTube generate it (local is faster, remote is more accurate)
* @returns the Innertube instance
*/
async function createInnertube(options = { withPlayer: false, location: undefined, safetyMode: false, clientType: undefined, generateSessionLocally: true }) {
async function createInnertube({ withPlayer = false, location = undefined, safetyMode = false, clientType = undefined, generateSessionLocally = true } = {}) {
let cache
if (options.withPlayer) {
if (withPlayer) {
const userData = await getUserDataPath()
cache = new PlayerCache(join(userData, 'player_cache'))
}
return await Innertube.create({
retrieve_player: !!options.withPlayer,
location: options.location,
enable_safety_mode: !!options.safetyMode,
client_type: options.clientType,
retrieve_player: !!withPlayer,
location: location,
enable_safety_mode: !!safetyMode,
client_type: clientType,
// use browser fetch
fetch: (input, init) => fetch(input, init),
cache,
generate_session_locally: !!options.generateSessionLocally
generate_session_locally: !!generateSessionLocally
})
}
@ -274,6 +274,9 @@ export async function getLocalChannel(id) {
return result
}
/**
* @param {string} id
*/
export async function getLocalChannelVideos(id) {
const innertube = await createInnertube()
@ -286,13 +289,22 @@ export async function getLocalChannelVideos(id) {
}))
const videosTab = new YT.Channel(null, response)
const { id: channelId = id, name, thumbnailUrl } = parseLocalChannelHeader(videosTab)
let videos
// if the channel doesn't have a videos tab, YouTube returns the home tab instead
// so we need to check that we got the right tab
if (videosTab.current_tab?.endpoint.metadata.url?.endsWith('/videos')) {
return parseLocalChannelVideos(videosTab.videos, videosTab.header.author)
videos = parseLocalChannelVideos(videosTab.videos, channelId, name)
} else {
return []
videos = []
}
return {
name,
thumbnailUrl,
videos
}
} catch (error) {
console.error(error)
@ -304,6 +316,9 @@ export async function getLocalChannelVideos(id) {
}
}
/**
* @param {string} id
*/
export async function getLocalChannelLiveStreams(id) {
const innertube = await createInnertube()
@ -316,13 +331,22 @@ export async function getLocalChannelLiveStreams(id) {
}))
const liveStreamsTab = new YT.Channel(null, response)
const { id: channelId = id, name, thumbnailUrl } = parseLocalChannelHeader(liveStreamsTab)
let videos
// if the channel doesn't have a live tab, YouTube returns the home tab instead
// so we need to check that we got the right tab
if (liveStreamsTab.current_tab?.endpoint.metadata.url?.endsWith('/streams')) {
return parseLocalChannelVideos(liveStreamsTab.videos, liveStreamsTab.header.author)
videos = parseLocalChannelVideos(liveStreamsTab.videos, channelId, name)
} else {
return []
videos = []
}
return {
name,
thumbnailUrl,
videos
}
} catch (error) {
console.error(error)
@ -365,16 +389,158 @@ export async function getLocalChannelCommunity(id) {
}
/**
* @param {import('youtubei.js').YTNodes.Video[]} videos
* @param {Misc.Author} author
* @param {YT.Channel} channel
*/
export function parseLocalChannelVideos(videos, author) {
export function parseLocalChannelHeader(channel) {
/** @type {string=} */
let id
/** @type {string} */
let name
/** @type {string=} */
let thumbnailUrl
/** @type {string=} */
let bannerUrl
/** @type {string=} */
let subscriberText
/** @type {string[]} */
const tags = []
switch (channel.header.type) {
case 'C4TabbedHeader': {
// example: Linus Tech Tips
// https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
/**
* @type {import('youtubei.js').YTNodes.C4TabbedHeader}
*/
const header = channel.header
id = header.author.id
name = header.author.name
thumbnailUrl = header.author.best_thumbnail.url
bannerUrl = header.banner?.[0]?.url
subscriberText = header.subscribers?.text
break
}
case 'CarouselHeader': {
// examples: Music and YouTube Gaming
// https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
/**
* @type {import('youtubei.js').YTNodes.CarouselHeader}
*/
const header = channel.header
/**
* @type {import('youtubei.js').YTNodes.TopicChannelDetails}
*/
const topicChannelDetails = header.contents.find(node => node.type === 'TopicChannelDetails')
name = topicChannelDetails.title.text
subscriberText = topicChannelDetails.subtitle.text
thumbnailUrl = topicChannelDetails.avatar[0].url
if (channel.metadata.external_id) {
id = channel.metadata.external_id
} else {
id = topicChannelDetails.subscribe_button.channel_id
}
break
}
case 'InteractiveTabbedHeader': {
// example: Minecraft - Topic
// https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg
/**
* @type {import('youtubei.js').YTNodes.InteractiveTabbedHeader}
*/
const header = channel.header
name = header.title.text
thumbnailUrl = header.box_art.at(-1).url
bannerUrl = header.banner[0]?.url
const badges = header.badges.map(badge => badge.label).filter(tag => tag)
tags.push(...badges)
id = channel.current_tab?.endpoint.payload.browseId
break
}
case 'PageHeader': {
// example: YouTube Gaming
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
// User channels (an A/B test at the time of writing)
/**
* @type {import('youtubei.js').YTNodes.PageHeader}
*/
const header = channel.header
name = header.content.title.text.text
if (header.content.image) {
if (header.content.image.type === 'ContentPreviewImageView') {
/** @type {import('youtubei.js').YTNodes.ContentPreviewImageView} */
const image = header.content.image
thumbnailUrl = image.image[0].url
} else {
/** @type {import('youtubei.js').YTNodes.DecoratedAvatarView} */
const image = header.content.image
thumbnailUrl = image.avatar?.image[0].url
}
}
if (!thumbnailUrl && channel.metadata.thumbnail) {
thumbnailUrl = channel.metadata.thumbnail[0].url
}
if (header.content.banner) {
bannerUrl = header.content.banner.image[0]?.url
}
if (header.content.actions) {
const modal = header.content.actions.actions_rows[0].actions[0].on_tap.modal
if (modal && modal.type === 'ModalWithTitleAndButton') {
/** @type {import('youtubei.js').YTNodes.ModalWithTitleAndButton} */
const typedModal = modal
id = typedModal.button.endpoint.next_endpoint?.payload.browseId
}
} else if (channel.metadata.external_id) {
id = channel.metadata.external_id
}
if (header.content.metadata) {
subscriberText = header.content.metadata.metadata_rows[0].metadata_parts[1].text.text
}
break
}
}
return {
id,
name,
thumbnailUrl,
bannerUrl,
subscriberText,
tags
}
}
/**
* @param {import('youtubei.js').YTNodes.Video[]} videos
* @param {string} channelId
* @param {string} channelName
*/
export function parseLocalChannelVideos(videos, channelId, channelName) {
const parsedVideos = videos.map(parseLocalListVideo)
// fix empty author info
parsedVideos.forEach(video => {
video.author = author.name
video.authorId = author.id
video.author = channelName
video.authorId = channelId
})
return parsedVideos
@ -382,17 +548,18 @@ export function parseLocalChannelVideos(videos, author) {
/**
* @param {import('youtubei.js').YTNodes.ReelItem[]} shorts
* @param {Misc.Author} author
* @param {string} channelId
* @param {string} channelName
*/
export function parseLocalChannelShorts(shorts, author) {
export function parseLocalChannelShorts(shorts, channelId, channelName) {
return shorts.map(short => {
return {
type: 'video',
videoId: short.id,
title: short.title.text,
author: author.name,
authorId: author.id,
viewCount: parseLocalSubscriberCount(short.views.text),
author: channelName,
authorId: channelId,
viewCount: short.views.isEmpty() ? null : parseLocalSubscriberCount(short.views.text),
lengthSeconds: ''
}
})
@ -405,40 +572,43 @@ export function parseLocalChannelShorts(shorts, author) {
/**
* @param {Playlist|GridPlaylist} playlist
* @param {Misc.Author} author
* @param {string} channelId
* @param {string} chanelName
*/
export function parseLocalListPlaylist(playlist, author = undefined) {
let channelName
let channelId = null
/** @type {import('youtubei.js').YTNodes.PlaylistVideoThumbnail} */
const thumbnailRenderer = playlist.thumbnail_renderer
export function parseLocalListPlaylist(playlist, channelId = undefined, channelName = undefined) {
let internalChannelName
let internalChannelId = null
if (playlist.author && playlist.author.id !== 'N/A') {
if (playlist.author instanceof Misc.Text) {
channelName = playlist.author.text
internalChannelName = playlist.author.text
if (author) {
channelId = author.id
if (channelId) {
internalChannelId = channelId
}
} else {
channelName = playlist.author.name
channelId = playlist.author.id
internalChannelName = playlist.author.name
internalChannelId = playlist.author.id
}
} else if (author) {
channelName = author.name
channelId = author.id
} else if (channelId || channelName) {
internalChannelName = channelName
internalChannelId = channelId
} else if (playlist.author?.name) {
// auto-generated album playlists don't have an author
// so in search results, the author text is "Playlist" and doesn't have a link or channel ID
channelName = playlist.author.name
internalChannelName = playlist.author.name
}
/** @type {import('youtubei.js').YTNodes.PlaylistVideoThumbnail} */
const thumbnailRenderer = playlist.thumbnail_renderer
return {
type: 'playlist',
dataSource: 'local',
title: playlist.title.text,
thumbnail: thumbnailRenderer ? thumbnailRenderer.thumbnail[0].url : playlist.thumbnails[0].url,
channelName,
channelId,
channelName: internalChannelName,
channelId: internalChannelId,
playlistId: playlist.id,
videoCount: extractNumberFromString(playlist.video_count.text)
}
@ -742,7 +912,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16, options = { looseChanne
case 'WEB_PAGE_TYPE_CHANNEL': {
const trimmedText = text.trim()
// In comments, mention can be `@Channel Name` (not handle, but name)
if (CHANNEL_HANDLE_REGEX.test(trimmedText) || (options.looseChannelNameDetection && trimmedText.startsWith('@'))) {
if (CHANNEL_HANDLE_REGEX.test(trimmedText) || options.looseChannelNameDetection) {
// Note that in regex `\s` must be used since the text contain non-default space (the half-width space char when we press spacebar)
const spacesBefore = (spacesBeforeRegex.exec(text) || [''])[0]
const spacesAfter = (spacesAfterRegex.exec(text) || [''])[0]

View File

@ -80,9 +80,14 @@ export async function parseYouTubeRSSFeed(rssString, channelId) {
promises.push(parseRSSEntry(entry, channelId, channelName))
}
return await Promise.all(promises)
return {
name: channelName,
videos: await Promise.all(promises)
}
} catch (e) {
return []
return {
videos: []
}
}
}

View File

@ -616,9 +616,10 @@ export function getVideoParamsFromUrl(url) {
/**
* This will match sequences of upper case characters and convert them into title cased words.
* This will also match excessive strings of punctionation and convert them to one representative character
* @param {string} title the title to process
* @param {number} minUpperCase the minimum number of consecutive upper case characters to match
* @returns {string} the title with upper case characters removed
* @returns {string} the title with upper case characters removed and punctuation normalized
*/
export function toDistractionFreeTitle(title, minUpperCase = 3) {
const firstValidCharIndex = (word) => {
@ -634,7 +635,10 @@ export function toDistractionFreeTitle(title, minUpperCase = 3) {
}
const reg = RegExp(`[\\p{Lu}|']{${minUpperCase},}`, 'ug')
return title.replace(reg, x => capitalizedWord(x.toLowerCase()))
return title
.replaceAll(/!{2,}/g, '!')
.replaceAll(/[!?]{2,}/g, '?')
.replace(reg, x => capitalizedWord(x.toLowerCase()))
}
export function formatNumber(number, options = undefined) {

View File

@ -1,5 +1,4 @@
import fs from 'fs/promises'
import { pathExists } from '../../helpers/filesystem'
import { createWebURL, fetchWithTimeout } from '../../helpers/utils'
const state = {
@ -40,6 +39,7 @@ const actions = {
console.error(err)
}
}
// If the invidious instance fetch isn't returning anything interpretable
if (instances.length === 0) {
// Fallback: read from static file
@ -47,15 +47,13 @@ const actions = {
/* eslint-disable-next-line n/no-path-concat */
const fileLocation = process.env.NODE_ENV === 'development' ? './static/' : `${__dirname}/static/`
const filePath = `${fileLocation}${fileName}`
if (!process.env.IS_ELECTRON || await pathExists(filePath)) {
console.warn('reading static file for invidious instances')
const fileData = process.env.IS_ELECTRON ? await fs.readFile(filePath, 'utf8') : await (await fetch(createWebURL(filePath))).text()
instances = JSON.parse(fileData).filter(e => {
return process.env.IS_ELECTRON || e.cors
}).map(e => {
return e.url
})
}
console.warn('reading static file for invidious instances')
const fileData = process.env.IS_ELECTRON ? await fs.readFile(filePath, 'utf8') : await (await fetch(createWebURL(filePath))).text()
instances = JSON.parse(fileData).filter(e => {
return process.env.IS_ELECTRON || e.cors
}).map(e => {
return e.url
})
}
commit('setInvidiousInstancesList', instances)
},

View File

@ -63,10 +63,13 @@ const actions = {
payload.createdAt = Date.now()
payload.lastUpdatedAt = Date.now()
// Ensure all videos has required attributes
const currentTime = new Date().getTime()
if (Array.isArray(payload.videos)) {
payload.videos.forEach(videoData => {
if (videoData.timeAdded == null) {
videoData.timeAdded = new Date().getTime()
videoData.timeAdded = currentTime
}
if (videoData.playlistItemId == null) {
videoData.playlistItemId = generateRandomUniqueId()
@ -149,11 +152,14 @@ const actions = {
// Since this action will ensure uniqueness of `playlistItemId` of added video entries
try {
const { _id, videos } = payload
const currentTime = new Date().getTime()
const newVideoObjects = videos.map((video) => {
// Create a new object to prevent changing existing values outside
const videoData = Object.assign({}, video)
if (videoData.timeAdded == null) {
videoData.timeAdded = new Date().getTime()
videoData.timeAdded = currentTime
}
videoData.playlistItemId = generateRandomUniqueId()
// For backward compatibility
@ -188,6 +194,9 @@ const actions = {
dispatch('addPlaylist', playlist)
})
} else {
const dateNow = Date.now()
const currentTime = new Date().getTime()
payload.forEach((playlist) => {
let anythingUpdated = false
// Assign generated playlist ID in case DB data corrupted
@ -205,19 +214,19 @@ const actions = {
// Assign current time as created time in case DB data corrupted
if (playlist.createdAt == null) {
// Time now in unix time, in ms
playlist.createdAt = Date.now()
playlist.createdAt = dateNow
anythingUpdated = true
}
// Assign current time as last updated time in case DB data corrupted
if (playlist.lastUpdatedAt == null) {
// Time now in unix time, in ms
playlist.lastUpdatedAt = Date.now()
playlist.lastUpdatedAt = dateNow
anythingUpdated = true
}
playlist.videos.forEach((v) => {
// Ensure all videos has `timeAdded` property
if (v.timeAdded == null) {
v.timeAdded = new Date().getTime()
v.timeAdded = currentTime
anythingUpdated = true
}
@ -257,8 +266,9 @@ const actions = {
return playlist.playlistName === 'Watch Later' || playlist._id === 'watchLater'
})
const defaultFavoritesPlaylist = state.defaultPlaylists.find((e) => e._id === 'favorites')
if (favoritesPlaylist != null) {
const defaultFavoritesPlaylist = state.defaultPlaylists.find((e) => e._id === 'favorites')
// Update existing matching playlist only if it exists
if (favoritesPlaylist._id !== defaultFavoritesPlaylist._id || favoritesPlaylist.protected !== defaultFavoritesPlaylist.protected) {
const oldId = favoritesPlaylist._id
@ -277,8 +287,9 @@ const actions = {
}
}
const defaultWatchLaterPlaylist = state.defaultPlaylists.find((e) => e._id === 'watchLater')
if (watchLaterPlaylist != null) {
const defaultWatchLaterPlaylist = state.defaultPlaylists.find((e) => e._id === 'watchLater')
// Update existing matching playlist only if it exists
if (watchLaterPlaylist._id !== defaultWatchLaterPlaylist._id || watchLaterPlaylist.protected !== defaultWatchLaterPlaylist.protected) {
const oldId = watchLaterPlaylist._id
@ -394,7 +405,7 @@ const mutations = {
addVideos(state, payload) {
const playlist = state.playlists.find(playlist => playlist._id === payload._id)
if (playlist) {
playlist.videos = [].concat(playlist.videos).concat(payload.videos)
playlist.videos = [].concat(playlist.videos, payload.videos)
}
},

View File

@ -91,6 +91,43 @@ const actions = {
commit('setProfileList', profiles)
},
async batchUpdateSubscriptionDetails({ getters, dispatch }, channels) {
if (channels.length === 0) { return }
const profileList = getters.getProfileList
for (const profile of profileList) {
const currentProfileCopy = deepCopy(profile)
let profileUpdated = false
for (const { channelThumbnailUrl, channelName, channelId } of channels) {
const channel = currentProfileCopy.subscriptions.find((channel) => {
return channel.id === channelId
}) ?? null
if (channel === null) { continue }
if (channel.name !== channelName && channelName != null) {
channel.name = channelName
profileUpdated = true
}
if (channelThumbnailUrl) {
const thumbnail = channelThumbnailUrl.replace(/=s\d*/, '=s176') // change thumbnail size if different
if (channel.thumbnail !== thumbnail) {
channel.thumbnail = thumbnail
profileUpdated = true
}
}
}
if (profileUpdated) {
await dispatch('updateProfile', currentProfileCopy)
}
}
},
async updateSubscriptionDetails({ getters, dispatch }, { channelThumbnailUrl, channelName, channelId }) {
const thumbnail = channelThumbnailUrl?.replace(/=s\d*/, '=s176') ?? null // change thumbnail size if different
const profileList = getters.getProfileList

View File

@ -289,7 +289,7 @@ const state = {
videoPlaybackRateInterval: 0.25,
downloadAskPath: true,
downloadFolderPath: '',
downloadBehavior: 'download',
downloadBehavior: 'open',
enableScreenshot: false,
screenshotFormat: 'png',
screenshotQuality: 95,

View File

@ -54,6 +54,10 @@ const actions = {
commit('updateShortsCacheByChannel', payload)
},
updateSubscriptionShortsCacheWithChannelPageShorts: ({ commit }, payload) => {
commit('updateShortsCacheWithChannelPageShorts', payload)
},
updateSubscriptionLiveCacheByChannel: ({ commit }, payload) => {
commit('updateLiveCacheByChannel', payload)
},
@ -86,6 +90,31 @@ const mutations = {
if (videos != null) { newObject.videos = videos }
state.shortsCache[channelId] = newObject
},
updateShortsCacheWithChannelPageShorts(state, { channelId, videos }) {
const cachedObject = state.shortsCache[channelId]
if (cachedObject && cachedObject.videos.length > 0) {
cachedObject.videos.forEach(cachedVideo => {
const channelVideo = videos.find(short => cachedVideo.videoId === short.videoId)
if (channelVideo) {
// authorId probably never changes, so we don't need to update that
cachedVideo.title = channelVideo.title
cachedVideo.author = channelVideo.author
// as the channel shorts page only has compact view counts for numbers above 1000 e.g. 12k
// and the RSS feeds include an exact value, we only want to overwrite it when the number is larger than the cached value
// 12345 vs 12000 => 12345
// 12345 vs 15000 => 15000
if (channelVideo.viewCount > cachedVideo.viewCount) {
cachedVideo.viewCount = channelVideo.viewCount
}
}
})
}
},
clearShortsCache(state) {
state.shortsCache = {}
},

View File

@ -358,14 +358,10 @@ const actions = {
},
async getRegionData ({ commit }, { locale }) {
let localePathExists
const localePathExists = process.env.GEOLOCATION_NAMES.includes(locale)
// Exclude __dirname from path if not in electron
const fileLocation = `${process.env.IS_ELECTRON ? process.env.NODE_ENV === 'development' ? '.' : __dirname : ''}/static/geolocations/`
if (process.env.IS_ELECTRON) {
localePathExists = await pathExists(`${fileLocation}${locale}.json`)
} else {
localePathExists = process.env.GEOLOCATION_NAMES.includes(locale)
}
const pathName = `${fileLocation}${localePathExists ? locale : 'en-US'}.json`
const countries = process.env.IS_ELECTRON ? JSON.parse(await fs.readFile(pathName)) : await (await fetch(createWebURL(pathName))).json()
@ -601,15 +597,10 @@ const actions = {
async getExternalPlayerCmdArgumentsData ({ commit }, payload) {
const fileName = 'external-player-map.json'
let fileData
/* eslint-disable-next-line n/no-path-concat */
const fileLocation = process.env.NODE_ENV === 'development' ? './static/' : `${__dirname}/static/`
if (await pathExists(`${fileLocation}${fileName}`)) {
fileData = await fs.readFile(`${fileLocation}${fileName}`)
} else {
fileData = '[{"name":"None","value":"","cmdArguments":null}]'
}
const fileData = await fs.readFile(`${fileLocation}${fileName}`)
const externalPlayerMap = JSON.parse(fileData).map((entry) => {
return { name: entry.name, nameTranslationKey: entry.nameTranslationKey, value: entry.value, cmdArguments: entry.cmdArguments }

View File

@ -5,7 +5,8 @@
.dracula,
.catppuccinMocha,
.pastelPink,
.hotPink {
.hotPink,
.nordic {
--primary-input-color: rgba(0, 0, 0, 0.50);
}
@ -15,7 +16,8 @@
.dracula,
.catppuccinMocha,
.pastelPink,
.hotPink {
.hotPink,
.nordic {
--link-color: var(--accent-color);
--link-visited-color: var(--accent-color-visited);
--instance-menu-color: var(--search-bar-color);
@ -27,7 +29,8 @@
.gray,
.dracula,
.catppuccinMocha,
.pastelPink {
.pastelPink,
.nordic {
--primary-input-color: rgba(0, 0, 0, 0.50);
--side-nav-hover-text-color: var(--primary-text-color);
}
@ -37,7 +40,8 @@
.black,
.gray,
.dracula,
.catppuccinMocha {
.catppuccinMocha,
.nordic {
--side-nav-active-text-color: var(--primary-text-color);
--scrollbar-text-color-hover: var(--primary-text-color);
@ -56,7 +60,8 @@
.gray,
.dracula,
.catppuccinMocha,
.hotPink {
.hotPink,
.nordic {
--primary-shadow-color: rgba(0, 0, 0, 0.75);
}
@ -237,6 +242,25 @@ it can be safely elided. This looks quite pleasant on this theme. */
text-decoration: underline;
}
.nordic {
--primary-text-color: #EEEEEE;
--secondary-text-color: #ddd;
--tertiary-text-color: #EEEEEE;
--title-color: #EEEEEE;
--bg-color: #2b2f3a;
--favorite-icon-color: #FFEA00;
--card-bg-color: #2e3440;
--secondary-card-bg-color: rgba(59, 66, 82, 0.75);
--scrollbar-color: #4b566a;
--scrollbar-color-hover: #4b566a;
--side-nav-color: #2e3440;
--side-nav-hover-color: #3b4252;
--side-nav-active-color: #3b4252;
--search-bar-color: #4b566a;
--logo-icon: url("../../_icons/iconNordicLightSmall.png");
--logo-text: url("../../_icons/textNordicLightSmall.png");
}
.mainRed,
.mainPink,
.mainPurple,

View File

@ -217,6 +217,23 @@
}
}
@media only screen and (max-width: 680px) {
.channelInfo {
flex-direction: column;
margin-block: 20px 10px;
}
.card {
max-inline-size: none;
inline-size: 100%;
}
.channelInfoActionsContainer {
flex-direction: row-reverse;
justify-content: left;
gap: 10px;
margin-block-start: 5px;
}
}
@media only screen and (max-width: 400px) {
.channelInfo {
justify-content: center;
@ -224,7 +241,11 @@
}
.channelInfoActionsContainer {
flex-direction: column-reverse;
justify-content: center;
}
.channelLineContainer {
padding-inline-start: 0;
}
.thumbnailContainer {

View File

@ -25,6 +25,7 @@ import {
import {
getLocalChannel,
getLocalChannelId,
parseLocalChannelHeader,
parseLocalChannelShorts,
parseLocalChannelVideos,
parseLocalCommunityPosts,
@ -32,6 +33,10 @@ import {
parseLocalListVideo,
parseLocalSubscriberCount
} from '../../helpers/api/local'
import {
addPublishedDatesInvidious,
addPublishedDatesLocal
} from '../../helpers/subscriptions'
export default defineComponent({
name: 'Channel',
@ -170,6 +175,13 @@ export default defineComponent({
return this.subscriptionInfo !== null
},
isSubscribedInAnyProfile: function () {
const profileList = this.$store.getters.getProfileList
// check the all channels profile
return profileList[0].subscriptions.some((channel) => channel.id === this.id)
},
videoLiveSelectNames: function () {
return [
this.$t('Channel.Videos.Sort Types.Newest'),
@ -532,90 +544,22 @@ export default defineComponent({
return
}
let channelId
let subscriberText = null
let tags = []
const parsedHeader = parseLocalChannelHeader(channel)
switch (channel.header.type) {
case 'C4TabbedHeader': {
// example: Linus Tech Tips
// https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
const channelId = parsedHeader.id ?? this.id
const subscriberText = parsedHeader.subscriberText ?? null
let tags = parsedHeader.tags
/**
* @type {import('youtubei.js').YTNodes.C4TabbedHeader}
*/
const header = channel.header
channelThumbnailUrl = parsedHeader.thumbnailUrl ?? this.subscriptionInfo?.thumbnail
channelName = parsedHeader.name ?? this.subscriptionInfo?.name
channelId = header.author.id
channelName = header.author.name
channelThumbnailUrl = header.author.best_thumbnail.url
subscriberText = header.subscribers?.text
break
}
case 'CarouselHeader': {
// examples: Music and YouTube Gaming
// https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
/**
* @type {import('youtubei.js').YTNodes.CarouselHeader}
*/
const header = channel.header
/**
* @type {import('youtubei.js').YTNodes.TopicChannelDetails}
*/
const topicChannelDetails = header.contents.find(node => node.type === 'TopicChannelDetails')
channelName = topicChannelDetails.title.text
subscriberText = topicChannelDetails.subtitle.text
channelThumbnailUrl = topicChannelDetails.avatar[0].url
if (channel.metadata.external_id) {
channelId = channel.metadata.external_id
} else {
channelId = topicChannelDetails.subscribe_button.channel_id
}
break
}
case 'InteractiveTabbedHeader': {
// example: Minecraft - Topic
// https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg
/**
* @type {import('youtubei.js').YTNodes.InteractiveTabbedHeader}
*/
const header = channel.header
channelName = header.title.text
channelId = this.id
channelThumbnailUrl = header.box_art.at(-1).url
const badges = header.badges.map(badge => badge.label).filter(tag => tag)
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('//')) {
if (channelThumbnailUrl?.startsWith('//')) {
channelThumbnailUrl = `https:${channelThumbnailUrl}`
}
this.channelName = channelName
this.thumbnailUrl = channelThumbnailUrl
this.bannerUrl = parsedHeader.bannerUrl ?? null
this.isFamilyFriendly = !!channel.metadata.is_family_safe
if (channel.metadata.tags) {
@ -646,12 +590,6 @@ export default defineComponent({
this.updateSubscriptionDetails({ channelThumbnailUrl, channelName, channelId })
if (channel.header.banner?.length > 0) {
this.bannerUrl = channel.header.banner[0].url
} else {
this.bannerUrl = null
}
let relatedChannels = channel.channels.map(({ author }) => ({
name: author.name,
id: author.id,
@ -837,9 +775,20 @@ export default defineComponent({
return
}
this.latestVideos = parseLocalChannelVideos(videosTab.videos, channel.header.author)
this.latestVideos = parseLocalChannelVideos(videosTab.videos, this.id, this.channelName)
this.videoContinuationData = videosTab.has_continuation ? videosTab : null
this.isElementListLoading = false
if (this.isSubscribedInAnyProfile && this.latestVideos.length > 0 && this.videoSortBy === 'newest') {
addPublishedDatesLocal(this.latestVideos)
this.updateSubscriptionVideosCacheByChannel({
channelId: this.id,
// create a copy so that we only cache the first page
// if we use the same array, the store will get angry at us for modifying it outside of the store,
// when the user clicks load more
videos: [...this.latestVideos]
})
}
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
@ -862,7 +811,7 @@ export default defineComponent({
*/
const continuation = await this.videoContinuationData.getContinuation()
this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.channelInstance.header.author))
this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.id, this.channelName))
this.videoContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
@ -895,9 +844,19 @@ export default defineComponent({
return
}
this.latestShorts = parseLocalChannelShorts(shortsTab.videos, channel.header.author)
this.latestShorts = parseLocalChannelShorts(shortsTab.videos, this.id, this.channelName)
this.shortContinuationData = shortsTab.has_continuation ? shortsTab : null
this.isElementListLoading = false
if (this.isSubscribedInAnyProfile && this.latestShorts.length > 0 && this.shortSortBy === 'newest') {
// As the shorts tab API response doesn't include the published dates,
// we can't just write the results to the subscriptions cache like we do with videos and live (can't sort chronologically without the date).
// However we can still update the metadata in the cache such as the view count and title that might have changed since it was cached
this.updateSubscriptionShortsCacheWithChannelPageShorts({
channelId: this.id,
videos: this.latestShorts
})
}
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
@ -920,7 +879,7 @@ export default defineComponent({
*/
const continuation = await this.shortContinuationData.getContinuation()
this.latestShorts.push(...parseLocalChannelShorts(continuation.videos, this.channelInstance.header.author))
this.latestShorts.push(...parseLocalChannelShorts(continuation.videos, this.id, this.channelName))
this.shortContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
@ -953,9 +912,20 @@ export default defineComponent({
return
}
this.latestLive = parseLocalChannelVideos(liveTab.videos, channel.header.author)
this.latestLive = parseLocalChannelVideos(liveTab.videos, this.id, this.channelName)
this.liveContinuationData = liveTab.has_continuation ? liveTab : null
this.isElementListLoading = false
if (this.isSubscribedInAnyProfile && this.latestLive.length > 0 && this.liveSortBy === 'newest') {
addPublishedDatesLocal(this.latestLive)
this.updateSubscriptionLiveCacheByChannel({
channelId: this.id,
// create a copy so that we only cache the first page
// if we use the same array, the store will get angry at us for modifying it outside of the store,
// when the user clicks load more
videos: [...this.latestLive]
})
}
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
@ -978,7 +948,7 @@ export default defineComponent({
*/
const continuation = await this.liveContinuationData.getContinuation()
this.latestLive.push(...parseLocalChannelVideos(continuation.videos, this.channelInstance.header.author))
this.latestLive.push(...parseLocalChannelVideos(continuation.videos, this.id, this.channelName))
this.liveContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
@ -1125,6 +1095,16 @@ export default defineComponent({
}
this.videoContinuationData = response.continuation || null
this.isElementListLoading = false
if (this.isSubscribedInAnyProfile && !more && this.latestVideos.length > 0 && this.videoSortBy === 'newest') {
addPublishedDatesInvidious(this.latestVideos)
this.updateSubscriptionVideosCacheByChannel({
channelId: this.id,
// create a copy so that we only cache the first page
// if we use the same array, it will also contain all the next pages
videos: [...this.latestVideos]
})
}
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
@ -1174,6 +1154,17 @@ export default defineComponent({
}
this.shortContinuationData = response.continuation || null
this.isElementListLoading = false
if (this.isSubscribedInAnyProfile && !more && this.latestShorts.length > 0 && this.shortSortBy === 'newest') {
// As the shorts tab API response doesn't include the published dates,
// we can't just write the results to the subscriptions cache like we do with videos and live (can't sort chronologically without the date).
// However we can still update the metadata in the cache e.g. adding the duration, as that isn't included in the RSS feeds
// and updating the view count and title that might have changed since it was cached
this.updateSubscriptionShortsCacheWithChannelPageShorts({
channelId: this.id,
videos: this.latestShorts
})
}
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
@ -1215,6 +1206,17 @@ export default defineComponent({
}
this.liveContinuationData = response.continuation || null
this.isElementListLoading = false
if (this.isSubscribedInAnyProfile && !more && this.latestLive.length > 0 && this.liveSortBy === 'newest') {
addPublishedDatesInvidious(this.latestLive)
this.updateSubscriptionLiveCacheByChannel({
channelId: this.id,
// create a copy so that we only cache the first page
// if we use the same array, the store will get angry at us for modifying it outside of the store,
// when the user clicks load more
videos: [...this.latestLive]
})
}
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
@ -1270,7 +1272,7 @@ export default defineComponent({
return
}
this.latestPlaylists = playlistsTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
this.latestPlaylists = playlistsTab.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.playlistContinuationData = playlistsTab.has_continuation ? playlistsTab : null
this.isElementListLoading = false
} catch (err) {
@ -1295,7 +1297,7 @@ export default defineComponent({
*/
const continuation = await this.playlistContinuationData.getContinuation()
const parsedPlaylists = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author))
const parsedPlaylists = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.latestPlaylists = this.latestPlaylists.concat(parsedPlaylists)
this.playlistContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
@ -1393,7 +1395,7 @@ export default defineComponent({
return
}
this.latestReleases = releaseTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
this.latestReleases = releaseTab.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.releaseContinuationData = releaseTab.has_continuation ? releaseTab : null
this.isElementListLoading = false
} catch (err) {
@ -1418,7 +1420,7 @@ export default defineComponent({
*/
const continuation = await this.releaseContinuationData.getContinuation()
const parsedReleases = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author))
const parsedReleases = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.latestReleases = this.latestReleases.concat(parsedReleases)
this.releaseContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
@ -1506,7 +1508,7 @@ export default defineComponent({
return
}
this.latestPodcasts = podcastTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
this.latestPodcasts = podcastTab.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.podcastContinuationData = podcastTab.has_continuation ? podcastTab : null
this.isElementListLoading = false
} catch (err) {
@ -1517,7 +1519,7 @@ export default defineComponent({
})
if (this.backendPreference === 'local' && this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
this.getChannelPodcastsInvidious()
this.channelInvidiousPodcasts()
} else {
this.isLoading = false
}
@ -1531,7 +1533,7 @@ export default defineComponent({
*/
const continuation = await this.podcastContinuationData.getContinuation()
const parsedPodcasts = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author))
const parsedPodcasts = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.latestPodcasts = this.latestPodcasts.concat(parsedPodcasts)
this.releaseContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
@ -1632,6 +1634,19 @@ export default defineComponent({
this.latestCommunityPosts = parseLocalCommunityPosts(posts)
this.communityContinuationData = communityTab.has_continuation ? communityTab : null
if (this.latestCommunityPosts.length > 0) {
this.latestCommunityPosts.forEach(post => {
post.authorId = this.id
})
this.updateSubscriptionPostsCacheByChannel({
channelId: this.id,
// create a copy so that we only cache the first page
// if we use the same array, the store will get angry at us for modifying it outside of the store,
// when the user clicks load more
posts: [...this.latestCommunityPosts]
})
}
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
@ -1683,6 +1698,19 @@ export default defineComponent({
this.latestCommunityPosts = posts
}
this.communityContinuationData = continuation
if (this.isSubscribedInAnyProfile && !more && this.latestCommunityPosts.length > 0) {
this.latestCommunityPosts.forEach(post => {
post.authorId = this.id
})
this.updateSubscriptionPostsCacheByChannel({
channelId: this.id,
// create a copy so that we only cache the first page
// if we use the same array, the store will get angry at us for modifying it outside of the store,
// when the user clicks load more
posts: [...this.latestCommunityPosts]
})
}
}).catch(async (err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
@ -1857,7 +1885,7 @@ export default defineComponent({
if (item.type === 'Video') {
return parseLocalListVideo(item)
} else {
return parseLocalListPlaylist(item, this.channelInstance.header.author)
return parseLocalListPlaylist(item, this.id, this.channelName)
}
})
@ -1922,7 +1950,11 @@ export default defineComponent({
...mapActions([
'showOutlines',
'updateSubscriptionDetails'
'updateSubscriptionDetails',
'updateSubscriptionVideosCacheByChannel',
'updateSubscriptionLiveCacheByChannel',
'updateSubscriptionShortsCacheWithChannelPageShorts',
'updateSubscriptionPostsCacheByChannel'
])
}
})

View File

@ -417,7 +417,7 @@
<ft-age-restricted
v-else-if="!isLoading && (!isFamilyFriendly && showFamilyFriendlyOnly)"
class="ageRestricted"
:content-type-string="'Channel'"
:is-channel="true"
/>
</div>
</template>

View File

@ -4,7 +4,7 @@ import debounce from 'lodash.debounce'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import PlaylistInfo from '../../components/playlist-info/playlist-info.vue'
import FtListVideoLazy from '../../components/ft-list-video-lazy/ft-list-video-lazy.vue'
import FtListVideoNumbered from '../../components/ft-list-video-numbered/ft-list-video-numbered.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtButton from '../../components/ft-button/ft-button.vue'
import {
@ -21,12 +21,12 @@ export default defineComponent({
'ft-loader': FtLoader,
'ft-card': FtCard,
'playlist-info': PlaylistInfo,
'ft-list-video-lazy': FtListVideoLazy,
'ft-list-video-numbered': FtListVideoNumbered,
'ft-flex-box': FtFlexBox,
'ft-button': FtButton
},
beforeRouteLeave(to, from, next) {
if (!this.isLoading && to.path.startsWith('/watch') && to.query.playlistId === this.playlistId) {
if (!this.isLoading && !this.isUserPlaylistRequested && to.path.startsWith('/watch') && to.query.playlistId === this.playlistId) {
this.setCachedPlaylist({
id: this.playlistId,
title: this.playlistTitle,
@ -54,11 +54,15 @@ export default defineComponent({
channelId: '',
infoSource: 'local',
playlistItems: [],
userPlaylistVisibleLimit: 100,
continuationData: null,
isLoadingMore: false,
getPlaylistInfoDebounce: function() {},
playlistInEditMode: false,
playlistInVideoSearchMode: false,
videoSearchQuery: '',
promptOpen: false,
}
},
@ -102,7 +106,11 @@ export default defineComponent({
},
moreVideoDataAvailable() {
return this.continuationData !== null
if (this.isUserPlaylistRequested) {
return this.userPlaylistVisibleLimit < this.sometimesFilteredUserPlaylistItems.length
} else {
return this.continuationData !== null
}
},
isUserPlaylistRequested: function () {
@ -117,6 +125,30 @@ export default defineComponent({
return this.selectedUserPlaylist?._id !== this.quickBookmarkPlaylistId
},
sometimesFilteredUserPlaylistItems() {
if (!this.isUserPlaylistRequested) { return this.playlistItems }
if (this.processedVideoSearchQuery === '') { return this.playlistItems }
return this.playlistItems.filter((v) => {
return v.title.toLowerCase().includes(this.processedVideoSearchQuery)
})
},
visiblePlaylistItems: function () {
if (!this.isUserPlaylistRequested) {
// No filtering for non user playlists yet
return this.playlistItems
}
if (this.userPlaylistVisibleLimit < this.sometimesFilteredUserPlaylistItems.length) {
return this.sometimesFilteredUserPlaylistItems.slice(0, this.userPlaylistVisibleLimit)
} else {
return this.sometimesFilteredUserPlaylistItems
}
},
processedVideoSearchQuery() {
return this.videoSearchQuery.trim().toLowerCase()
},
},
watch: {
$route () {
@ -147,8 +179,10 @@ export default defineComponent({
this.getPlaylistInfoDebounce()
},
},
mounted: function () {
created: function () {
this.getPlaylistInfoDebounce = debounce(this.getPlaylistInfo, 100)
},
mounted: function () {
this.getPlaylistInfoDebounce()
},
methods: {
@ -250,7 +284,7 @@ export default defineComponent({
const dateString = new Date(result.updated * 1000)
this.lastUpdated = dateString.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' })
this.playlistItems = this.playlistItems.concat(result.videos)
this.playlistItems = result.videos
this.isLoading = false
}).catch((err) => {
@ -298,6 +332,20 @@ export default defineComponent({
case 'local':
this.getNextPageLocal()
break
case 'user':
// Stop users from spamming the load more button, by replacing it with a loading symbol until the newly added items are renderered
this.isLoadingMore = true
setTimeout(() => {
if (this.userPlaylistVisibleLimit + 100 < this.videoCount) {
this.userPlaylistVisibleLimit += 100
} else {
this.userPlaylistVisibleLimit = this.videoCount
}
this.isLoadingMore = false
})
break
case 'invidious':
console.error('Playlist pagination is not currently supported when the Invidious backend is selected.')
break

View File

@ -7,7 +7,6 @@
box-sizing: border-box;
block-size: calc(100vh - 132px);
margin-inline-end: 1em;
overflow-y: auto;
padding: 10px;
position: sticky;
inset-block-start: 96px;
@ -62,11 +61,6 @@
transform: translate(calc(10% * var(--horizontal-directionality-coefficient)));
}
.videoIndex {
color: var(--tertiary-text-color);
text-align: center;
}
.loadNextPageWrapper {
/* about the same height as the button */
max-block-size: 7vh;

View File

@ -28,6 +28,9 @@
}"
@enter-edit-mode="playlistInEditMode = true"
@exit-edit-mode="playlistInEditMode = false"
@search-video-mode-on="playlistInVideoSearchMode = true"
@search-video-mode-off="playlistInVideoSearchMode = false"
@search-video-query-change="(v) => videoSearchQuery = v"
@prompt-open="promptOpen = true"
@prompt-close="promptOpen = false"
/>
@ -36,60 +39,63 @@
v-if="!isLoading"
class="playlistItems"
>
<span
<template
v-if="playlistItems.length > 0"
>
<transition-group
name="playlistItem"
tag="span"
<template
v-if="visiblePlaylistItems.length > 0"
>
<div
v-for="(item, index) in playlistItems"
:key="`${item.videoId}-${item.playlistItemId || index}`"
class="playlistItem"
<transition-group
name="playlistItem"
tag="span"
>
<p
class="videoIndex"
>
{{ index + 1 }}
</p>
<ft-list-video-lazy
<ft-list-video-numbered
v-for="(item, index) in visiblePlaylistItems"
:key="`${item.videoId}-${item.playlistItemId || index}`"
class="playlistItem"
:data="item"
:playlist-id="playlistId"
:playlist-type="infoSource"
:playlist-index="index"
:playlist-index="playlistInVideoSearchMode ? playlistItems.findIndex(i => i === item) : index"
:playlist-item-id="item.playlistItemId"
appearance="result"
force-list-type="list"
:always-show-add-to-playlist-button="true"
:quick-bookmark-button-enabled="quickBookmarkButtonEnabled"
:can-move-video-up="index > 0"
:can-move-video-down="index < playlistItems.length - 1"
:can-move-video-up="index > 0 && !playlistInVideoSearchMode"
:can-move-video-down="index < playlistItems.length - 1 && !playlistInVideoSearchMode"
:can-remove-from-playlist="true"
:hide-forbidden-titles="false"
:video-index="playlistInVideoSearchMode ? playlistItems.findIndex(i => i === item) : index"
:initial-visible-state="index < 10"
@move-video-up="moveVideoUp(item.videoId, item.playlistItemId)"
@move-video-down="moveVideoDown(item.videoId, item.playlistItemId)"
@remove-from-playlist="removeVideoFromPlaylist(item.videoId, item.playlistItemId)"
/>
</transition-group>
<ft-flex-box
v-if="moreVideoDataAvailable && !isLoadingMore"
>
<ft-button
:label="$t('Subscriptions.Load More Videos')"
background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@click="getNextPage"
/>
</ft-flex-box>
<div
v-if="isLoadingMore"
class="loadNextPageWrapper"
>
<ft-loader />
</div>
</transition-group>
</template>
<ft-flex-box
v-if="moreVideoDataAvailable && !isLoadingMore"
v-else
>
<ft-button
:label="$t('Subscriptions.Load More Videos')"
background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@click="getNextPage"
/>
<p class="message">
{{ $t("User Playlists['Empty Search Message']") }}
</p>
</ft-flex-box>
<div
v-if="isLoadingMore"
class="loadNextPageWrapper"
>
<ft-loader />
</div>
</span>
</template>
<ft-flex-box
v-else
>

View File

@ -450,7 +450,7 @@ export default defineComponent({
timestamp: formatDurationAsTimestamp(start),
startSeconds: start,
endSeconds: 0,
thumbnail: chapter.thumbnail[0].url
thumbnail: chapter.thumbnail[0]
})
}
} else {

View File

@ -16,7 +16,7 @@
display: inline-block;
max-inline-size: calc(80vh * 1.78);
@media only screen and (min-width: 901px) {
@media only screen and (min-width: 1051px) {
inline-size: 300%;
}
}
@ -99,7 +99,7 @@
.sidebarArea {
grid-area: sidebar;
@media only screen and (min-width: 901px) {
@media only screen and (min-width: 1051px) {
min-inline-size: 380px;
}
@ -135,7 +135,7 @@
margin-block: 0 16px;
margin-inline: 0;
@media only screen and (min-width: 901px) {
@media only screen and (min-width: 1051px) {
margin-block: 0 16px;
margin-inline: 8px;
}
@ -145,17 +145,17 @@
@include theatre-mode-template;
}
@media only screen and (min-width: 901px) {
@media only screen and (min-width: 1051px) {
&.useTheatreMode {
@include theatre-mode-template;
}
}
@media only screen and (max-width: 900px) {
@media only screen and (max-width: 1050px) {
@include single-column-template;
}
@media only screen and (min-width: 901px) {
@media only screen and (min-width: 1051px) {
.infoArea {
scroll-margin-block-start: 76px;
}

View File

@ -87,7 +87,7 @@
<ft-age-restricted
v-if="(!isLoading && !isFamilyFriendly && showFamilyFriendlyOnly)"
class="ageRestricted"
:content-type-string="'Video'"
:is-video="true"
/>
<div
v-if="(isFamilyFriendly || !showFamilyFriendlyOnly)"

View File

@ -1,5 +1,5 @@
# Put the name of your locale in the same language
Locale Name: 'الإنجليزية (الولايات المتحدة)'
Locale Name: 'العربية'
FreeTube: 'فري تيوب'
# Currently on Subscriptions, Playlists, and History
'This part of the app is not ready yet. Come back later when progress has been made.': >-
@ -173,6 +173,7 @@ User Playlists:
Reverted to use {oldPlaylistName} for quick bookmark: تمت العودة لاستخدام {oldPlaylistName}
للإشارة المرجعية السريعة
Quick bookmark disabled: تم تعطيل الإشارة المرجعية السريعة
Search for Videos: ‬البحث عن مقاطع الفيديو
AddVideoPrompt:
Select a playlist to add your N videos to: حدد قائمة تشغيل لإضافة الفيديو الخاص
بك إلى | حدد قائمة تشغيل لإضافة مقاطع الفيديو {videoCount} إليها
@ -186,6 +187,7 @@ User Playlists:
Save: حفظ
Search in Playlists: البحث في قوائم التشغيل
N playlists selected: تم تحديد {playlistCount}
Added {count} Times: تمت إضافة {count} الوقت | تمت إضافة {count} مرة
CreatePlaylistPrompt:
Toast:
There is already a playlist with this name. Please pick a different name.: توجد
@ -276,6 +278,7 @@ Settings:
Catppuccin Mocha: كاتبوتشين موكا
Pastel Pink: الباستيل الوردي
Hot Pink: وردي فاقع
Nordic: بلدان الشمال الأوروبي
Main Color Theme:
Main Color Theme: 'لون السِمة الأساسي'
Red: 'أحمر'
@ -517,8 +520,8 @@ Settings:
Hide Upcoming Premieres: إخفاء العروض الأولى القادمة
Hide Channels: إخفاء مقاطع الفيديو من القنوات
Hide Channels Placeholder: معرف القناة
Display Titles Without Excessive Capitalisation: عرض العناوين بدون احرف كبيرة
بشكل مفرط
Display Titles Without Excessive Capitalisation: عرض العناوين بدون استخدام الأحرف
الكبيرة وعلامات الترقيم بشكل مفرط
Hide Featured Channels: إخفاء القنوات المميزة
Hide Channel Playlists: إخفاء قوائم تشغيل القناة
Hide Channel Community: إخفاء مجتمع القناة
@ -930,6 +933,7 @@ Video:
Pause on Current Video: توقف مؤقتًا على الفيديو الحالي
Unhide Channel: عرض القناة
Hide Channel: إخفاء القناة
More Options: المزيد من الخيارات
Videos:
#& Sort By
Sort By:
@ -1006,7 +1010,7 @@ Up Next: 'التالي'
Local API Error (Click to copy): 'خطأ API المحلي (انقر للنسخ)'
Invidious API Error (Click to copy): 'خطأ Invidious API ( انقر للنسخ)'
Falling back to Invidious API: 'التراجع إلى Invidious API'
Falling back to the local API: 'التراجع إلى API المحلي'
Falling back to Local API: 'التراجع إلى API المحلي'
Subscriptions have not yet been implemented: 'لم يتم تنفيذ الاشتراكات بعد'
Loop is now disabled: 'تم تعطيل التكرار'
Loop is now enabled: 'تم تمكين التكرار'
@ -1130,11 +1134,6 @@ Starting download: بدء تنزيل "{videoTitle}"
Screenshot Success: تم حفظ لقطة الشاشة كا"{filePath}"
Screenshot Error: فشل أخذ لقطة للشاشة. {error}
New Window: نافذة جديدة
Age Restricted:
Type:
Channel: القناة
Video: فيديو
This {videoOrPlaylist} is age restricted: 'هذا {videoOrPlaylist} مقيد بالعمر'
Channels:
Count: تم العثور على قناة (قنوات) {number}.
Unsubscribed: 'تمت إزالة {channelName} من اشتراكاتك'
@ -1171,3 +1170,7 @@ Trimmed input must be at least N characters long: يجب أن يكون طول ا
حرفًا واحدًا على الأقل | يجب أن يبلغ طول الإدخال المقتطع {length} من الأحرف على
الأقل
Tag already exists: العلامة "{tagName}" موجودة بالفعل
Age Restricted:
This channel is age restricted: هذه القناة مقيدة بالعمر
This video is age restricted: هذا الفيديو مقيد بالفئة العمرية
Close Banner: إغلاق الشعار

View File

@ -813,7 +813,7 @@ Tooltips:
Local API Error (Click to copy): ''
Invidious API Error (Click to copy): ''
Falling back to Invidious API: ''
Falling back to the local API: ''
Falling back to Local API: ''
This video is unavailable because of missing formats. This can happen due to country unavailability.: ''
Subscriptions have not yet been implemented: ''
Unknown YouTube url type, cannot be opened in app: ''
@ -833,11 +833,6 @@ Canceled next video autoplay: ''
Default Invidious instance has been set to {instance}: ''
Default Invidious instance has been cleared: ''
'The playlist has ended. Enable loop to continue playing': ''
Age Restricted:
This {videoOrPlaylist} is age restricted: ''
Type:
Channel: ''
Video: ''
External link opening has been disabled in the general settings: ''
Downloading has completed: ''
Starting download: ''

View File

@ -1023,7 +1023,7 @@ Up Next: 'Следващ'
Local API Error (Click to copy): 'Грешка в локалния интерфейс (щракнете за копиране)'
Invidious API Error (Click to copy): 'Грешка в Invidious интерфейса (щракнете за копиране)'
Falling back to Invidious API: 'Връщане към Invidious интерфейса'
Falling back to the local API: 'Връщане към локалния интерфейс'
Falling back to Local API: 'Връщане към локалния интерфейс'
This video is unavailable because of missing formats. This can happen due to country unavailability.: 'Видеото
не е достъпно поради липсващи формати. Това може да се дължи на ограничен достъп
за страната.'
@ -1148,12 +1148,6 @@ Downloading failed: Имаше проблем при изтеглянето на
Screenshot Error: Снимката на екрана е неуспешна. {error}
Screenshot Success: Запазена снимка на екрана като "{filePath}"
New Window: Нов прозорец
Age Restricted:
This {videoOrPlaylist} is age restricted: Този {videoOrPlaylist} е с възрастово
ограничение
Type:
Channel: Канал
Video: Видео
Channels:
Count: Намерени са {number} канала.
Unsubscribe: Отписване

View File

@ -135,10 +135,6 @@ 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: ভিডিও
Most Popular: অতিপরিচিত
Channels:
Search bar placeholder: চ্যানেল খুঁজুন

View File

@ -829,7 +829,7 @@ Tooltips:
Local API Error (Click to copy): ''
Invidious API Error (Click to copy): ''
Falling back to Invidious API: ''
Falling back to the local API: ''
Falling back to Local API: ''
This video is unavailable because of missing formats. This can happen due to country unavailability.: ''
Subscriptions have not yet been implemented: ''
Unknown YouTube url type, cannot be opened in app: ''
@ -849,11 +849,6 @@ Canceled next video autoplay: ''
Default Invidious instance has been set to {instance}: ''
Default Invidious instance has been cleared: ''
'The playlist has ended. Enable loop to continue playing': ''
Age Restricted:
This {videoOrPlaylist} is age restricted: ''
Type:
Channel: 'کەناڵ'
Video: 'ڤیدیۆ'
External link opening has been disabled in the general settings: ''
Downloading has completed: ''
Starting download: ''

View File

@ -146,6 +146,7 @@ User Playlists:
Select a playlist to add your N videos to: Vyberte playlist, do kterého přidat
vaše video | Vyberte playlist, do kterého přidat vašich {videoCount} videí
N playlists selected: Vybráno {playlistCount}
Added {count} Times: Přidáno {count}krát | Přidáno {count}krát
SinglePlaylistView:
Toast:
There were no videos to remove.: Nejsou zde žádná videa k odstranění.
@ -175,6 +176,7 @@ User Playlists:
This playlist is now used for quick bookmark: Tento playlist bude nyní použit
pro rychlé uložení
Quick bookmark disabled: Rychlé uložení vypnuto
Search for Videos: Hledat videa
Are you sure you want to delete this playlist? This cannot be undone: Opravdu chcete
odstranit tento playlist? Tato akce je nevratná.
Sort By:
@ -234,7 +236,7 @@ Settings:
General Settings:
General Settings: 'Obecné nastavení'
Check for Updates: 'Kontrolovat aktualizace'
Check for Latest Blog Posts: 'Kontrolovat nejnovější příspěvky blogů'
Check for Latest Blog Posts: 'Kontrolovat nejnovější příspěvky na blogu'
Fallback to Non-Preferred Backend on Failure: 'Při chybě přepnout na nepreferovaný
backend'
Enable Search Suggestions: 'Zapnout návrhy vyhledávání'
@ -292,6 +294,7 @@ Settings:
Catppuccin Mocha: Catppuccin Mocha
Hot Pink: Horká růžová
Pastel Pink: Pastelově růžová
Nordic: Nordic
Main Color Theme:
Main Color Theme: 'Hlavní barva motivu'
Red: 'Červená'
@ -443,7 +446,7 @@ Settings:
Hide Channels: Skrýt videa z kanálů
Hide Channels Placeholder: ID kanálu
Display Titles Without Excessive Capitalisation: Zobrazit názvy bez nadměrného
použití velkých písmen
použití velkých písmen a interpunkce
Hide Featured Channels: Skrýt doporučené kanály
Hide Channel Playlists: Skrýt playlisty kanálu
Hide Channel Community: Skrýt komunitu kanálu
@ -925,6 +928,7 @@ Video:
Pause on Current Video: Pozastavit na současném videu
Unhide Channel: Zobrazit kanál
Hide Channel: Skrýt kanál
More Options: Další možnosti
Videos:
#& Sort By
Sort By:
@ -1024,10 +1028,10 @@ Tooltips:
otevřít ve FreeTube.\nVe výchozím nastavení otevře FreeTube odkaz ve vašem výchozím
prohlížeči.\n"
Player Settings:
Force Local Backend for Legacy Formats: 'Funguje pouze v případě, že je výchozím
nastavením API Invidious. Je-li povoleno, spustí se místní API a použije starší
Force Local Backend for Legacy Formats: 'Funguje pouze v případě, že je jako výchozí
nastaveno API Invidious. Je-li povoleno, spustí se místní API a použije starší
formáty místo těch, které vrátí Invidious. Může pomoci, pokud videa z Invidious
nemohou být přehrána kvůli regionálnímu omezení.'
nemohou být přehrána z důvodu regionálních omezení.'
Proxy Videos Through Invidious: 'Připojí se k Invidious, aby poskytoval videa
namísto přímého připojení k YouTube. Toto přepíše předvolby API.'
Default Video Format: 'Nastavte formáty použité při přehrávání videa. Formáty
@ -1089,7 +1093,7 @@ Tooltips:
Local API Error (Click to copy): 'Chyba lokálního API (kliknutím zkopírujete)'
Invidious API Error (Click to copy): 'Chyba Invidious API (kliknutím zkopírujete)'
Falling back to Invidious API: 'Přepínám na Invidious API'
Falling back to the local API: 'Přepínám na lokální API'
Falling back to Local API: 'Přepínám na lokální API'
This video is unavailable because of missing formats. This can happen due to country unavailability.: 'Toto
video není k dispozici z důvodu chybějících formátů. K tomu může dojít z důvodu
nedostupnosti země.'
@ -1128,11 +1132,6 @@ Downloading has completed: Bylo dokončeno stahování "{videoTitle}"
Downloading failed: Došlo k problému při stahování "{videoTitle}"
Starting download: Zahájení stahování "{videoTitle}"
New Window: Nové okno
Age Restricted:
This {videoOrPlaylist} is age restricted: Toto {videoOrPlaylist} je omezeno věkem
Type:
Channel: kanál
Video: Video
Channels:
Channels: Kanály
Title: Seznam kanálů
@ -1169,3 +1168,7 @@ Channel Unhidden: Kanál {channel} odebrán z filtrů kanálů
Trimmed input must be at least N characters long: Oříznutý vstup musí být dlouhý alespoň
1 znak | Oříznutý vstup musí být dlouhý alespoň {length} znaků
Tag already exists: Štítek „{tagName}“ již existuje
Close Banner: Zavřít panel
Age Restricted:
This channel is age restricted: Tento kanál je omezen věkem
This video is age restricted: Toto video je omezeno věkem

View File

@ -831,7 +831,7 @@ Tooltips:
Local API Error (Click to copy): ''
Invidious API Error (Click to copy): ''
Falling back to Invidious API: ''
Falling back to the local API: ''
Falling back to Local API: ''
This video is unavailable because of missing formats. This can happen due to country unavailability.: ''
Subscriptions have not yet been implemented: ''
Unknown YouTube url type, cannot be opened in app: ''
@ -851,11 +851,6 @@ Canceled next video autoplay: ''
Default Invidious instance has been set to {instance}: ''
Default Invidious instance has been cleared: ''
'The playlist has ended. Enable loop to continue playing': ''
Age Restricted:
This {videoOrPlaylist} is age restricted: ''
Type:
Channel: 'Sianel'
Video: 'Fideo'
External link opening has been disabled in the general settings: ''
Downloading has completed: ''
Starting download: ''

View File

@ -769,7 +769,7 @@ Up Next: 'Næste'
Local API Error (Click to copy): 'Lokal API-Fejl (Klik for at kopiere)'
Invidious API Error (Click to copy): 'Invidious-API-Fejl (Klik for at kopiere)'
Falling back to Invidious API: 'Falder tilbage til Invidious-API'
Falling back to the local API: 'Falder tilbage til den lokale API'
Falling back to Local API: 'Falder tilbage til den lokale API'
Subscriptions have not yet been implemented: 'Abonnementer er endnu ikke blevet implementerede'
Loop is now disabled: 'Gentagelse er nu deaktiveret'
Loop is now enabled: 'Gentagelse er nu aktiveret'
@ -858,11 +858,6 @@ Default Invidious instance has been cleared: Standard Invidious-instans er bleve
Are you sure you want to open this link?: Er du sikker på at du vil åbne dette link?
Search Bar:
Clear Input: Ryd Input
Age Restricted:
Type:
Video: Video
Channel: Kanal
This {videoOrPlaylist} is age restricted: Denne {videoOrPlaylist} er aldersbegrænset
Downloading failed: Der var et problem med at downloade "{videoTitle}"
Unknown YouTube url type, cannot be opened in app: Ukendt YouTube URL-type, kan ikke
åbnes i appen

View File

@ -44,7 +44,7 @@ Global:
View Count: 1 Aufruf | {count} Aufrufe
Watching Count: 1 Zuschauer | {count} Zuschauer
Input Tags:
Length Requirement: Deutsch
Length Requirement: Der Tag muss mindestens {number} Zeichen lang sein
Search / Go to URL: Suche / Gehe zur URL
# In Filter Button
Search Filters:
@ -294,6 +294,7 @@ Settings:
Catppuccin Mocha: Catppuccin Mocha
Hot Pink: Pink
Pastel Pink: Pastellrosa
Nordic: Nordic
Main Color Theme:
Main Color Theme: Hauptfarbe des Farbschemas
Red: Rot
@ -568,6 +569,10 @@ Settings:
Hide Channels Already Exists: Kanal-ID bereits vorhanden
Hide Channels API Error: Fehler beim Abrufen von Benutzern mit der bereitgestellten
ID. Bitte überprüfen Sie erneut, ob der Ausweis korrekt ist.
Hide Videos and Playlists Containing Text: Videos und Playlisten verstecken, die
Text enthalten
Hide Videos and Playlists Containing Text Placeholder: Wort, Teil eines Wortes
oder Satz
The app needs to restart for changes to take effect. Restart and apply change?: Die
App muss neu gestartet werden, damit die Änderungen wirksam werden. Neu starten
und Änderung übernehmen?
@ -764,6 +769,7 @@ Channel:
votes: '{votes} Stimmen'
Reveal Answers: Antworten aufzeigen
Hide Answers: Antworten verbergen
Video hidden by FreeTube: Video versteckt von FreeTube
Live:
Live: Live
This channel does not currently have any live streams: Dieser Kanal hat derzeit
@ -1008,7 +1014,7 @@ Up Next: Nächster Titel
Local API Error (Click to copy): Lokaler API-Fehler (Zum Kopieren anklicken)
Invidious API Error (Click to copy): Invidious-API-Fehler (Zum Kopieren anklicken)
Falling back to Invidious API: Rückgriff auf Invidious-API
Falling back to the local API: Rückgriff auf lokale API
Falling back to Local API: Rückgriff auf lokale API
This video is unavailable because of missing formats. This can happen due to country unavailability.: Dieses
Video ist aufgrund fehlender Formate nicht verfügbar. Zugriffsbeschränkungen im
Land kann dafür der Grund sein.
@ -1163,6 +1169,11 @@ Tooltips:
Hide Subscriptions Live: Diese Einstellung wird durch die App-weite Einstellung
„{appWideSetting}“ im Abschnitt „{subsection}“ der „{settingsSection}“ außer
Kraft gesetzt
Hide Videos and Playlists Containing Text: Gebe ein Wort, einen Teil eines Wortes
oder einen Satz ein (Groß-/Kleinschreibung wird ignoriert) um alle Videos und
Playlisten, welche es in ihren Originaltiteln enthalten, auf ganz FreeTube auszublenden.
Dein Verlauf, deine eigenen Playlisten und Videos innerhalb deiner Playlisten
sind davon nicht betroffen.
SponsorBlock Settings:
UseDeArrowTitles: Videotitel durch von Benutzern eingereichte Titel von DeArrow
ersetzen.
@ -1193,11 +1204,6 @@ Downloading failed: Beim Herunterladen von „{videoTitle}“ gab es ein Problem
Screenshot Success: Bildschirmfoto als „{filePath}“ gespeichert
Screenshot Error: Bildschirmfoto fehlgeschlagen. {error}
New Window: Neues Fenster
Age Restricted:
Type:
Video: Video
Channel: Kanal
This {videoOrPlaylist} is age restricted: Dieses {videoOrPlaylist} ist altersbeschränkt
Channels:
Channels: Kanäle
Title: Kanalliste
@ -1231,3 +1237,7 @@ Playlist will not pause when current video is finished: Wiedergabeliste wird nic
Channel Hidden: '{channel} wurde zum Kanalfilter hinzugefügt'
Go to page: Gehe zu {page}
Channel Unhidden: '{channel} wurde aus dem Kanalfilter entfernt'
Trimmed input must be at least N characters long: Gekürzte Eingaben müssen mindestens
1 Zeichen lang sein | Gekürzte Eingaben müssen mindestens {length} Zeichen lang
sein
Tag already exists: Die Markierung „{tagName}“ existiert bereits

View File

@ -945,7 +945,7 @@ Local API Error (Click to copy): 'Τοπικό σφάλμα Διεπαφής π
Invidious API Error (Click to copy): 'Σφάλμα Διεπαφής προγραμματισμού εφαρμογής Invidious(*API)
(Κάντε κλικ για αντιγραφή)'
Falling back to Invidious API: 'Επιστροφή στο Invidious API'
Falling back to the local API: 'Επιστροφή στη τοπική Διεπαφή προγραμματισμού εφαρμογής
Falling back to Local API: 'Επιστροφή στη τοπική Διεπαφή προγραμματισμού εφαρμογής
(API)'
Subscriptions have not yet been implemented: 'Οι συνδρομές δεν έχουν ακόμη υλοποιηθεί'
Loop is now disabled: 'Η επανάληψη είναι πλέον απενεργοποιημένη'
@ -1097,12 +1097,6 @@ Chapters:
Chapters: Κεφάλαια
'Chapters list visible, current chapter: {chapterName}': 'Ορατή λίστα κεφαλαίων,
τρέχον κεφάλαιο: {chapterName}'
Age Restricted:
This {videoOrPlaylist} is age restricted: Αυτό το {videoOrPlaylist} έχει περιορισμό
ηλικίας
Type:
Channel: Κανάλι
Video: Βίντεο
Ok: Εντάξει
Preferences: Προτιμήσεις
Screenshot Success: Αποθηκευμένο στιγμιότυπο οθόνης ως "{filePath}"

View File

@ -32,6 +32,7 @@ Back: Back
Forward: Forward
Open New Window: Open New Window
Go to page: Go to {page}
Close Banner: Close Banner
Version {versionNumber} is now available! Click for more details: Version {versionNumber} is now available! Click
for more details
@ -186,6 +187,8 @@ User Playlists:
EarliestPlayedFirst: 'Earliest Played'
SinglePlaylistView:
Search for Videos: Search for Videos
Toast:
This video cannot be moved up.: This video cannot be moved up.
This video cannot be moved down.: This video cannot be moved down.
@ -213,6 +216,8 @@ User Playlists:
Search in Playlists: Search in Playlists
Save: Save
Added {count} Times: 'Added {count} Time | Added {count} Times'
Toast:
You haven't selected any playlist yet.: You haven't selected any playlist yet.
"{videoCount} video(s) added to 1 playlist": "1 video added to 1 playlist | {videoCount} videos added to 1 playlist"
@ -296,6 +301,7 @@ Settings:
Catppuccin Mocha: Catppuccin Mocha
Pastel Pink: Pastel Pink
Hot Pink: Hot Pink
Nordic: Nordic
Main Color Theme:
Main Color Theme: Main Color Theme
Red: Red
@ -450,7 +456,7 @@ Settings:
Hide Video Description: Hide Video Description
Hide Comments: Hide Comments
Hide Profile Pictures in Comments: Hide Profile Pictures in Comments
Display Titles Without Excessive Capitalisation: Display Titles Without Excessive Capitalisation
Display Titles Without Excessive Capitalisation: Display Titles Without Excessive Capitalisation And Punctuation
Hide Live Streams: Hide Live Streams
Hide Upcoming Premieres: Hide Upcoming Premieres
Hide Sharing Actions: Hide Sharing Actions
@ -721,6 +727,7 @@ Channel:
Hide Answers: Hide Answers
Video hidden by FreeTube: Video hidden by FreeTube
Video:
More Options: More Options
Mark As Watched: Mark As Watched
Remove From History: Remove From History
Video has been marked as watched: Video has been marked as watched
@ -954,7 +961,7 @@ Tooltips:
By default FreeTube will open the clicked link in your default browser.
Player Settings:
Force Local Backend for Legacy Formats: Only works when the Invidious API is your
default. When enabled, the local API will run and use the legacy formats returned
default. When enabled, the Local API will run and use the legacy formats returned
by that instead of the ones returned by Invidious. Helps when the videos returned
by Invidious don't play due to country restrictions.
Proxy Videos Through Invidious: Will connect to Invidious to serve videos instead
@ -1008,7 +1015,7 @@ Tooltips:
Local API Error (Click to copy): Local API Error (Click to copy)
Invidious API Error (Click to copy): Invidious API Error (Click to copy)
Falling back to Invidious API: Falling back to Invidious API
Falling back to the local API: Falling back to the local API
Falling back to Local API: Falling back to Local API
This video is unavailable because of missing formats. This can happen due to country unavailability.: This
video is unavailable because of missing formats. This can happen due to country
unavailability.
@ -1032,10 +1039,8 @@ Default Invidious instance has been cleared: Default Invidious instance has been
'The playlist has ended. Enable loop to continue playing': 'The playlist has ended. Enable
loop to continue playing'
Age Restricted:
This {videoOrPlaylist} is age restricted: This {videoOrPlaylist} is age restricted
Type:
Channel: Channel
Video: Video
This channel is age restricted: This channel is age restricted
This video is age restricted: This video is age restricted
External link opening has been disabled in the general settings: 'External link opening has been disabled in the general settings'
Downloading has completed: '"{videoTitle}" has finished downloading'
Starting download: 'Starting download of "{videoTitle}"'

View File

@ -43,6 +43,8 @@ Global:
View Count: 1 view | {count} views
Watching Count: 1 watching | {count} watching
Channel Count: 1 channel | {count} channels
Input Tags:
Length Requirement: Tag must be at least {number} characters long
Version {versionNumber} is now available! Click for more details: 'Version {versionNumber}
is now available! Click for more details'
Download From Site: 'Download from site'
@ -119,14 +121,14 @@ Playlists: 'Playlists'
User Playlists:
Your Playlists: 'Your playlists'
Playlist Message: This page is not reflective of fully working playlists. It only
lists videos that you have saved or favourited. When the work has finished, all
videos currently here will be migrated to a Favourites playlist.
lists videos that you have saved or made a Favourite. When the work has finished,
all videos currently here will be migrated to a Favourites playlist.
Your saved videos are empty. Click on the save button on the corner of a video to have it listed here: Your
saved videos are empty. Click on the save button on the corner of a video to have
it listed here
Search bar placeholder: Search in playlist
Empty Search Message: There are no videos in this playlist that match your search
Create New Playlist: Create new playlist
Create New Playlist: Create new Playlist
Add to Playlist: Add to playlist
This playlist currently has no videos.: This playlist currently has no videos.
Move Video Down: Move video down
@ -167,6 +169,14 @@ User Playlists:
There was an issue with updating this playlist.: There was an issue with updating
this playlist.
There were no videos to remove.: There were no videos to remove.
Reverted to use {oldPlaylistName} for quick bookmark: Reverted to use {oldPlaylistName}
for quick bookmark
This playlist is now used for quick bookmark: This playlist is now used for
quick bookmark
This playlist is now used for quick bookmark instead of {oldPlaylistName}. Click here to undo: This
playlist is now used for quick bookmark instead of {oldPlaylistName}. Click
here to undo
Quick bookmark disabled: Quick bookmark disabled
AddVideoPrompt:
N playlists selected: '{playlistCount} selected'
Search in Playlists: Search in playlists
@ -175,11 +185,21 @@ User Playlists:
"{videoCount} video(s) added to 1 playlist": 1 video added to 1 playlist | {videoCount}
videos added to 1 playlist
You haven't selected any playlist yet.: You haven't selected any playlist yet.
"{videoCount} video(s) added to {playlistCount} playlists": 1 video added to
{playlistCount} playlists | {videoCount} videos added to {playlistCount}
playlists
Select a playlist to add your N videos to: Select a playlist to add your video
to | Select a playlist to add your {videoCount} videos to
CreatePlaylistPrompt:
New Playlist Name: New playlist name
New Playlist Name: New Playlist name
Create: Create
Toast:
Playlist {playlistName} has been successfully created.: Playlist {playlistName}
has been successfully created.
There was an issue with creating the playlist.: There was an problem when creating
the playlist.
There is already a playlist with this name. Please pick a different name.: There
is already a Playlist with this name. Please pick a different name.
You have no playlists. Click on the create new playlist button to create a new one.: You
have no playlists. Click on the create new playlist button to create a new one.
Move Video Up: Move video up
@ -193,6 +213,9 @@ User Playlists:
Are you sure you want to delete this playlist? This cannot be undone: Are you sure
you want to delete this playlist? This cannot be undone.
Add to Favorites: Add to {playlistName}
Remove from Favorites: Remove from {playlistName}
Enable Quick Bookmark With This Playlist: Enable quick bookmark with this playlist
Disable Quick Bookmark: Disable quick bookmark
History:
# On History Page
History: 'History'
@ -230,38 +253,39 @@ Settings:
Hidden: Hidden
'Invidious Instance (Default is https://invidious.snopyta.org)': 'Invidious Instance
(Default is https://invidious.snopyta.org)'
Region for Trending: 'Region for Trending'
Region for Trending: 'Region for trending'
#! List countries
View all Invidious instance information: View all Invidious instance information
System Default: System default
External Player: External Player
External Player Executable: Custom External Player Executable
Clear Default Instance: Clear default instance
Set Current Instance as Default: Set Current Instance as Default
Set Current Instance as Default: Set current instance as default
Current instance will be randomized on startup: Current instance will be randomised
on startup
on Startup
No default instance has been set: No default instance has been set
The currently set default instance is {instance}: The currently set default instance
is {instance}
Current Invidious Instance: Current Invidious Instance
External Link Handling:
No Action: No Action
Ask Before Opening Link: Ask Before Opening Link
Open Link: Open Link
External Link Handling: External Link Handling
No Action: No action
Ask Before Opening Link: Ask before opening link
Open Link: Open link
External Link Handling: External link handling
Theme Settings:
Theme Settings: 'Theme Settings'
Theme Settings: 'Theme settings'
Match Top Bar with Main Color: 'Match top bar with main colour'
Base Theme:
Base Theme: 'Base theme'
Black: 'Black'
Dark: 'Dark'
System Default: 'System Default'
System Default: 'System default'
Light: 'Light'
Dracula: 'Dracula'
Catppuccin Mocha: Catppuccin Mocha
Pastel Pink: Pastel pink
Hot Pink: Hot pink
Nordic: Nordic
Main Color Theme:
Main Color Theme: 'Main colour theme'
Red: 'Red'
@ -307,7 +331,7 @@ Settings:
Disable Smooth Scrolling: Disable smooth scrolling
Expand Side Bar by Default: Expand side bar by default
Hide Side Bar Labels: Hide side bar labels
Hide FreeTube Header Logo: Hide FreeTube Header Logo
Hide FreeTube Header Logo: Hide FreeTube header logo
Player Settings:
Player Settings: 'Player settings'
Force Local Backend for Legacy Formats: 'Force local back-end for legacy formats'
@ -581,6 +605,7 @@ Settings:
Remove Password: Remove password
Password Settings: Password settings
Set Password: Set password
Expand All Settings Sections: Expand all settings sections
About:
#On About page
About: About
@ -951,7 +976,7 @@ Up Next: 'Up Next'
Local API Error (Click to copy): 'Local API Error (Click to copy)'
Invidious API Error (Click to copy): 'Invidious API Error (Click to copy)'
Falling back to Invidious API: 'Falling back to Invidious API'
Falling back to the local API: 'Falling back to the local API'
Falling back to Local API: 'Falling back to Local API'
Subscriptions have not yet been implemented: 'Subscriptions have not yet been implemented'
Loop is now disabled: 'Loop is now disabled'
Loop is now enabled: 'Loop is now enabled'
@ -983,7 +1008,7 @@ Tooltips:
Proxy Videos Through Invidious: Will connect to Invidious to serve videos instead
of making a direct connection to YouTube. Overrides API preference.
Force Local Backend for Legacy Formats: Only works when the Invidious API is your
default. When enabled, the local API will run and use the legacy formats returned
default. When enabled, the Local API will run and use the legacy formats returned
by that instead of the ones returned by Invidious. Helps when the videos returned
by Invidious dont play due to country restrictions.
Scroll Playback Rate Over Video Player: While the cursor is over the video, press
@ -1067,17 +1092,15 @@ New Window: New window
Channels:
Empty: Your channel list is currently empty.
Unsubscribe: Unsubscribe
Unsubscribed: '{channelName} has been removed from your subscriptions'
Unsubscribe Prompt: Are you sure you want to unsubscribe from {channelName}?
Unsubscribed: '{channelName} has been removed from your Subscriptions'
Unsubscribe Prompt: Are you sure you want to Unsubscribe from {channelName}?
Title: Channel list
Search bar placeholder: Search channels
Channels: Channels
Count: '{number} channel(s) found.'
Age Restricted:
This {videoOrPlaylist} is age restricted: This {videoOrPlaylist} is age restricted
Type:
Video: Video
Channel: Channel
This channel is age restricted: This channel is age restricted
This video is age restricted: This video is age restricted
Chapters:
'Chapters list visible, current chapter: {chapterName}': 'Chapters list visible,
current chapter: {chapterName}'
@ -1099,3 +1122,5 @@ Playlist will pause when current video is finished: Playlist will pause when cur
Playlist will not pause when current video is finished: Playlist will not pause when
current video is finished
Go to page: Go to {page}
Tag already exists: {tagName} tag already exists
Close Banner: Close Banner

View File

@ -678,7 +678,7 @@ Local API Error (Click to copy): 'Error de la API local (Presione para copiar)'
Invidious API Error (Click to copy): 'Error de la API de Invidious (Presione para
copiar)'
Falling back to Invidious API: 'Recurriendo a la API de Invidious'
Falling back to the local API: 'Recurriendo a la API local'
Falling back to Local API: 'Recurriendo a la API local'
Subscriptions have not yet been implemented: 'Las suscripciones aún no se han implementado'
Loop is now disabled: 'El bucle esta desactivado'
Loop is now enabled: 'El bucle esta activado'

View File

@ -142,6 +142,7 @@ User Playlists:
a la que añadir su vídeo | Seleccione una lista de reproducción a la que añadir
sus {videoCount} vídeos
N playlists selected: '{playlistCount} seleccionada'
Added {count} Times: Añadido {count} vez | Añadido {count} veces
SinglePlaylistView:
Toast:
There were no videos to remove.: No había vídeos que eliminar.
@ -287,6 +288,7 @@ Settings:
Catppuccin Mocha: Catppuccin Moca
Pastel Pink: Rosa pastel
Hot Pink: Rosa fuerte
Nordic: Nórdico
Main Color Theme:
Main Color Theme: 'Color principal'
Red: 'Rojo'
@ -531,8 +533,8 @@ Settings:
Hide Upcoming Premieres: Ocultar los próximos estrenos
Hide Channels: Ocultar vídeos de los canales
Hide Channels Placeholder: ID del canal
Display Titles Without Excessive Capitalisation: Mostrar títulos sin demasiadas
mayúsculas
Display Titles Without Excessive Capitalisation: Mostrar títulos sin mayúsculas
ni signos de puntuación excesivos
Hide Featured Channels: Ocultar canales recomendados
Hide Channel Playlists: Ocultar las listas de reproducción de los canales
Hide Channel Community: Ocultar canales de la comunidad
@ -954,6 +956,7 @@ Video:
Pause on Current Video: Pausa en el vídeo actual
Unhide Channel: Mostrar el canal
Hide Channel: Ocultar el canal
More Options: Más opciones
Videos:
#& Sort By
Sort By:
@ -1037,7 +1040,7 @@ Local API Error (Click to copy): 'Error de la API local (Clic para copiar el có
Invidious API Error (Click to copy): 'Error de la API de Invidious (Clic para copiar
el código)'
Falling back to Invidious API: 'Recurriendo a la API de Invidious'
Falling back to the local API: 'Recurriendo a la API local'
Falling back to Local API: 'Recurriendo a la API local'
Subscriptions have not yet been implemented: 'Todavía no se han implementado las suscripciones'
Loop is now disabled: 'Reproducción en bucle desactivada'
Loop is now enabled: 'Reproducción en bucle activada'
@ -1076,10 +1079,11 @@ Tooltips:
Proxy Videos Through Invidious: Se conectará a Invidious para obtener vídeos en
lugar de conectar directamente con YouTube. Sobreescribirá la preferencia de
API.
Force Local Backend for Legacy Formats: Solo funciona cuando la API de Invidious
es la predeterminada. la API local se ejecutará y usará los formatos heredados
en lugar de Invidious. Esto ayudará cuando Invidious no pueda reproducir un
vídeo por culpa de las restricciones regionales.
Force Local Backend for Legacy Formats: Sólo funciona cuando la API de Invidious
es la predeterminada. Si está activada, la API local se ejecutará y utilizará
los formatos heredados devueltos por ella en lugar de los devueltos por Invidious.
Es útil cuando los vídeos devueltos por Invidious no se reproducen debido a
restricciones nacionales.
Scroll Playback Rate Over Video Player: Cuando el cursor esté sobre el vídeo,
presiona y mantén la tecla Control (Comando en Mac) y desplaza la rueda del
ratón hacia arriba o abajo para cambiar la velocidad de reproducción. Presiona
@ -1183,12 +1187,6 @@ Channels:
Unsubscribe: Cancelar la suscripción
Unsubscribed: '{channelName} ha sido eliminado de tus suscripciones'
Unsubscribe Prompt: ¿Está seguro/segura de querer desuscribirse de «{channelName}»?
Age Restricted:
Type:
Channel: Canal
Video: Vídeo
This {videoOrPlaylist} is age restricted: Este {videoOrPlaylist} tiene restricción
de edad
Clipboard:
Copy failed: Error al copiar al portapapeles
Cannot access clipboard without a secure connection: No se puede acceder al portapapeles
@ -1215,3 +1213,7 @@ Channel Unhidden: '{channel} eliminado del filtro de canales'
Tag already exists: La etiqueta "{tagName}" ya existe
Trimmed input must be at least N characters long: La entrada recortada debe tener
al menos 1 carácter | La entrada recortada debe tener al menos {length} caracteres
Close Banner: Cerrar el banner
Age Restricted:
This channel is age restricted: Este canal está restringido por edad
This video is age restricted: Este vídeo está restringido por edad

View File

@ -161,6 +161,7 @@ User Playlists:
Select a playlist to add your N videos to: Vali esitusloend, kuhu soovid oma video
lisada | Vali esitusloend, kuhu soovid oma {videoCount} videot lisada
N playlists selected: '{playlistCount} valitud'
Added {count} Times: Lisatud {count} kord | Lisatud {count} korda
SinglePlaylistView:
Toast:
There were no videos to remove.: Ei leidnud ühtegi videot, mida saaks eemaldada.
@ -286,6 +287,7 @@ Settings:
Catppuccin Mocha: Catppuccin Mocha
Pastel Pink: Pastelne roosa
Hot Pink: Säravroosa
Nordic: Põhjala
Main Color Theme:
Main Color Theme: 'Põhiline värviteema'
Red: 'Punane'
@ -496,7 +498,7 @@ Settings:
Hide Channels: Peida kanalites leiduvad videod
Hide Channels Placeholder: Kanali tunnus
Display Titles Without Excessive Capitalisation: Näita pealkirju ilma liigsete
suurtähtedeta
suurtähtede ja kirjavahemärkideta
Sections:
General: Üldist
Side Bar: Külgpaan
@ -883,6 +885,7 @@ Video:
Pause on Current Video: Peata hetkel esitatav video
Unhide Channel: Näita kanalit
Hide Channel: Peida kanal
More Options: Veel valikuid
Videos:
#& Sort By
Sort By:
@ -963,7 +966,7 @@ Up Next: 'Järgmisena'
Local API Error (Click to copy): 'Kohaliku API viga (kopeerimiseks klõpsi)'
Invidious API Error (Click to copy): 'Invidious''e API viga (kopeerimiseks klõpsi)'
Falling back to Invidious API: 'Varuvariandina kasutan Invidious''e API''t'
Falling back to the local API: 'Varuvariandina kasutan kohalikku API''t'
Falling back to Local API: 'Varuvariandina kasutan kohalikku API''t'
This video is unavailable because of missing formats. This can happen due to country unavailability.: 'Kuna
vajalikke vorminguid ei leidu, siis see video pole saadaval. Niisugune viga võib
juhtuda ka maapiirangute tõttu.'
@ -1092,11 +1095,6 @@ Channels:
Unsubscribe: Loobu tellimusest
Unsubscribed: '{channelName} on sinu tellimustest eemaldatud'
Unsubscribe Prompt: Kas oled kindel, et soovid „{channelName}“ tellimusest loobuda?
Age Restricted:
This {videoOrPlaylist} is age restricted: See {videoOrPlaylist} on vanusepiiranguga
Type:
Channel: Kanal
Video: Video
Screenshot Success: Kuvatõmmis on salvestatud faili „{filePath}“
Clipboard:
Copy failed: Lõikelauale kopeerimine ei õnnestunud
@ -1124,3 +1122,7 @@ Channel Unhidden: '{channel} on eemaldatud kanalite filtrist'
Tag already exists: Silt „{tagName}“ on juba olemas
Trimmed input must be at least N characters long: Kärbitud sisend peab olema vähemalt
1 tähemärgi pikkune | Kärbitud sisend peab olema vähemalt {length} tähemärgi pikkune
Age Restricted:
This channel is age restricted: Kanali vaatamisel on vanusepiirang
This video is age restricted: Video vaatamisel on vanusepiirang
Close Banner: Sulge rekaampilt

View File

@ -34,6 +34,17 @@ Forward: 'Aurrera'
Global:
Videos: 'Bideoak'
Counts:
Subscriber Count: 1.harpideduna| {count} harpidedun
Watching Count: 1. ikuslea | {count} ikusle
Channel Count: 1. kanala ! {count} kanal
Video Count: 1. bideoa | {count} bideo
View Count: 1. ikustaldia | {count} ikustaldi
Live: Zuzenekoa
Shorts: Laburrak
Input Tags:
Length Requirement: Etiketak {zenbaki} karaktere izan behar ditu gutxienez
Community: Komunitatea
Version {versionNumber} is now available! Click for more details: '{versionNumber}
bertsioa erabilgarri! Klikatu azalpen gehiagorako'
Download From Site: 'Webgunetik jaitsi'
@ -90,6 +101,14 @@ Subscriptions:
Refresh Subscriptions: 'Harpidetzak freskatu'
Load More Videos: 'Bideo gehiago kargatu'
Error Channels: Akatsak dituzten kateak
Disabled Automatic Fetching: Harpidetza-bilaketa automatikoa desgaitu duzu. Freskatu
harpidetzak hemen ikusteko.
Empty Channels: Harpidetutako kanalek ez dute bideorik.
Empty Posts: Harpidetutako kanalek ez dute argitalpenik.
Load More Posts: Kargatu mezu gehiago
Subscriptions Tabs: Harpidetzen fitxak
All Subscription Tabs Hidden: Harpidetza fitxa guztiak ezkutatuta daude. Hemen edukia
ikusteko, erakutsi fitxa batzuk "{azpisection}" ataleko "{settingsSection}"-ean.
More: 'Gehiago'
Trending:
Trending: 'Joerak'
@ -111,6 +130,100 @@ User Playlists:
Search bar placeholder: Bilatu Erreprodukzio-zerrendan
Empty Search Message: Erreprodukzio-zerrenda honetan ez dago zure bilaketarekin
bat datorren bideorik
Remove Watched Videos: Kendu ikusitako bideoak
You have no playlists. Click on the create new playlist button to create a new one.: Ez
duzu erreprodukzio-zerrendarik. Egin klik sortu erreprodukzio-zerrenda berria
botoian berri bat sortzeko.
This playlist currently has no videos.: Erreprodukzio-zerrenda honek ez du bideorik.
Create New Playlist: Sortu erreprodukzio zerrenda berria
Add to Playlist: Gehitu erreprodukzio zerrendara
Add to Favorites: Gehitu {playlistName}-ra
Remove from Favorites: Kendu {playlistName}tik
Move Video Up: Mugitu bideoa gora
Move Video Down: Mugitu bideoa behera
Remove from Playlist: Kendu erreprodukzio zerrendatik
Playlist Name: Erreprodukzio zerrendaren izena
Playlist Description: Erreprodukzio-zerrendaren deskribapena
Save Changes: Gorde aldaketak
Cancel: Utzi
Edit Playlist Info: Editatu erreprodukzio zerrendaren informazioa
Copy Playlist: Kopiatu Erreprodukzio zerrenda
CreatePlaylistPrompt:
New Playlist Name: Erreprodukzio-zerrendaren izen berria
Toast:
Playlist {playlistName} has been successfully created.: '{playlistName} erreprodukzio-zerrenda
behar bezala sortu da.'
There was an issue with creating the playlist.: Arazo bat izan da erreprodukzio-zerrenda
sortzearekin.
There is already a playlist with this name. Please pick a different name.: Badago
dagoeneko izen honekin erreprodukzio-zerrenda bat. Mesedez, aukeratu beste
izen bat.
Create: Sortu
Enable Quick Bookmark With This Playlist: Gaitu laster-marka azkarra erreprodukzio-zerrenda
honekin
Disable Quick Bookmark: Desgaitu laster-marka azkarra
Sort By:
NameDescending: Z-A
LatestCreatedFirst: Sortu berria
EarliestCreatedFirst: Lehenen sortuak
LatestPlayedFirst: Duela gutxi erreproduzitua
EarliestPlayedFirst: Lehenen erreproduzitua
Sort By: Ordenatu honen arabera
NameAscending: A-Z
EarliestUpdatedFirst: Lehenen eguneratuak
LatestUpdatedFirst: Eguneratu berriak
Delete Playlist: Ezabatu erreprodukzio zerrenda
Are you sure you want to delete this playlist? This cannot be undone: Ziur erreprodukzio-zerrenda
ezabatu nahi duzula? Hau ezin da desegin.
SinglePlaylistView:
Toast:
Playlist has been updated.: Erreprodukzio-zerrenda eguneratu da.
"{videoCount} video(s) have been removed": bideo 1 kendu da | {videoCount} bideo
kendu dira
This video cannot be moved up.: Bideo hau ezin da gora eraman.
This video cannot be moved down.: Bideoa ezin da behera eraman.
Video has been removed: Bideoa kendu da
There was a problem with removing this video: Arazo bat izan da bideoa kentzean
This playlist is now used for quick bookmark: Erreprodukzio-zerrenda hau laster-markak
egiteko erabiltzen da orain
Quick bookmark disabled: Laster-marka azkarra desgaituta dago
This playlist is now used for quick bookmark instead of {oldPlaylistName}. Click here to undo: Erreprodukzio-zerrenda
hau laster-marketarako erabiltzen da orain {oldPlaylistName}-ren ordez. Egin
klik hemen desegiteko
Reverted to use {oldPlaylistName} for quick bookmark: '{oldPlaylistName} erabili
da berriro azkar markatzeko'
Some videos in the playlist are not loaded yet. Click here to copy anyway.: Erreprodukzio-zerrendako
bideo batzuk oraindik ez dira kargatu. Egin klik hemen kopiatzeko hala ere.
Playlist name cannot be empty. Please input a name.: Erreprodukzio zerrendaren
izena ezin da hutsik egon. Mesedez, idatzi izena.
There was an issue with updating this playlist.: Arazo bat izan da erreprodukzio-zerrenda
eguneratzean.
There were no videos to remove.: Ez zegoen kentzeko bideorik.
This playlist is protected and cannot be removed.: Erreprodukzio-zerrenda hau
babestuta dago eta ezin da kendu.
Playlist {playlistName} has been deleted.: '{playlistName} erreprodukzio-zerrenda
ezabatu da.'
This playlist does not exist: Erreprodukzio-zerrenda hau ez da existitzen
Search for Videos: Bilatu bideoak
AddVideoPrompt:
Added {count} Times: Gehitu da {count} Time | {count} aldiz gehitu da
Toast:
"{videoCount} video(s) added to 1 playlist": Bideo 1 gehitu da erreprodukzio-zerrenda
batera | {videoCount} bideo gehitu dira erreprodukzio-zerrenda batera
You haven't selected any playlist yet.: Oraindik ez duzu erreprodukzio zerrendarik
hautatu.
"{videoCount} video(s) added to {playlistCount} playlists": Bideo 1 gehitu da
{playlistCount} erreprodukzio zerrendetan | {videoCount} bideo gehitu dira
{playlistCount} erreprodukzio-zerrendetan
Select a playlist to add your N videos to: Hautatu erreprodukzio-zerrenda zure
bideoa -ra gehitzeko | Hautatu erreprodukzio-zerrenda zure {videoCount} bideoak
gehitzeko
N playlists selected: '{playlistCount} hautatuta'
Search in Playlists: Bilatu erreprodukzio-zerrendetan
Save: Gorde
Are you sure you want to remove all watched videos from this playlist? This cannot be undone: Ziur
ikusitako bideo guztiak erreprodukzio-zerrenda honetatik kendu nahi dituzula?
Hau ezin da desegin.
History:
# On History Page
History: 'Historikoa'
@ -147,6 +260,8 @@ Settings:
Beginning: 'Hasiera'
Middle: 'Erdian'
End: 'Bukaera'
Hidden: Ezkutatuta
Blur: Lausotzea
View all Invidious instance information: 'Invidious instantzia guztien informazioa
ikusi'
Region for Trending: 'Joeren eskualdea'
@ -179,6 +294,9 @@ Settings:
Dracula: Drakula
System Default: Sistemak lehenetsia
Catppuccin Mocha: Catppuccin Motxa
Nordic: nordikoa
Pastel Pink: Pastel arrosa
Hot Pink: Arrosa beroa
Main Color Theme:
Main Color Theme: 'Oinarrizko koloreen gaia'
Red: 'Gorria'
@ -221,6 +339,7 @@ Settings:
Secondary Color Theme: 'Gaiaren bigarren mailako kolorea'
#* Main Color Theme
Hide Side Bar Labels: Ezkutatu alboko barraren etiketak
Hide FreeTube Header Logo: Ezkutatu FreeTube goiburuko logotipoa
Player Settings:
Player Settings: 'Erreprodukzioaren ezarpenak'
Force Local Backend for Legacy Formats: 'Behartu backend lokala Legacy formatuentzat'
@ -272,6 +391,11 @@ Settings:
Display Play Button In Video Player: Bistaratu Erreproduzitzeko botoia bideo erreproduzitzailean
Max Video Playback Rate: Gehienezko bideoen erreprodukzio-tasa
Video Playback Rate Interval: Bideo Erreprodukzio-tasa tartea
Comment Auto Load:
Comment Auto Load: Iruzkin karga automatikoa
Skip by Scrolling Over Video Player: Saltatu bideo-erreproduzitzailean korrituz
Enter Fullscreen on Display Rotate: Sartu pantaila osoko pantaila biratu pantailan
Allow DASH AV1 formats: Baimendu DASH AV1 formatuak
Privacy Settings:
Privacy Settings: 'Pribatutasunari buruzko ezarpenak'
Remember History: 'Historikoa oroitu'
@ -289,11 +413,20 @@ Settings:
Are you sure you want to remove all subscriptions and profiles? This cannot be undone.: 'Ziur
al zaude zure profil eta harpidetza guztiak ezabatu nahi dituzula? Ezingo duzu
atzera egin.'
All playlists have been removed: Erreprodukzio-zerrenda guztiak kendu dira
Save Watched Videos With Last Viewed Playlist: Gorde ikusitako bideoak ikusitako
azken erreprodukzio-zerrendarekin
Remove All Playlists: Kendu erreprodukzio-zerrenda guztiak
Are you sure you want to remove all your playlists?: Ziur erreprodukzio-zerrenda
guztiak kendu nahi dituzula?
Subscription Settings:
Subscription Settings: 'Harpidetzen ezarpenak'
Hide Videos on Watch: 'Ikusten ari zaren bideoa ezkutatu'
Fetch Feeds from RSS: 'RSS jarioak eskuratu'
Manage Subscriptions: 'Harpidetzak kudeatu'
Fetch Automatically: Eskuratu jarioa automatikoki
Only Show Latest Video for Each Channel: Erakutsi soilik kanal bakoitzeko azken
bideoa
Distraction Free Settings:
Distraction Free Settings: 'Oharkabetasunak ekiditeko ezarpenak'
Hide Video Views: 'Bideoen ikustaldi kopurua ezkutatu'
@ -310,6 +443,38 @@ Settings:
Hide Video Description: Bideoaren deskribapena ezkutatu
Hide Comments: Iruzkinak ezkutatu
Hide Live Streams: Zuzeneko emanaldiak ezkutatu
Sections:
Side Bar: Alboko Barra
Subscriptions Page: Harpidetzak Orria
Channel Page: Kanalaren Orria
General: Orokorra
Watch Page: Ikusi Orria
Hide Profile Pictures in Comments: Ezkutatu profileko argazkiak iruzkinetan
Display Titles Without Excessive Capitalisation: Bistaratu izenburuak gehiegizko
letra larriz eta puntuaziorik gabe
Hide Channels Placeholder: Kanalaren IDa
Hide Channel Playlists: Ezkutatu kanaleko erreprodukzio-zerrendak
Hide Channel Community: Ezkutatu kanalaren komunitatea
Hide Channel Podcasts: Ezkutatu kanaleko podcastak
Hide Videos and Playlists Containing Text: Ezkutatu testua duten bideoak eta erreprodukzio-zerrendak
Hide Channels: Ezkutatu bideoak kanaletatik
Hide Upcoming Premieres: Ezkutatu datozen estreinaldiak
Hide Subscriptions Videos: Ezkutatu harpidetza-bideoak
Hide Subscriptions Shorts: Ezkutatu bideo laburren harpidetzak
Hide Channel Releases: Ezkutatu kanalen kaleratzeak
Hide Chapters: Ezkutatu kapituluak
Hide Channels Invalid: Emandako kanalaren IDa baliogabea da
Hide Channels API Error: Errore bat gertatu da emandako IDa duen erabiltzailea
berreskuratzean. Mesedez, egiaztatu berriro IDa zuzena den.
Hide Channels Already Exists: Kanalaren IDa badago jada
Hide Channel Shorts: Ezkutatu kanalaren bideo laburrak
Hide Videos and Playlists Containing Text Placeholder: Hitza, Hitzaren zatia edo
esaldia
Hide Subscriptions Live: Ezkutatu zuzenekoen harpidetzak
Hide Subscriptions Community: Ezkutatu harpidetzen komunitateak
Hide Featured Channels: Ezkutatu nabarmendutako kanalak
Hide Channels Disabled Message: Kanal batzuk IDa erabiliz blokeatu dira eta ez
dira prozesatu. Eginbidea blokeatuta dago ID horiek eguneratzen ari diren bitartean
Data Settings:
Data Settings: 'Datuen ezarpenak'
Select Import Type: 'Hautatu Inportazio mota'
@ -357,6 +522,18 @@ Settings:
esportatu dira
Playlist insufficient data: Ez da datu nahikorik "{playlist}" erreprodukzio zerrendarentzat,
elementutik ateratzen
History File: Historikoaren fitxategia
Playlist File: Erreprodukzio-zerrendaren fitxategia
Export Playlists For Older FreeTube Versions:
Label: Esportatu erreprodukzio zerrendak FreeTube bertsio zaharretarako
Tooltip: "Aukera honek erreprodukzio-zerrenda guztietako bideoak \"Gogokoak\"\
\ izeneko erreprodukzio-zerrenda batera esportatzen ditu.\nNola esportatu
eta inportatu bideoak erreprodukzio-zerrendetan FreeTube-ren bertsio zaharrago
baterako:\n 1. Esportatu zure erreprodukzio zerrendak aukera hau gaituta.\n
2. Ezabatu lehendik dituzun erreprodukzio-zerrenda guztiak Pribatutasun-ezarpenetan
dagoen Kendu zerrenda guztiak aukera erabiliz.\n 3. Abiarazi FreeTube-ren
bertsio zaharra eta inportatu esportatutako erreprodukzio-zerrendak.\""
Subscription File: Harpidetza Fitxategia
Proxy Settings:
Proxy Settings: 'Proxy-aren ezarpenak'
Enable Tor / Proxy: 'Tor / Proxy ahalbidetu'
@ -381,6 +558,10 @@ Settings:
External Player: Kanpoko erreproduzitzailea
Custom External Player Executable: Lehenetsitako kanpo erreproduzitzailea exekutagarria
Custom External Player Arguments: Lehenetsitako kanpo erreproduzitzailea argudioak
Ignore Default Arguments: Ez ikusi lehenetsitako argudioak
Players:
None:
Name: Bat ere ez
Download Settings:
Download Settings: Deskargen ezarpenak
Ask Download Path: Deskargaren ibilbidea galdetu
@ -402,11 +583,31 @@ Settings:
'SponsorBlock API Url (Default is https://sponsor.ajay.app)': Babesleak blokeatzeko
API Url (lehenetsia https://sponsor.ajay.app da)
SponsorBlock Settings: Babesleak blokeatzeko ezarpenak
UseDeArrowTitles: Erabili DeArrow bideo-izenburuak
UseDeArrowThumbnails: Erabili DeArrow irudi txikietarako
'DeArrow Thumbnail Generator API Url (Default is https://dearrow-thumb.ajay.app)': DeArrow
Thumbnail Generator API URLa (lehenetsia https://dearrow-thumb.ajay.app da)
Parental Control Settings:
Show Family Friendly Only: Erakutsi familientzat aproposa dena bakarrik
Hide Unsubscribe Button: Harpidetza kendu botoia ezkutatu
Parental Control Settings: Gurasoen kontrolaren ezarpenak
Hide Search Bar: Bilaketa barra ezkutatu
Password Dialog:
Password: Pasahitza
Password Incorrect: Pasahitza okerra
Unlock: Desblokeatu
Enter Password To Unlock: Sartu pasahitza ezarpenak desblokeatzeko
Experimental Settings:
Replace HTTP Cache: Ordeztu HTTP cachea
Experimental Settings: Ezarpen esperimentalak
Warning: Ezarpen hauek esperimentalak dira, aktibatuta dauden bitartean hutsegiteak
eragin ditzakete. Oso gomendagarria da babeskopiak egitea. Erabili zure ardurapean!
Password Settings:
Password Settings: Pasahitz ezarpenak
Set Password To Prevent Access: Ezarri pasahitz bat ezarpenetara sarbidea galarazteko
Remove Password: Pasahitza ezabatu
Set Password: Ezarri pasahitza
Expand All Settings Sections: Zabaldu ezarpen guztien atalak
About:
#On About page
About: 'Honi buruz'
@ -436,6 +637,7 @@ About:
these people and projects: 'Hurrengo pertsonak eta proiektuak'
Donate: 'Donazioa egin'
Discussions: Eztabaidak
Profile:
Profile Select: 'Hautatu profila'
Profile Filter: 'Profilaren iragazkiak'
@ -480,6 +682,12 @@ Profile:
batetik ezabatuko.'
#On Channel Page
Profile Settings: Profilaren ezarpenak
Close Profile Dropdown: Itxi goitibeherako profila
Open Profile Dropdown: Ireki goitibeherako profila
Toggle Profile List: Aldatu profilen zerrenda
Edit Profile Name: Editatu profilaren izena
Create Profile Name: Sortu profilaren izena
Profile Name: Profilaren izena
Channel:
Subscribe: 'Harpidetu'
Unsubscribe: 'Harpidetza kendu'
@ -510,6 +718,39 @@ Channel:
About: 'Honi buruz'
Channel Description: 'Kanalaren deskribapena'
Featured Channels: 'Nabarmendutako kanalak'
Joined: Bat eginda
Location: Kokapena
Tags:
Tags: Etiketak
Search for: Bilatu "{tag}"
Details: Xehetasunak
This channel does not exist: Kanal hau ez da existitzen
This channel is age-restricted and currently cannot be viewed in FreeTube.: Kanal
hau adin mugatuta dago eta une honetan ezin da ikusi FreeTube-n.
Shorts:
This channel does not currently have any shorts: Une honetan kanal honek ez du
bideo laburrik
Releases:
This channel does not currently have any releases: Une honetan kanal honek ez
du argitalpenik
Releases: Argitalpenak
Community:
Hide Answers: Erantzunak ezkutatu
votes: '{votes} bozka'
This channel currently does not have any posts: Une honetan kanal honek ez du
argitalpenik
Reveal Answers: Erantzunak agerian utzi
Video hidden by FreeTube: FreeTube-k ezkutatutako bideoa
Channel Tabs: Kanalaren fitxak
This channel does not allow searching: Kanal honek ez du bilaketarik onartzen
Live:
Live: Zuzenekoak
This channel does not currently have any live streams: Kanal honek ez du zuzeneko
erreprodukziorik
Podcasts:
Podcasts: Podcastak
This channel does not currently have any podcasts: Une honetan kanal honek ez
du podcastik
Video:
Mark As Watched: 'Ikusitako gisa jarri'
Remove From History: 'Historikotik ezabatu'
@ -592,6 +833,7 @@ Video:
Years: 'Urteak'
Ago: 'Duela'
Upcoming: 'Estreinaldiak'
In less than a minute: Minutu bat baino gutxiagoan
Published on: 'Noiz argitaratua'
Streamed on: 'Noiz zuzenean emana'
Started streaming on: 'Noiz hasi zen zuzenekoa'
@ -636,6 +878,16 @@ Video:
intro: Sarrera
Skipped segment: Saltatu egin da segmentua
Premieres on: Estreinaldiak
Hide Channel: Kanala ezkutatu
Unhide Channel: Kanala erakutsi
Premieres: Estreinaldiak
'Live Chat is unavailable for this stream. It may have been disabled by the uploader.': Zuzeneko
txata ez dago erabilgarri zuzeneko honentzat. Baliteke igo duenak desgaitu izana.
Show Super Chat Comment: Erakutsi Super txat iruzkina
Pause on Current Video: Gelditu uneko bideoa
More Options: Aukera gehiago
Upcoming: Datozenak
Scroll to Bottom: Joan Beherantz
Videos:
#& Sort By
Sort By:
@ -685,6 +937,7 @@ Share:
YouTube Channel URL copied to clipboard: 'Youtube-ko kanalaren URL-a arbelean itsatsi
da'
Share Channel: Kanala partekatu
Mini Player: 'Erreproduzitzaile txikia'
Comments:
Comments: 'Iruzkinak'
@ -710,6 +963,10 @@ Comments:
Show More Replies: Erantzun gehiago erakutsi
And others: eta bestelakoak
Pinned by: Honengatik ainguratuta
Hearted: Bihotzez
From {channelName}: '{channelName}-tik'
View {replyCount} replies: Ikusi {replyCount} erantzunak
Subscribed: Harpidetuta
Up Next: 'Hurrengoa'
#Tooltips
@ -749,11 +1006,19 @@ Tooltips:
eta mantendu (Komando tekla MAC-etan) eta ondoren saguaren ezkerreko botoia
sakatu, lehenetsitako erreprodukzio tasara itzultzeko (1x baldin eta ezarpenetan
aldaketarik egin ez bada).
Skip by Scrolling Over Video Player: Erabili korritze-gurpila bideoa saltatzeko,
MPV estiloa.
Allow DASH AV1 formats: DASH AV1 formatuak DASH H.264 formatuak baino itxura hobea
izan dezake. DASH AV1 formatuek potentzia gehiago behar dute erreproduzitzeko!
Ez daude bideo guztietan eskuragarri, kasu horietan erreproduzitzaileak DASH
H.264 formatuak erabiliko ditu ordez.
Subscription Settings:
Fetch Feeds from RSS: 'Posible denean, Freetube-k bere lehenetsitako metodoa erabili
beharrean RSS-ak baliatuko ditu zure harpidetzen jariora konektatzeko. RSS arinagoa
izateaz gain, IP-en blokeoak saihesten ditu. Aldiz, zuzenekoaren egoeraren edo
bideoaren iraupenaren informaziorik ez du ematen, besteak beste'
Fetch Automatically: Gaituta dagoenean, FreeTubek automatikoki eskuratuko du zure
harpidetza-jarioa leiho berri bat irekitzen denean eta profila aldatzean.
Privacy Settings:
Remove Video Meta Files: 'Gaituta dagoenean, FreeTube-k automatikoki ezabatzen
ditu bideoen erreprodukzioan sortutako metafitxategiak, bistaratze orria ixten
@ -772,11 +1037,34 @@ Tooltips:
Individious-en ezarpenek ez dituzte kanpo erreproduzitzaileak trabatzen.
Custom External Player Arguments: Komando-lerroko argumentu pertsonalizatuak,
puntu eta komaz bereizita (';'), kanpoko erreproduzitzailera pasatzea nahi duzu.
Ignore Default Arguments: Ez bidali argumentu lehenetsirik kanpoko erreproduzitzaileari
bideoaren URLaz gain (adibidez, erreprodukzio-tasa, erreprodukzio-zerrendaren
URLa, etab.). Argumentu pertsonalizatuak transmitituko dira oraindik.
Distraction Free Settings:
Hide Channels: Sartu kanalaren ID bat bideo, erreprodukzio-zerrenda eta kanala
bera bilaketetan, joeran, ezagunenetan eta gomendagarrienetan ager ez dadin
ezkutatzeko. Sartutako kanalaren IDak guztiz bat etorri behar du eta maiuskulak
eta minuskulak bereizten ditu.
Hide Videos and Playlists Containing Text: Idatzi hitz bat, hitz-zati bat edo
esaldi bat (maiuskulak eta minuskulak bereizten ez diren) jatorrizko izenburuak
FreeTube osoan duten bideo eta erreprodukzio-zerrenda guztiak ezkutatzeko, historia,
zure erreprodukzio-zerrendak eta erreprodukzio-zerrenden barneko bideoak soilik
kenduta.
Hide Subscriptions Live: Ezarpen hau aplikazio osorako "{appWideSetting}" ezarpenak
ordezkatzen du, "{settingsSection}" ataleko "{azpisekzioa}" atalean
Experimental Settings:
Replace HTTP Cache: Electron-en diskoan oinarritutako HTTP cachea desgaitzen du
eta memoriako irudien cache pertsonalizatua gaitu. RAM erabilera handitzea ekarriko
du.
SponsorBlock Settings:
UseDeArrowThumbnails: Ordeztu bideoaren miniaturak DeArrow-en miniaturaz.
UseDeArrowTitles: Ordeztu bideoen izenburuak DeArrow-en erabiltzaileek bidalitako
tituluekin.
Local API Error (Click to copy): 'Tokiko API-ak huts egin du (klikatu kopiatzeko)'
Invidious API Error (Click to copy): 'Individious-eko APIak huts egin du (klikatu
kopiatzeko)'
Falling back to Invidious API: 'Individious-eko APIra itzultzen'
Falling back to the local API: 'Tokiko APIra itzultzen'
Falling back to Local API: 'Tokiko APIra itzultzen'
This video is unavailable because of missing formats. This can happen due to country unavailability.: 'Bideo
hau ez dago erabilgarri, zenbait formatu eskas baitira. Honakoa zure herrialdean
erabilgarri ez dagoelako gerta daiteke.'
@ -827,9 +1115,34 @@ Channels:
Unsubscribe Prompt: Ziur al zaude "{channelName}"-ren harpidetza kendu nahi duzula?
Count: '{number} kanal aurkitu dira.'
Empty: Zure kanalen zerrenda hutsik da.
Age Restricted:
This {videoOrPlaylist} is age restricted: Honako {videoOrPlaylist} adin muga du
Type:
Channel: Kanala
Video: Bideoa
Preferences: Hobespenak
Go to page: Joan {page}-ra
Close Banner: Itxi iragarkia
Age Restricted:
This channel is age restricted: Kanal hau adin mugatuta dago
This video is age restricted: Bideo hau adin mugatuta dago
Playlist will not pause when current video is finished: Erreprodukzio-zerrenda ez
da etengo uneko bideoa amaitzen denean
Playlist will pause when current video is finished: Erreprodukzio-zerrenda pausatu
egingo da uneko bideoa amaitzen denean
Channel Unhidden: '{channel} kanalaren iragazkitik kendu da'
Clipboard:
Cannot access clipboard without a secure connection: Ezin da arbelera sartu konexio
segururik gabe
Copy failed: Ezin izan da kopiatu arbelean
Tag already exists: '"{tagName}" etiketa badago jada'
Trimmed input must be at least N characters long: Moztutako sarrerak karaktere bat
izan behar du gutxienez | Moztutako sarrerak gutxienez {length} karaktere izan behar
ditu
Chapters:
'Chapters list hidden, current chapter: {chapterName}': 'Kapituluen zerrenda ezkutatuta
dago, uneko kapitulua: {chapterName}'
Chapters: Kapituluak
'Chapters list visible, current chapter: {chapterName}': 'Kapituluen zerrenda ikusgai,
uneko kapitulua: {chapterName}'
Hashtag:
Hashtag: Traola
This hashtag does not currently have any videos: Traola honek ez du bideorik une
honetan
Ok: Ados
Channel Hidden: '{channel} gehitu da kanalaren iragazkian'

View File

@ -900,12 +900,6 @@ Screenshot Success: اسکرین شات به عنوان «{filePath}» ذخیر
Ok: تایید
Downloading has completed: دانلود '{videoTitle}' به پایان رسید
Loop is now enabled: حلقه اکنون فعال است
Age Restricted:
Type:
Video: ویدیو
Channel: کانال
This {videoOrPlaylist} is age restricted: این {videoOrPlaylist} دارای محدودیت سنی
است
Shuffle is now enabled: Shuffle اکنون فعال است
Falling back to Invidious API: بازگشت به Invidious API
Local API Error (Click to copy): خطای Local API (برای کپی کلیک کنید)
@ -913,7 +907,7 @@ Shuffle is now disabled: Shuffle اکنون غیرفعال است
Canceled next video autoplay: پخش خودکار ویدیوی بعدی لغو شد
Unknown YouTube url type, cannot be opened in app: نوع URL ناشناخته YouTube، در برنامه
باز نمی شود
Falling back to the local API: بازگشت به API محلی
Falling back to Local API: بازگشت به API محلی
This video is unavailable because of missing formats. This can happen due to country unavailability.: این
ویدیو به دلیل عدم وجود قالب در دسترس نیست. این ممکن است به دلیل در دسترس نبودن کشور
اتفاق بیفتد.

View File

@ -175,6 +175,7 @@ User Playlists:
on luotu.
Add to Favorites: Lisää soittolistaan {playlistName}
Remove from Favorites: Poista soittolistalta {playlistName}
Remove Watched Videos: Poista katsotut videot
History:
# On History Page
History: 'Historia'
@ -903,7 +904,7 @@ Up Next: 'Seuraavaksi'
Local API Error (Click to copy): 'Paikallinen API-virhe (Kopioi napsauttamalla)'
Invidious API Error (Click to copy): 'Invidious API-virhe (Kopioi napsauttamalla)'
Falling back to Invidious API: 'Palaa takaisin Invidious-sovellusliittymään'
Falling back to the local API: 'Palaa takaisin paikalliseen sovellusliittymään'
Falling back to Local API: 'Palaa takaisin paikalliseen sovellusliittymään'
Subscriptions have not yet been implemented: 'Tilauksia ei ole vielä jalkautettu'
Loop is now disabled: 'Silmukka on poistettu käytöstä'
Loop is now enabled: 'Silmukka on nyt käytössä'
@ -1063,11 +1064,6 @@ Downloading has completed: Videon "{videoTitle}" lataus on valmis
Starting download: Aloitetaan lataamaan "{videoTitle}"
Screenshot Success: Kuvakaappaus tallennettu nimellä ”{filePath}”
New Window: Uusi Ikkuna
Age Restricted:
This {videoOrPlaylist} is age restricted: Tämä {videoOrPlaylist} on ikärajoitettu
Type:
Video: Video
Channel: Kanava
Screenshot Error: Ruutukaappaus epäonnistui. {error}
Channels:
Channels: Kanavat

View File

@ -145,9 +145,10 @@ User Playlists:
You haven't selected any playlist yet.: Vous n'avez pas encore sélectionné de
liste de lecture.
"{videoCount} video(s) added to {playlistCount} playlists": 1 vidéo ajoutée
à {playlistCount} liste de lecture | {videoCount} vidéos ajoutées à {playlistCount}
listes de lecture.
à {playlistCount} listes de lecture | {videoCount} vidéos ajoutées à {playlistCount}
listes de lecture
N playlists selected: '{playlistCount} Sélectionnée(s)'
Added {count} Times: Ajouté {count} Fois | Ajouté {count} Fois
SinglePlaylistView:
Toast:
There were no videos to remove.: Il n'y avait aucune vidéo à supprimer.
@ -299,6 +300,7 @@ Settings:
Catppuccin Mocha: Catppuccin Moka
Pastel Pink: Rose pastel
Hot Pink: Rose vif
Nordic: Nordic
Main Color Theme:
Main Color Theme: 'Couleur principale du thème'
Red: 'Rouge'
@ -467,7 +469,7 @@ Settings:
sûr(e) de vouloir supprimer tous les abonnements et les profils ? Cette action
est définitive.
Remove All Subscriptions / Profiles: Supprimer tous les Abonnements / Profils
Automatically Remove Video Meta Files: Suppression automatiquement les métafichiers
Automatically Remove Video Meta Files: Supprimer automatiquement les métafichiers
vidéo
Save Watched Videos With Last Viewed Playlist: Sauvegarder les vidéos regardées
avec la dernière liste de lecture vue
@ -556,7 +558,7 @@ Settings:
Hide Channels: Masquer les vidéos des chaînes
Hide Channels Placeholder: Identifiant de la chaîne
Display Titles Without Excessive Capitalisation: Afficher les titres sans majuscules
excessives
ni ponctuation excessives
Hide Channel Playlists: Masquer les listes de lecture des chaînes
Hide Featured Channels: Masquer les chaînes en vedette
Hide Channel Community: Masquer la communauté de la chaîne
@ -945,6 +947,7 @@ Video:
Pause on Current Video: Pause sur la vidéo en cours
Hide Channel: Cacher la chaîne
Unhide Channel: Rétablir la chaîne
More Options: Plus d'options
Videos:
#& Sort By
Sort By:
@ -1029,7 +1032,7 @@ Up Next: 'À suivre'
Local API Error (Click to copy): 'Erreur d''API locale (Cliquez pour copier)'
Invidious API Error (Click to copy): 'Erreur d''API Invidious (Cliquez pour copier)'
Falling back to Invidious API: 'Revenir à l''API Invidious'
Falling back to the local API: 'Revenir à l''API locale'
Falling back to Local API: 'Revenir à l''API locale'
Subscriptions have not yet been implemented: 'Les abonnements n''ont pas encore été
implémentés'
Loop is now disabled: 'La boucle est maintenant désactivée'
@ -1225,12 +1228,6 @@ Download folder does not exist: 'Le répertoire "$" de téléchargement n''exist
Screenshot Success: Capture d'écran enregistrée sous « {filePath} »
Screenshot Error: La capture d'écran a échoué. {error}
New Window: Nouvelle fenêtre
Age Restricted:
Type:
Video: Vidéo
Channel: Chaîne
This {videoOrPlaylist} is age restricted: Ce {videoOrPlaylist} est soumis à une
limite d'âge
Channels:
Channels: Chaînes
Title: Liste des chaînes
@ -1265,4 +1262,8 @@ Channel Hidden: '{channel} ajouté au filtre de chaîne'
Channel Unhidden: '{channel} retiré du filtre de chaîne'
Trimmed input must be at least N characters long: L'entrée tronquée doit comporter
au moins 1 caractère | L'entrée tronquée doit comporter au moins {length} caractères
Tag already exists: La balise "{tagName}" existe déjà
Tag already exists: L'étiquette « {tagName} » existe déjà
Age Restricted:
This channel is age restricted: Cette chaîne est soumise à des restrictions d'âge
This video is age restricted: Cette vidéo est soumise à des restrictions d'âge
Close Banner: Fermer la bannière

View File

@ -36,6 +36,10 @@ Global:
Videos: 'Vídeos'
Community: Comunidade
Shorts: Cortos
Input Tags:
Length Requirement: A etiqueta debe ser de polo menos {number} caracteres.
Live: En vivo
Version {versionNumber} is now available! Click for more details: 'A versión {versionNumber}
está dispoñible! Fai clic para veres máis detalles'
Download From Site: 'Descargar do sitio'
@ -656,8 +660,8 @@ Video:
nesta versión.'
'Chat is disabled or the Live Stream has ended.': 'O chat foi desactivado ou a transmisión
en vivo rematou.'
Live chat is enabled. Chat messages will appear here once sent.: 'Chat en vivo
activado. As mensaxes aparecerán aquí ao seren enviadas.'
Live chat is enabled. Chat messages will appear here once sent.: 'Chat en vivo activado. As
mensaxes aparecerán aquí ao seren enviadas.'
'Live Chat is currently not supported with the Invidious API. A direct connection to YouTube is required.': 'Chat
en vivo actualmente non soportado coa API de Invidious. Precísase dunha conexión
directa con YouTube.'
@ -844,9 +848,9 @@ Tooltips:
as chamadas da aplicacion.'
Region for Trending: 'A rexión das tendencias permíteche escoller os vídeos máis
populares nun Estado.'
External Link Handling: "Escolla o comportamento predeterminado cando se fai clic\
\ nunha ligazón, que non se pode abrir en FreeTube.\nDe forma predeterminada,\
\ FreeTube abrirá a ligazón na que premeches no teu navegador predeterminado.\n"
External Link Handling: "Escolla o comportamento predeterminado cando se fai clic
nunha ligazón, que non se pode abrir en FreeTube.\nDe forma predeterminada,
FreeTube abrirá a ligazón na que premeches no teu navegador predeterminado.\n"
Player Settings:
Force Local Backend for Legacy Formats: 'Só funcionará se a API de Invidious está
escollida por defecto. Cando estea activa, a API local usará formatos antigos
@ -907,7 +911,7 @@ Tooltips:
Local API Error (Click to copy): 'Erro de API local (Preme para copiar)'
Invidious API Error (Click to copy): 'Erro de API Invidious (Preme para copiar)'
Falling back to Invidious API: 'Recorrendo á API Invidious'
Falling back to the local API: 'Recorrendo á API local'
Falling back to Local API: 'Recorrendo á API local'
This video is unavailable because of missing formats. This can happen due to country unavailability.: 'Este
vídeo non está dispoñible porque faltan formatos. Isto pode ocorrer debido á non
dispoñibilidade do país.'
@ -954,12 +958,6 @@ Downloading has completed: '"{videoTitle}" rematou de descargarse'
External link opening has been disabled in the general settings: A apertura das ligazóns
externas desactivouse na configuración xeral
Starting download: Comenzando a descarga de "{videoTitle}"
Age Restricted:
Type:
Channel: Canle
Video: Vídeo
This {videoOrPlaylist} is age restricted: Esta {videoOrPlaylist} ten restricións
de idade
Default Invidious instance has been cleared: Borrouse a instancia predeterminada de
Invidious
Screenshot Error: Produciuse un erro na captura de pantalla. {error}
@ -977,3 +975,4 @@ Chapters:
capítulo actual: {chapterName}'
Screenshot Success: Captura da pantalla gardada como "{filePath}"
Ok: De acordo
Go to page: Ir a {page}

View File

@ -49,4 +49,3 @@ Settings:
SponsorBlock Settings: {}
Channel: {}
Tooltips: {}
Age Restricted: {}

View File

@ -874,7 +874,7 @@ Up Next: 'הסרטון הבא'
Local API Error (Click to copy): 'בעיה ב־API המקומי (יש ללחוץ להעתקה)'
Invidious API Error (Click to copy): 'בעיה ב־API של Invidious (יש ללחוץ להעתקה)'
Falling back to Invidious API: 'מתבצעת נסיגה ל־API של Invidious'
Falling back to the local API: 'מתבצעת נסיגה ל־API המקומי'
Falling back to Local API: 'מתבצעת נסיגה ל־API המקומי'
This video is unavailable because of missing formats. This can happen due to country unavailability.: 'חסרות
תצורות לסרטון הזה. הדבר יכול להיגרם בגלל חוסר זמינות למדינה.'
Subscriptions have not yet been implemented: 'מנגנון המינויים עדיין לא מוכן'
@ -980,11 +980,6 @@ Playing Next Video Interval: הסרטון הבא יתחיל מייד. לחיצה
Screenshot Success: צילום המסך נשמר בתור „{filePath}”
Screenshot Error: צילום המסך נכשל. {error}
New Window: חלון חדש
Age Restricted:
Type:
Channel: ערוץ
Video: סרטון
This {videoOrPlaylist} is age restricted: '{videoOrPlaylist} זה מוגבל בגיל'
Channels:
Search bar placeholder: חיפוש ערוצים
Empty: רשימת הערוצים שלך ריקה כרגע.

View File

@ -101,8 +101,8 @@ Subscriptions:
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}”.
All Subscription Tabs Hidden: Sve kartice pretplata su skrivene. Za prikaz sadržaja
na ovom mjestu, sakrij neke kartice u pododjeljku „{subsection}” u „{settingsSection}”.
Empty Posts: Kanali na koje si pretplaćen/a trenutačno nemaju objave.
Load More Posts: Učitaj još objava
Trending:
@ -175,6 +175,14 @@ User Playlists:
Some videos in the playlist are not loaded yet. Click here to copy anyway.: Neka
videa zbirke još nisu učitani. Pritisni ovdje za kopiranje.
There were no videos to remove.: Nije bilo videa za uklanjanje.
This playlist is now used for quick bookmark: Ova se zbirka sada koristi za
brze zabilješke
Quick bookmark disabled: Brze zabilješke su deaktivirane
Reverted to use {oldPlaylistName} for quick bookmark: Vraćeno na korištenje
zbirke {oldPlaylistName} za brze zabilješke
This playlist is now used for quick bookmark instead of {oldPlaylistName}. Click here to undo: Ova
se zbirka sada koristi za brze zabilješke umjesto zbirke {oldPlaylistName}.
Pritisni ovdje za poništavanje
AddVideoPrompt:
N playlists selected: 'Odabrano: {playlistCount}'
Search in Playlists: Traži u zbirkama
@ -187,6 +195,7 @@ User Playlists:
videa dodana u 1 zbirku
Select a playlist to add your N videos to: Odaberi zbirku za dodavanje tvog videa
| Odaberi zbirku za dodavanje tvojih {videoCount} videa
Added {count} Times: Dodano {count} puta | Dodano {count} puta
CreatePlaylistPrompt:
Create: Stvori
Toast:
@ -199,6 +208,8 @@ User Playlists:
New Playlist Name: Ime nove zbirke
Add to Favorites: Dodaj u zbirku {playlistName}
Remove from Favorites: Ukloni iz zbirke {playlistName}
Enable Quick Bookmark With This Playlist: Aktiviraj brze zabilješke s ovom zbirkom
Disable Quick Bookmark: Deaktiviraj brze zabilješke
History:
# On History Page
History: 'Povijest'
@ -262,11 +273,12 @@ Settings:
Black: 'Crna'
Dark: 'Tamna'
Light: 'Svijetla'
Dracula: 'Drakula'
Dracula: 'Dracula'
System Default: Standard sustava
Catppuccin Mocha: Catppuccin Mocha
Pastel Pink: Pastelno ružičasta
Hot Pink: Vruća ružičasta
Nordic: Nordic
Main Color Theme:
Main Color Theme: 'Glavna boja teme'
Red: 'Crvena'
@ -511,7 +523,7 @@ Settings:
Hide Channels: Sakrij videa iz kanala
Hide Channels Placeholder: ID kanala
Display Titles Without Excessive Capitalisation: Prikaži naslove bez pretjeranog
korištenja velikih slova
korištenja velikih slova i interpunkcije
Hide Featured Channels: Sakrij istaknute kanale
Hide Channel Playlists: Sakrij kanal zbirki
Hide Channel Community: Sakrij kanal zajednice
@ -538,6 +550,7 @@ Settings:
provjeri točnost ID-a.
Hide Videos and Playlists Containing Text Placeholder: Riječ, fragment riječi
ili fraza
Hide Videos and Playlists Containing Text: Sakrij videa i zbirke koji sadrže tekst
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:
@ -916,7 +929,7 @@ Video:
Resolution: Razlučivost
Player Dimensions: Dimenzije playera
Bitrate: Brzina prijenosa
Volume: Glasnoća
Volume: Direktorij
Bandwidth: Propusnost
Buffered: Učitano u memoriju
Mimetype: Mimetype
@ -931,6 +944,7 @@ Video:
Pause on Current Video: Zaustavi trenutačni video
Unhide Channel: Prikaži kanal
Hide Channel: Sakrij kanal
More Options: Više opcija
Videos:
#& Sort By
Sort By:
@ -1011,7 +1025,7 @@ Up Next: 'Sljedeći'
Local API Error (Click to copy): 'Greška lokalnog sučelja (pritisni za kopiranje)'
Invidious API Error (Click to copy): 'Greška Invidious sučelja (pritisni za kopiranje)'
Falling back to Invidious API: 'Koristit će se Invidious sučelje'
Falling back to the local API: 'Koristit će se lokalno sučelje'
Falling back to Local API: 'Koristit će se lokalno sučelje'
Subscriptions have not yet been implemented: 'Pretplate još nisu implementirane'
Loop is now disabled: 'Ponavljanje je sada deaktivirano'
Loop is now enabled: 'Ponavljanje je sada aktivirano'
@ -1042,7 +1056,7 @@ Tooltips:
Proxy Videos Through Invidious: Za reprodukciju videa povezat će se s Invidiousom
umjesto izravnog povezivanja s YouTubeom. Zanemaruje postavke sučelja.
Force Local Backend for Legacy Formats: Radi samo, kad se Invidious postavi kao
standardno sučelje. Kada je aktivirano, lokalno sučelje će pokretati i koristiti
standardno sučelje. Kada je aktivirano, lokalno API sučelje će pokretati i koristiti
stare formate umjesto onih koje dostavlja Invidious. Pomaže u slučajevima, kad
je reprodukcija videa koje dostavlja Invidious u zemlji zabranjena/ograničena.
Scroll Playback Rate Over Video Player: Dok se pokazivač nalazi na videu, pritisni
@ -1107,6 +1121,10 @@ Tooltips:
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}”
Hide Videos and Playlists Containing Text: Upiši riječ, fragment riječi ili izraz
(ne razlikuje velika i mala slova) za skrivanje svih videa i zbirki s tim sadržajem
u njihovim izvornim naslovima u cijelom FreeTubeu, isključujući samo povijest,
tvoje zbirke i videa unutar zbirki.
SponsorBlock Settings:
UseDeArrowTitles: Zamijeni naslove videa koje su poslali korisnici s DeArrow naslovima.
UseDeArrowThumbnails: Zamijeni minijature videa s DeArrow minijaturama.
@ -1133,11 +1151,6 @@ Starting download: Početak preuzimanja „{videoTitle}”
Screenshot Success: Snimka ekrana je spremljena pod „{filePath}”
Screenshot Error: Neuspjela snimka ekrana. {error}
New Window: Novi prozor
Age Restricted:
This {videoOrPlaylist} is age restricted: Ovaj {videoOrPlaylist} je dobno ograničen
Type:
Channel: Kanal
Video: Video
Channels:
Channels: Kanali
Title: Popis kanala
@ -1172,3 +1185,7 @@ Channel Unhidden: '{channel} je uklonjen iz filtra kanala'
Trimmed input must be at least N characters long: Skraćeni unos mora imati barem 1
znak | Skraćeni unos mora imati barem {length} znaka
Tag already exists: Oznaka „{tagName}” već postoji
Age Restricted:
This channel is age restricted: Ovaj je dobno ograničeni kanal
This video is age restricted: Ovaj je dobno ograničeni video
Close Banner: Zatvori natpis

View File

@ -1,5 +1,5 @@
# Put the name of your locale in the same language
Locale Name: 'English (US)'
Locale Name: 'Magyar'
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.': >-
@ -147,12 +147,13 @@ User Playlists:
lejátszási listákhoz
"{videoCount} video(s) added to 1 playlist": 1 videó hozzáadva 1 lejátszási
listához | {videoCount} videó hozzáadása 1 lejátszási listához
You haven't selected any playlist yet.: Még nem választott ki lejátszási listát
sem.
You haven't selected any playlist yet.: Még nem választott ki egyetlen lejátszási
listát sem.
Select a playlist to add your N videos to: Válasszon ki egy lejátszási listát
a videó hozzáadásához | Válasszon ki egy lejátszási listát a {videoCount} videó
hozzáadásához
N playlists selected: '{playlistCount} Kiválasztott'
Added {count} Times: Hozzáadva {count} Alkalommal | Hozzáadva {count} Alkalommal
SinglePlaylistView:
Toast:
There were no videos to remove.: Nem voltak eltávolítható videók.
@ -183,6 +184,7 @@ User Playlists:
Kattints ide a visszavonáshoz
Reverted to use {oldPlaylistName} for quick bookmark: Visszaállítva a(z) {oldPlaylistName}
használatára a gyors könyvjelzőhöz
Search for Videos: Videók keresése
Are you sure you want to delete this playlist? This cannot be undone: Biztos, hogy
törölni szeretné ezt a lejátszási listát? Ezt nem lehet visszacsinálni.
Sort By:
@ -296,6 +298,7 @@ Settings:
Catppuccin Mocha: Catppuccin Mocha
Pastel Pink: Pasztell rózsaszín
Hot Pink: Forró rózsaszín
Nordic: Skandináv
Main Color Theme:
Main Color Theme: 'Fő színtéma'
Red: 'Vörös'
@ -533,14 +536,14 @@ Settings:
Hide Playlists: Lejátszási listák elrejtése
Hide Video Description: Videó leírásának elrejtése
Hide Comments: Megjegyzések elrejtése
Hide Live Streams: Élő adatfolyamok elrejtése
Hide Live Streams: Élő közvetítések elrejtése
Hide Sharing Actions: Megosztási műveletek elrejtése
Hide Chapters: Fejezetek elrejtése
Hide Upcoming Premieres: Közelgő első előadások elrejtése
Hide Channels: Videók elrejtése a csatornákból
Hide Channels Placeholder: Csatornaazonosító
Display Titles Without Excessive Capitalisation: Címek megjelenítése túlzott nagybetűk
nélkül
Display Titles Without Excessive Capitalisation: Jelenítse meg a címeket túlzott
nagybetűs írás és írásjelek nélkül
Hide Featured Channels: Kiemelt csatornák elrejtése
Hide Channel Playlists: Csatorna lejátszási listák elrejtése
Hide Channel Community: Csatornaközösség elrejtése
@ -952,6 +955,7 @@ Video:
Pause on Current Video: Jelenlegi videó szüneteltetése
Unhide Channel: Csatorna megjelenítése
Hide Channel: Csatorna elrejtése
More Options: További beállítások
Videos:
#& Sort By
Sort By:
@ -1036,7 +1040,7 @@ Up Next: 'Következő'
Local API Error (Click to copy): 'Helyi-API hiba (kattintson a másoláshoz)'
Invidious API Error (Click to copy): 'Invidious-API hiba (Kattintson a másoláshoz)'
Falling back to Invidious API: 'Invidious-API visszatérve'
Falling back to the local API: 'Helyi-API visszatérve'
Falling back to Local API: 'Helyi-API visszatérve'
This video is unavailable because of missing formats. This can happen due to country unavailability.: 'Ez
a videó hiányzó formátumok miatt nem érhető el. Ez az ország nem elérhetősége miatt
következhet be.'
@ -1088,10 +1092,10 @@ Tooltips:
videókat szolgáltasson, ahelyett, hogy közvetlen kapcsolatot létesítene a YouTube
szolgáltatással. Felülbírálja az API beállítást.
Force Local Backend for Legacy Formats: Csak akkor működik, ha az Invidious API
az alapértelmezett. Ha engedélyezve van, a helyi API futni fog, és az általa
visszaadott örökölt formátumokat fogja használni az Invidious által visszaadottak
helyett. Segít, ha az Invidious által visszaküldött videókat nem lehet lejátszani
az ország korlátozása miatt.
az alapértelmezett. Ha engedélyezve van, a helyi API fut, és az általa visszaadott
régi formátumokat használja az Invidious által visszaadottak helyett. Segít,
ha az Invidious által visszaküldött videók nem játszódnak le az országos korlátozások
miatt.
Scroll Playback Rate Over Video Player: Amíg a kurzor a videó felett van, nyomja
meg és tartsa lenyomva a Control billentyűt (Mac gépen a Command billentyű),
és görgesse az egér görgőjét előre vagy hátra a lejátszási sebesség szabályozásához.
@ -1170,11 +1174,6 @@ Channels:
Unsubscribe: Leiratkozás
Unsubscribed: '{channelName} eltávolítva az feliratkozásáiból'
Unsubscribe Prompt: Biztosan le szeretne iratkozni a(z) „{channelName}” csatornáról?
Age Restricted:
Type:
Video: Videó
Channel: Csatorna
This {videoOrPlaylist} is age restricted: A(z) {videoOrPlaylist} korhatáros
Downloading failed: Hiba történt a(z) „{videoTitle}” letöltése során
Starting download: „{videoTitle}” letöltésének indítása
Downloading has completed: A(z) „{videoTitle}” letöltése befejeződött
@ -1207,3 +1206,7 @@ Trimmed input must be at least N characters long: A vágott bemenetnek legalább
hosszúnak kell lennie | A vágott bemenetnek legalább {length} karakter hosszúnak
kell lennie
Tag already exists: '„{tagName}” címke már létezik'
Close Banner: Banner bezárása
Age Restricted:
This channel is age restricted: Ez a csatorna korhatáros
This video is age restricted: Ez a videó korhatáros

View File

@ -753,7 +753,7 @@ Up Next: 'Akan Datang'
Local API Error (Click to copy): 'API Lokal Galat (Klik untuk menyalin)'
Invidious API Error (Click to copy): 'API Invidious Galat (Klik untuk menyalin)'
Falling back to Invidious API: 'Kembali ke API Invidious'
Falling back to the local API: 'Kembali ke API lokal'
Falling back to Local API: 'Kembali ke API lokal'
Subscriptions have not yet been implemented: 'Langganan masih belum diterapkan'
Loop is now disabled: 'Putar-Ulang sekarang dimatikan'
Loop is now enabled: 'Putar-Ulang sekarang diaktifkan'

View File

@ -44,6 +44,8 @@ Global:
Subscriber Count: 1 áskrifandi | {count} áskrifendur
View Count: 1 áhorf | {count} áhorf
Watching Count: 1 að horfa | {count} að horfa
Input Tags:
Length Requirement: Merki þarf að vera a.m.k. {number} stafa langt
Version {versionNumber} is now available! Click for more details: 'Útgáfa {versionNumber}
er tiltæk! Smelltu til að skoða nánar'
Download From Site: 'Sækja af vefsvæði'
@ -174,6 +176,14 @@ User Playlists:
Some videos in the playlist are not loaded yet. Click here to copy anyway.: Sum
myndskeið í spilunarlistanum hafa ekki enn hlaðist inn. Smelltu hér til að
afrita samt.
This playlist is now used for quick bookmark: Þessi spilunarlisti er núna notaður
undir flýtibókamerki
Quick bookmark disabled: Flýtibókamerki óvirk
This playlist is now used for quick bookmark instead of {oldPlaylistName}. Click here to undo: Þessi
spilunarlisti er núna notaður undir flýtibókamerki í stað {oldPlaylistName}.
Smelltu hér til að afturkalla
Reverted to use {oldPlaylistName} for quick bookmark: Snéri aftur í að nota
{oldPlaylistName} undir flýtibókamerki
AddVideoPrompt:
N playlists selected: '{playlistCount} valin'
Search in Playlists: Leita í spilunarlistum
@ -187,6 +197,7 @@ User Playlists:
| {videoCount} myndskeiðum bætt við 1 spilunarlista
Select a playlist to add your N videos to: Veldu spilunarlista til að bæta myndskeiðinu
þínu á | Veldu spilunarlista til að bæta {videoCount}̣ myndskeiðunum þínum á
Added {count} Times: Bætt við {count} sinni | Bætt við {count} sinnum
CreatePlaylistPrompt:
Create: Búa til
New Playlist Name: Heiti á nýjum spilunarlista
@ -209,6 +220,10 @@ User Playlists:
Delete Playlist: Eyða spilunarlista
Are you sure you want to delete this playlist? This cannot be undone: Ertu viss
um að þú viljir eyða þessum spilunarlista? Aðgerðin er ekki afturkallanleg.
Add to Favorites: Bæta á {playlistName}
Remove from Favorites: Fjarlægja af {playlistName}
Enable Quick Bookmark With This Playlist: Virkja flýtibókamerki með þessum spilunarlista
Disable Quick Bookmark: Eyða flýtibókamerki
History:
# On History Page
History: 'Áhorf'
@ -283,6 +298,7 @@ Settings:
Catppuccin Mocha: Catppuccin Mocha
Pastel Pink: Pastelbleikt
Hot Pink: Dimmbleikt
Nordic: Norrænt
Main Color Theme:
Main Color Theme: 'Aðallitur þema'
Red: 'Rautt'
@ -436,6 +452,7 @@ Settings:
Hide Channels: Fela myndskeið úr rásum
Hide Channels Placeholder: Auðkenni rásar
Display Titles Without Excessive Capitalisation: Birta titla án umfram-hástafa
og greinarmerkja
Sections:
Side Bar: Hliðarspjald
Channel Page: Rásasíða
@ -461,6 +478,9 @@ Settings:
Hide Channels Already Exists: Auðkenni rásar er þegar til
Hide Channels API Error: Villa við að ná í notanda með uppgefið auðkenni. Athugaðu
aftur hvort auðkennið ré rétt.
Hide Videos and Playlists Containing Text: Fela myndskeið og spilunarlista sem
innihalda texta
Hide Videos and Playlists Containing Text Placeholder: Orð, orðhluti eða setning
Data Settings:
Data Settings: 'Stillingar gagna'
Select Import Type: 'Veldu tegund innflutnings'
@ -721,6 +741,7 @@ Channel:
Reveal Answers: Birta svör
Hide Answers: Fela svör
votes: '{votes} atkvæði'
Video hidden by FreeTube: Myndskeið falið af FreeTube
Shorts:
This channel does not currently have any shorts: Þessi rás er í augnablikinu ekki
með neinar stuttmyndir
@ -878,6 +899,7 @@ Video:
Pause on Current Video: Setja núverandi myndskeið í bið
Unhide Channel: Birta rás
Hide Channel: Fela rás
More Options: Fleiri valkostir
Videos:
#& Sort By
Sort By:
@ -1043,7 +1065,7 @@ Local API Error (Click to copy): 'Villa í staðværu API-kerfisviðmóti (smell
Invidious API Error (Click to copy): 'Villa í Invidious API-kerfisviðmóti (smella
til að afrita)'
Falling back to Invidious API: 'Nota til vara Invidious API-kerfisviðmót'
Falling back to the local API: 'Nota til vara staðvært API-kerfisviðmót'
Falling back to Local API: 'Nota til vara staðvært API-kerfisviðmót'
This video is unavailable because of missing formats. This can happen due to country unavailability.: 'Þetta
myndskeiðer ekki tiltækt vegna þess að það vantar skráasnið. Þetta getur gest ef
þau eru ekki tiltæk í viðkomandi landi.'
@ -1084,11 +1106,6 @@ Starting download: Byrja að sækja "{videoTitle}"
Downloading failed: Vandamál kom upp við að sækja "{videoTitle}"
Screenshot Error: Skjámyndataka mistókst. {error}
Screenshot Success: Vistaði skjámynd sem "{filePath}"
Age Restricted:
Type:
Channel: Rás
Video: Myndskeið
This {videoOrPlaylist} is age restricted: Þetta {videoOrPlaylist} er með aldurstakmörkunum
New Window: Nýr gluggi
Channels:
Search bar placeholder: Leita í rásum
@ -1122,3 +1139,8 @@ Playlist will not pause when current video is finished: Spilunarlisti mun ekki f
Go to page: Fara á {page}
Channel Hidden: '{channel} bætt við rásasíu'
Channel Unhidden: '{channel} fjarlægt úr rásasíu'
Close Banner: Loka borða
Age Restricted:
This video is age restricted: Þetta myndskeið er með aldurstakmörkunum
This channel is age restricted: Þessi rás er með aldurstakmörkunum
Tag already exists: '"{tagName}" merkið er þegar til staðar'

View File

@ -143,6 +143,7 @@ User Playlists:
Select a playlist to add your N videos to: Seleziona una playlist a cui aggiungere
il tuo video | Seleziona una playlist a cui aggiungere i tuoi {videoCount} video
N playlists selected: '{playlistCount} selezionate'
Added {count} Times: Aggiunto {count} volta | Aggiunti {count} volte
SinglePlaylistView:
Toast:
There were no videos to remove.: Non c'erano video da rimuovere.
@ -175,6 +176,7 @@ User Playlists:
This playlist is now used for quick bookmark instead of {oldPlaylistName}. Click here to undo: Questa
playlist è ora usata per i segnalibri rapidi al posto di {oldPlaylistName}.
Fai clic qui per annullare
Search for Videos: Cerca video
Are you sure you want to delete this playlist? This cannot be undone: Sei sicuro
di voler eliminare questa playlist? Questa operazione non può essere annullata.
Sort By:
@ -287,6 +289,7 @@ Settings:
Catppuccin Mocha: Cappuccino moka
Pastel Pink: Rosa pastello
Hot Pink: Rosa caldo
Nordic: Nordico
Main Color Theme:
Main Color Theme: 'Colore principale del tema'
Red: 'Rosso'
@ -536,8 +539,8 @@ Settings:
Hide Upcoming Premieres: Nascondi le prossime Première
Hide Channels: Nascondi i video dai canali
Hide Channels Placeholder: ID del canale
Display Titles Without Excessive Capitalisation: Visualizza i titoli senza un
uso eccessivo di maiuscole
Display Titles Without Excessive Capitalisation: Visualizza i titoli senza maiuscole
e punteggiatura eccessive
Hide Featured Channels: Nascondi i canali in evidenza
Hide Channel Playlists: Nascondi le playlist del canale
Hide Channel Community: Nascondi la comunità del canale
@ -913,6 +916,7 @@ Video:
Pause on Current Video: Pausa sul video attuale
Unhide Channel: Mostra canale
Hide Channel: Nascondi canale
More Options: Più opzioni
Videos:
#& Sort By
Sort By:
@ -996,7 +1000,7 @@ Up Next: 'Prossimi video'
Local API Error (Click to copy): 'Errore API Locale (Clicca per copiare)'
Invidious API Error (Click to copy): 'Errore API Invidious (Clicca per copiare)'
Falling back to Invidious API: 'Torno alle API Invidious'
Falling back to the local API: 'Torno alle API locali'
Falling back to Local API: 'Torno alle API locali'
Subscriptions have not yet been implemented: 'Le Iscrizioni non sono ancora state
implementate'
Loop is now disabled: 'Il loop è ora disabilitato'
@ -1177,13 +1181,6 @@ Starting download: Avvio del download di "{videoTitle}"
Downloading failed: Si è verificato un problema durante il download di "{videoTitle}"
Screenshot Success: Screenshot salvato come "{filePath}"
Screenshot Error: Screenshot non riuscito. {error}
Age Restricted:
The currently set default instance is {instance}: Questo {instance} è limitato dall'età
Type:
Channel: Canale
Video: Video
This {videoOrPlaylist} is age restricted: Questo {videoOrPlaylist} ha limiti di
età
New Window: Nuova finestra
Channels:
Unsubscribed: '{channelName} è stato rimosso dalle tue iscrizioni'
@ -1220,3 +1217,7 @@ Channel Unhidden: '{channel} rimosso dal filtro canali'
Tag already exists: Il tag "{tagName}" esiste già
Trimmed input must be at least N characters long: L'input troncato deve essere lungo
almeno 1 carattere | L'input troncato deve essere lungo almeno {length} caratteri
Age Restricted:
This video is age restricted: Questo video è soggetto a limiti di età
This channel is age restricted: Questo canale è soggetto a limiti di età
Close Banner: Chiudi banner

View File

@ -114,6 +114,10 @@ User Playlists:
このページは、完全に動作する動画リストではありません。保存またはお気に入りと設定した動画のみが表示されます。操作が完了すると、現在ここにあるすべての動画は「お気に入り」の動画リストに移動します。
Search bar placeholder: 動画リスト内の検索
Empty Search Message: この再生リストに、検索に一致する動画はありません
This playlist currently has no videos.: 存在、この再生リストには動画があっていません。
Create New Playlist: 新規再生リストを作られる
Sort By:
NameAscending: A-Z
History:
# On History Page
History: '履歴'
@ -276,7 +280,7 @@ Settings:
Folder Button: フォルダーの選択
Enter Fullscreen on Display Rotate: 横画面時にフルスクリーンにする
Skip by Scrolling Over Video Player: 動画プレーヤーでスクロールしてスキップ可能にする
Allow DASH AV1 formats: DASH AV1形式を許可する
Allow DASH AV1 formats: DASH AV1形式を許可
Comment Auto Load:
Comment Auto Load: コメント自動読み込み
Subscription Settings:
@ -794,7 +798,7 @@ Up Next: '次の動画'
Local API Error (Click to copy): '内部 API エラー(クリックするとコピー)'
Invidious API Error (Click to copy): 'Invidious API エラー(クリックするとコピー)'
Falling back to Invidious API: '代替の Invidious API に切替'
Falling back to the local API: '代替の内部 API に切替'
Falling back to Local API: '代替の内部 API に切替'
Subscriptions have not yet been implemented: '登録チャンネルは未実装です'
Loop is now disabled: 'ループ再生を無効にしました'
Loop is now enabled: 'ループ再生を有効にしました'
@ -850,7 +854,7 @@ The playlist has been reversed: 再生リストを逆順にしました
A new blog is now available, {blogTitle}. Click to view more: '新着ブログ公開、{blogTitle}。クリックしてブログを読む'
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.: この動画は、動画形式の情報が利用できないため再生できません。再生が許可されていない国で発生します。
Tooltips:
Subscription Settings:
@ -866,8 +870,8 @@ Tooltips:
Scroll Playback Rate Over Video Player: カーソルが動画上にあるとき、Ctrl キーMac では Command キーを押したまま、マウスホイールを前後にスクロールして再生速度を調整します。Control
キーMac では Command キー)を押したままマウスを左クリックすると、すぐにデフォルトの再生速度(設定を変更していない場合は 1 xに戻ります。
Skip by Scrolling Over Video Player: スクロール ホイールを使用して、ビデオ、MPV スタイルをスキップします。
Allow DASH AV1 formats: DASH H.264形式よりDASH AV1形式の方がきれいに見える可能性があるけど、再生には必要な電力がより多い。全ての動画でDASH
AV1を利用できないため、プレイヤーはDASH H.264形式に自動変更する場合があります。
Allow DASH AV1 formats: DASH H.264形式よりDASH AV1形式の方がきれいに見える可能性がありますが、再生にはより多くの処理能力が必要となります。DASH
AV1形式を使用できない場合、プレイヤーはDASH H.264形式を自動で使用します。
General Settings:
Invidious Instance: FreeTube が使用する Invidious API の接続先サーバーです。
Preferred API Backend: FreeTube が youtube からデータを取得する方法を選択します。「内部 API」とはアプリから取得する方法です。「Invidious
@ -913,12 +917,6 @@ Are you sure you want to open this link?: このリンクを開きますか?
Starting download: '"{videoTitle}" のダウンロードを開始します'
Downloading has completed: '"{videoTitle}" のダウンロードが終了しました'
Downloading failed: '"{videoTitle}" のダウンロード中に問題が発生しました'
Age Restricted:
The currently set default instance is {instance}: '{instance} は 18 歳以上の視聴者向け動画です'
Type:
Channel: チャンネル
Video: 動画
This {videoOrPlaylist} is age restricted: この {videoOrPlaylist} は年齢制限があります
Channels:
Channels: チャンネル
Unsubscribe: 登録解除
@ -947,3 +945,5 @@ Hashtag:
This hashtag does not currently have any videos: このハッシュタグには現在動画がありません
Playlist will pause when current video is finished: 現在のビデオが終了すると、プレイリストは停止します
Playlist will not pause when current video is finished: 現在のビデオが終了しても、プレイリストは停止しません
Close Banner: バナーを閉じる
Go to page: '{page}に行く'

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