Compare commits

...

24 Commits

Author SHA1 Message Date
Sveinn í Felli
b91a15277f Translated using Weblate (Icelandic)
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/is/
Translation: FreeTube/Translations
2025-11-18 18:51:33 +00:00
Oğuz Ersen
35d9663d95 Translated using Weblate (Turkish)
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/tr/
Translation: FreeTube/Translations
2025-11-18 17:52:50 +01:00
Hosted Weblate
ec724382de Merge branch 'origin/development' into Weblate. 2025-11-18 15:51:48 +01:00
Fjuro
e9c48fbb58 Translated using Weblate (Czech)
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/cs/
Translation: FreeTube/Translations
2025-11-18 15:51:27 +01:00
Priit Jõerüüt
aa745d5ead Translated using Weblate (Estonian)
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/et/
Translation: FreeTube/Translations
2025-11-18 15:51:24 +01:00
dependabot[bot]
ee2ff267d1 Bump sass from 1.93.3 to 1.94.0 (#8300)
Bumps [sass](https://github.com/sass/dart-sass) from 1.93.3 to 1.94.0.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.93.3...1.94.0)

---
updated-dependencies:
- dependency-name: sass
  dependency-version: 1.94.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 12:42:02 +00:00
absidue
24593d425e Switch to Vue's useTemplateRef() function (#8308) 2025-11-18 06:09:36 -05:00
Massimo Pissarello
92f79616d1 Translated using Weblate (Italian)
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/it/
Translation: FreeTube/Translations
2025-11-18 03:51:17 +01:00
delvani
c092104596 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: delvani <del.cidrak@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/pt_BR/
Translation: FreeTube/Translations
2025-11-18 03:51:17 +01:00
大王叫我来巡山
ac0a8b0134 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/zh_Hans/
Translation: FreeTube/Translations
2025-11-18 01:51:20 +01:00
absidue
f1d4991ca6 Workaround ready-to-show not firing consistently on wayland (#8294) 2025-11-18 08:29:14 +08:00
dependabot[bot]
c14e332051 Bump electron from 38.4.0 to 39.2.1 (#8303)
Bumps [electron](https://github.com/electron/electron) from 38.4.0 to 39.2.1.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v38.4.0...v39.2.1)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 39.2.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 08:28:19 +08:00
absidue
e7280db2b9 Migrate the WatchVideoInfo component to the composition API (#8295) 2025-11-17 19:04:22 -05:00
summoner001
bf340071ad Translated using Weblate (Hungarian)
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: summoner001 <summoner@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/hu/
Translation: FreeTube/Translations
2025-11-17 22:51:34 +01:00
Telaneo
ea71f45be5 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: Telaneo <post@telaneo.net>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/nb_NO/
Translation: FreeTube/Translations
2025-11-17 22:51:32 +01:00
Mickaël Binos
549e3f918a Translated using Weblate (French)
Currently translated at 100.0% (978 of 978 strings)

Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/free-tube/translations/fr/
Translation: FreeTube/Translations
2025-11-17 22:51:30 +01:00
dependabot[bot]
3b379dbcb4 Bump eslint-plugin-jsdoc from 61.1.12 to 61.2.1 in the eslint group (#8298)
Bumps the eslint group with 1 update: [eslint-plugin-jsdoc](https://github.com/gajus/eslint-plugin-jsdoc).


Updates `eslint-plugin-jsdoc` from 61.1.12 to 61.2.1
- [Release notes](https://github.com/gajus/eslint-plugin-jsdoc/releases)
- [Changelog](https://github.com/gajus/eslint-plugin-jsdoc/blob/main/.releaserc)
- [Commits](https://github.com/gajus/eslint-plugin-jsdoc/compare/v61.1.12...v61.2.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-jsdoc
  dependency-version: 61.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-17 20:55:29 +01:00
absidue
8695da836b Replace broken trending with a local API only equivalent (#8289) 2025-11-17 14:42:31 -05:00
absidue
a711a9730b Fix settings layout issues after the Vue 3 migration (#8292) 2025-11-17 20:17:49 +01:00
dependabot[bot]
cb14c8c4ce Bump lefthook from 2.0.3 to 2.0.4 (#8299)
Bumps [lefthook](https://github.com/evilmartians/lefthook) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: lefthook
  dependency-version: 2.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-17 17:51:39 +01:00
dependabot[bot]
8be984c2fc Bump shaka-player from 4.16.8 to 4.16.9 (#8301)
Bumps [shaka-player](https://github.com/shaka-project/shaka-player) from 4.16.8 to 4.16.9.
- [Release notes](https://github.com/shaka-project/shaka-player/releases)
- [Changelog](https://github.com/shaka-project/shaka-player/blob/v4.16.9/CHANGELOG.md)
- [Commits](https://github.com/shaka-project/shaka-player/compare/v4.16.8...v4.16.9)

---
updated-dependencies:
- dependency-name: shaka-player
  dependency-version: 4.16.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-17 17:43:38 +01:00
dependabot[bot]
fadaa2457b Bump electron-builder from 26.1.0 to 26.2.0 (#8302)
Bumps [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) from 26.1.0 to 26.2.0.
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.2.0/packages/electron-builder)

---
updated-dependencies:
- dependency-name: electron-builder
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-17 17:35:14 +01:00
absidue
fde2ade504 Fix playlist video view counts on the local API (#8290) 2025-11-16 20:39:52 -05:00
ozrendev
5e243c68ec change hardcoded color app text logoColor (#8288) 2025-11-17 09:07:19 +08:00
114 changed files with 915 additions and 1258 deletions

View File

@@ -119,6 +119,7 @@ export default [
}],
'vue/no-unused-emit-declarations': 'error',
'vue/prefer-use-template-ref': 'error',
'jsdoc/check-alignment': 'error',
'jsdoc/check-property-names': 'error',

View File

@@ -65,7 +65,7 @@
"electron-context-menu": "^4.1.1",
"marked": "^17.0.0",
"process": "^0.11.10",
"shaka-player": "^4.16.8",
"shaka-player": "^4.16.9",
"swiper": "^12.0.3",
"vue": "^3.5.24",
"vue-i18n": "^11.1.12",
@@ -84,10 +84,10 @@
"copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.2",
"electron": "^38.4.0",
"electron-builder": "^26.1.0",
"electron": "^39.2.1",
"electron-builder": "^26.2.0",
"eslint": "^9.39.1",
"eslint-plugin-jsdoc": "^61.1.12",
"eslint-plugin-jsdoc": "^61.2.1",
"eslint-plugin-jsonc": "^2.21.0",
"eslint-plugin-unicorn": "^62.0.0",
"eslint-plugin-vue": "^10.5.1",
@@ -97,13 +97,13 @@
"html-webpack-plugin": "^5.6.4",
"js-yaml": "^4.1.1",
"json-minimizer-webpack-plugin": "^5.0.1",
"lefthook": "^2.0.3",
"lefthook": "^2.0.4",
"mini-css-extract-plugin": "^2.9.4",
"neostandard": "^0.12.2",
"npm-run-all2": "^8.0.4",
"postcss": "^8.5.6",
"postcss-scss": "^4.0.9",
"sass": "^1.93.3",
"sass": "^1.94.0",
"sass-loader": "^16.0.6",
"stylelint": "^16.25.0",
"stylelint-config-sass-guidelines": "^12.1.0",

View File

@@ -1121,8 +1121,7 @@ function runApp() {
newWindow.webContents.ipc.on(IpcChannels.SEARCH_INPUT_HANDLING_READY, searchInputReadyHandler)
}
// Show when loaded
newWindow.once('ready-to-show', () => {
const showWindow = () => {
if (newWindow.isVisible()) {
// only open the dev tools if they aren't already open
if (process.env.NODE_ENV === 'development' && !newWindow.webContents.isDevToolsOpened()) {
@@ -1141,7 +1140,18 @@ function runApp() {
if (process.env.NODE_ENV === 'development') {
newWindow.webContents.openDevTools({ activate: false })
}
})
}
// The `ready-to-show` event doesn't always fire on wayland.
// Use the `did-finish-load` event on the web contents instead as that is similar enough
// https://github.com/electron/electron/issues/48859
if (process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') === 'wayland') {
newWindow.webContents.once('did-finish-load', showWindow)
} else {
// Show when loaded
newWindow.once('ready-to-show', showWindow)
}
newWindow.once('close', async () => {
if (BrowserWindow.getAllWindows().length !== 1) {
@@ -2391,7 +2401,7 @@ function runApp() {
},
type: 'normal'
},
!hideTrendingVideos && {
(!hideTrendingVideos && (backendFallback || backendPreference === 'local')) && {
label: 'Trending',
click: (_menuItem, browserWindow, _event) => {
navigateTo('/trending', browserWindow)

View File

@@ -628,7 +628,6 @@ export default defineComponent({
transformed = true
break
case 'subscriptions':
case 'trending':
case 'history':
transformedURL.pathname = `/feed/${pathParts[1]}`
transformed = true

View File

@@ -251,7 +251,7 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, useTemplateRef } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import FtCard from '../ft-card/ft-card.vue'
@@ -381,7 +381,7 @@ function search(query) {
emit('search', query)
}
const searchBar = ref(null)
const searchBar = useTemplateRef('searchBar')
/**
* @param {KeyboardEvent} event

View File

@@ -0,0 +1,3 @@
.box {
justify-content: center;
}

View File

@@ -5,7 +5,7 @@
<h4 class="groupTitle">
{{ $t('Subscriptions.Subscriptions') }}
</h4>
<FtFlexBox class="dataSettingsBox">
<FtFlexBox class="box">
<FtButton
:label="$t('Settings.Data Settings.Import Subscriptions')"
@click="importSubscriptions"
@@ -29,7 +29,7 @@
<h4 class="groupTitle">
{{ $t('History.History') }}
</h4>
<FtFlexBox class="dataSettingsBox">
<FtFlexBox class="box">
<FtButton
:label="$t('Settings.Data Settings.Import History')"
@click="importWatchHistory"
@@ -42,7 +42,7 @@
<h4 class="groupTitle">
{{ $t('Playlists') }}
</h4>
<FtFlexBox class="dataSettingsBox">
<FtFlexBox class="box">
<FtButton
:label="$t('Settings.Data Settings.Import Playlists')"
@click="importPlaylists"
@@ -55,7 +55,7 @@
<h4 class="groupTitle">
{{ t('Settings.Data Settings.Search history') }}
</h4>
<FtFlexBox class="dataSettingsBox">
<FtFlexBox class="box">
<FtButton
:label="t('Settings.Data Settings.Import search history')"
@click="importSearchHistory"
@@ -84,18 +84,18 @@
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from '../composables/use-i18n-polyfill'
import { useI18n } from '../../composables/use-i18n-polyfill'
import { useRouter } from 'vue-router'
import FtButton from './FtButton/FtButton.vue'
import FtFlexBox from './ft-flex-box/ft-flex-box.vue'
import FtPrompt from './FtPrompt/FtPrompt.vue'
import FtSettingsSection from './FtSettingsSection/FtSettingsSection.vue'
import FtButton from '../FtButton/FtButton.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtPrompt from '../FtPrompt/FtPrompt.vue'
import FtSettingsSection from '../FtSettingsSection/FtSettingsSection.vue'
import store from '../store/index'
import store from '../../store/index'
import { MAIN_PROFILE_ID } from '../../constants'
import { calculateColorLuminance, getRandomColor } from '../helpers/colors'
import { MAIN_PROFILE_ID } from '../../../constants'
import { calculateColorLuminance, getRandomColor } from '../../helpers/colors'
import {
deepCopy,
escapeHTML,
@@ -103,8 +103,8 @@ import {
readFileWithPicker,
showToast,
writeFileWithPicker,
} from '../helpers/utils'
import { processToBeAddedPlaylistVideo } from '../helpers/playlists'
} from '../../helpers/utils'
import { processToBeAddedPlaylistVideo } from '../../helpers/playlists'
const IMPORT_DIRECTORY_ID = 'data-settings-import'
const START_IN_DIRECTORY = 'downloads'
@@ -1403,3 +1403,5 @@ async function exportYouTubeSearchHistory() {
// #endregion search history
</script>
<style scoped src="./DataSettings.css" />

View File

@@ -96,8 +96,10 @@
<div class="switchColumnGrid">
<div class="switchColumn">
<FtToggleSwitch
v-if="SUPPORTS_LOCAL_API"
:label="t('Settings.Distraction Free Settings.Hide Trending Videos')"
:compact="true"
:disabled="disableHideTrendingVideos"
:default-value="hideTrendingVideos"
@change="updateHideTrendingVideos"
/>
@@ -298,6 +300,8 @@ import { checkYoutubeChannelId, findChannelTagInfo } from '../../helpers/channel
const { t } = useI18n()
const SUPPORTS_LOCAL_API = process.env.SUPPORTS_LOCAL_API
const channelHiderDisabled = ref(false)
/** @type {import('vue').ComputedRef<'local' | 'invidious'>} */
@@ -368,6 +372,7 @@ function handleHideRecommendedVideos(value) {
/** @type {import('vue').ComputedRef<boolean>} */
const hideTrendingVideos = computed(() => store.getters.getHideTrendingVideos)
const disableHideTrendingVideos = computed(() => backendPreference.value !== 'local' && !backendFallback.value)
/**
* @param {boolean} value
*/

View File

@@ -163,7 +163,7 @@
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import autolinker from 'autolinker'
import { A11y, Navigation, Pagination } from 'swiper/modules'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, useTemplateRef } from 'vue'
import FtListVideo from '../ft-list-video/ft-list-video.vue'
import FtListPlaylist from '../FtListPlaylist/FtListPlaylist.vue'
@@ -305,7 +305,7 @@ function getBestQualityImage(imageArray) {
return imageArrayCopy[0]?.url?.replace(/-c-fcrop64=[^-]+/i, '') ?? ''
}
const swiperContainerRef = ref(null)
const swiperContainerRef = useTemplateRef('swiperContainerRef')
if (postType === 'multiImage' && postContent.content.length > 0) {
onMounted(() => {

View File

@@ -43,7 +43,7 @@
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import FtButton from '../FtButton/FtButton.vue'
@@ -83,7 +83,7 @@ const playlistPersistenceDisabled = computed(() => {
return playlistName.value === '' || playlistNameBlank.value || playlistWithNameExists.value
})
const playlistNameInput = ref(null)
const playlistNameInput = useTemplateRef('playlistNameInput')
onMounted(() => {
// Faster to input required playlist name

View File

@@ -113,7 +113,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, reactive, ref, shallowRef, useId, watch } from 'vue'
import { computed, reactive, ref, shallowRef, useId, useTemplateRef, watch } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import FtTooltip from '../FtTooltip/FtTooltip.vue'
@@ -195,7 +195,7 @@ const emit = defineEmits(['clear', 'click', 'input', 'remove'])
const id = useId()
const inputRef = ref(null)
const inputRef = useTemplateRef('inputRef')
const inputData = ref(props.value)
const searchState = reactive({

View File

@@ -79,7 +79,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref, useId } from 'vue'
import { useId, useTemplateRef } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import FtInput from '../FtInput/FtInput.vue'
@@ -139,7 +139,7 @@ const { t } = useI18n()
const id = useId()
const tagNameInput = ref(null)
const tagNameInput = useTemplateRef('tagNameInput')
/**
* @param {string} text

View File

@@ -101,7 +101,7 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
@@ -282,7 +282,7 @@ const anyPlaylistContainsVideosToBeAdded = computed(() => {
return playlistIdsContainingVideosToBeAdded.value.size > 0
})
const searchBar = ref(null)
const searchBar = useTemplateRef('searchBar')
watch(allPlaylistsLength, (val, oldVal) => {
const allPlaylistIds = new Set()

View File

@@ -80,7 +80,7 @@
</template>
<script setup>
import { computed, nextTick, ref, useId } from 'vue'
import { computed, nextTick, ref, useId, useTemplateRef } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import { useRouter } from 'vue-router'
@@ -143,7 +143,7 @@ function isActiveProfile(profile) {
return profile._id === activeProfile.value._id
}
const profileListRef = ref(null)
const profileListRef = useTemplateRef('profileListRef')
function toggleProfileList() {
profileListShown.value = !profileListShown.value
@@ -178,8 +178,7 @@ function handleProfileListFocusOut() {
}
}
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const iconButton = ref(null)
const iconButton = useTemplateRef('iconButton')
function handleProfileListEscape() {
iconButton.value?.focus()

View File

@@ -56,7 +56,7 @@
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, useId } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, useId, useTemplateRef } from 'vue'
import store from '../../store/index'
@@ -103,7 +103,7 @@ const emit = defineEmits(['click'])
const id = useId()
const promptCard = ref(null)
const promptCard = useTemplateRef('promptCard')
let promptButtons = []
let lastActiveElement = null

View File

@@ -16,14 +16,14 @@
background-color: var(--card-bg-color);
padding-block: 10px;
> div {
> :deep(div) {
box-sizing: border-box;
padding-block: 0;
padding-inline: 20px;
inline-size: 100%;
}
> div:not(:last-child, .ft-flex-box) {
> :deep(div:not(:last-child, .ft-flex-box)) {
@media only screen and (width <= 800px) {
margin-block-end: 20px;
}
@@ -36,10 +36,6 @@
margin-block: 0.5em;
}
.dataSettingsBox {
justify-content: center;
}
:deep(.groupTitle) {
text-align: center;
margin-block: 0.5em;
@@ -90,10 +86,8 @@
@media only screen and (width <= 680px) {
.settingsSection {
> div {
:deep(.text.bottom) {
inset-inline-start: -85px;
}
> :deep(div .text.bottom) {
inset-inline-start: -85px;
}
:deep(.switch-ctn.containsTooltip) {
@@ -103,16 +97,14 @@
padding-inline: 10px;
}
:not(.select, .selectLabel) {
> :deep(.tooltip) {
display: inline-block;
position: absolute;
inset-inline-end: -25px;
inset-block-start: 12px;
}
:deep(:not(.select, .selectLabel) > .tooltip) {
display: inline-block;
position: absolute;
inset-inline-end: -25px;
inset-block-start: 12px;
}
.settingsFlexStart460px :deep(.tooltip) {
:deep(.settingsFlexStart460px .tooltip) {
inset-inline-end: 0;
inset-block-start: -2px;
}

View File

@@ -124,7 +124,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, ref } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { copyToClipboard, openExternalLink } from '../../helpers/utils'
import { useI18n } from '../../composables/use-i18n-polyfill'
@@ -165,7 +165,7 @@ const props = defineProps({
})
const includeTimestamp = ref(false)
const iconButton = ref(null)
const iconButton = useTemplateRef('iconButton')
const isChannel = computed(() => {
return props.shareTargetType === 'Channel'

View File

@@ -90,7 +90,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, ref, shallowRef, useId } from 'vue'
import { computed, ref, shallowRef, useId, useTemplateRef } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import FtButton from '../FtButton/FtButton.vue'
@@ -242,7 +242,7 @@ function handleSubscription(profile) {
}
}
const subscribeButton = ref(null)
const subscribeButton = useTemplateRef('subscribeButton')
function handleProfileDropdownFocusOut() {
if (subscribeButton.value && !subscribeButton.value.matches(':focus-within')) {

View File

@@ -79,6 +79,10 @@
}
}
.containsTooltip .switch-label-text {
margin-inline-end: 5px;
}
.disabled {
.switch-label {
cursor: not-allowed;

View File

@@ -16,7 +16,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, useTemplateRef } from 'vue'
import FtCard from '../ft-card/ft-card.vue'
import FtInput from '../FtInput/FtInput.vue'
@@ -25,7 +25,7 @@ import store from '../../store/index'
const emit = defineEmits(['unlocked'])
const password = ref(null)
const password = useTemplateRef('password')
onMounted(() => {
password.value.focus()

View File

@@ -263,7 +263,7 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import { useRouter } from 'vue-router'
@@ -609,7 +609,7 @@ async function savePlaylistInfo() {
}
}
const playlistTitleInput = ref(null)
const playlistTitleInput = useTemplateRef('playlistTitleInput')
function enterEditMode() {
newTitle.value = props.title
@@ -893,7 +893,7 @@ const updateQueryDebounced = debounce((newQuery) => {
emit('search-video-query-change', newQuery)
}, 500)
const searchInput = ref(null)
const searchInput = useTemplateRef('searchInput')
/**
* @param {KeyboardEvent} event

View File

@@ -55,7 +55,7 @@
</p>
</router-link>
<router-link
v-if="!hideTrendingVideos"
v-if="SUPPORTS_LOCAL_API && !hideTrendingVideos && (backendFallback || backendPreference === 'local')"
class="navOption mobileHidden"
role="button"
to="/trending"
@@ -256,6 +256,8 @@ import { KeyboardShortcuts } from '../../../constants'
const { locale, t } = useI18n()
const SUPPORTS_LOCAL_API = process.env.SUPPORTS_LOCAL_API
/** @type {import('vue').ComputedRef<boolean>} */
const isOpen = computed(() => {
return store.getters.getIsSideNavOpen

View File

@@ -56,7 +56,7 @@
</p>
</router-link>
<router-link
v-if="!hideTrendingVideos"
v-if=" SUPPORTS_LOCAL_API && trendingVisible"
class="navOption"
:title="$t('Trending.Trending')"
:aria-label="hideLabelsSideBar ? $t('Trending.Trending') : null"
@@ -197,18 +197,21 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import store from '../../store/index'
const SUPPORTS_LOCAL_API = process.env.SUPPORTS_LOCAL_API
const openMoreOptions = ref(false)
const menuRef = ref(null)
const menuRef = useTemplateRef('menuRef')
/** @type {import('vue').ComputedRef<boolean>} */
const hideTrendingVideos = computed(() => {
return store.getters.getHideTrendingVideos
const trendingVisible = computed(() => {
return !store.getters.getHideTrendingVideos &&
(store.getters.getBackendFallback || store.getters.getBackendPreference === 'local')
})
/** @type {import('vue').ComputedRef<boolean>} */

View File

@@ -123,7 +123,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import { useRoute, useRouter } from 'vue-router'
@@ -369,10 +369,8 @@ function showSearchFilters() {
store.dispatch('showSearchFilters')
}
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const searchContainer = ref(null)
/** @type {import('vue').Ref<InstanceType<typeof FtInput> | null>} */
const searchInput = ref(null)
const searchContainer = useTemplateRef('searchContainer')
const searchInput = useTemplateRef('searchInput')
/** @type {import('vue').ComputedRef<any>} */
const searchSettings = computed(() => store.getters.getSearchSettings)

View File

@@ -64,7 +64,7 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import FtCard from '../ft-card/ft-card.vue'
@@ -86,8 +86,7 @@ const props = defineProps({
const emit = defineEmits(['timestamp-event'])
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const chaptersWrapper = ref(null)
const chaptersWrapper = useTemplateRef('chaptersWrapper')
let chaptersVisible = false
const currentIndex = ref(props.currentChapterIndex)

View File

@@ -45,7 +45,7 @@
<script setup>
import autolinker from 'autolinker'
import { onMounted, ref, computed } from 'vue'
import { onMounted, ref, computed, useTemplateRef } from 'vue'
import FtCard from '../ft-card/ft-card.vue'
import FtTimestampCatcher from '../FtTimestampCatcher.vue'
@@ -67,7 +67,7 @@ const props = defineProps({
const emit = defineEmits(['timestamp-event'])
let shownDescription = ''
const descriptionContainer = ref()
const descriptionContainer = useTemplateRef('descriptionContainer')
const showFullDescription = ref(false)
const showControls = ref(false)

View File

@@ -0,0 +1,567 @@
<template>
<FtCard class="watchVideoInfo">
<div>
<h1
class="videoTitle"
>
{{ title }}
</h1>
<div
v-if="isUnlisted"
class="unlistedBadge"
>
{{ t('Video.Unlisted') }}
</div>
</div>
<div class="videoMetrics">
<div class="datePublishedAndViewCount">
{{ publishedString }} {{ dateString }}
<template
v-if="!hideVideoViews"
>
<span class="seperator"> </span><span class="videoViews">{{ parsedViewCount }}</span>
</template>
</div>
<div
v-if="!hideVideoLikesAndDislikes"
class="likeBarContainer"
>
<div
class="likeSection"
>
<span class="likeCount"><FontAwesomeIcon :icon="['fas', 'thumbs-up']" /> {{ parsedLikeCount }}</span>
</div>
</div>
</div>
<div class="videoButtons">
<div
class="profileRow"
>
<div
v-if="!hideUploader"
>
<RouterLink
:to="`/channel/${channelId}`"
>
<img
:src="channelThumbnail"
class="channelThumbnail"
alt=""
>
</RouterLink>
</div>
<div>
<div
v-if="!hideUploader"
>
<RouterLink
:to="`/channel/${channelId}`"
class="channelName"
>
{{ channelName }}
</RouterLink>
</div>
<FtSubscribeButton
v-if="!hideUnsubscribeButton"
:channel-id="channelId"
:channel-name="channelName"
:channel-thumbnail="channelThumbnail"
:subscription-count-text="subscriptionCountText"
/>
</div>
</div>
<div class="videoOptions">
<span class="videoOptionsMobileRow">
<FtIconButton
v-if="showPlaylists && !isUpcoming"
:title="t('User Playlists.Add to Playlist')"
:icon="['fas', 'plus']"
theme="base"
@click="togglePlaylistPrompt"
/>
<FtIconButton
v-if="isQuickBookmarkEnabled"
:title="quickBookmarkIconText"
:icon="isInQuickBookmarkPlaylist ? ['fas', 'check'] : ['fas', 'bookmark']"
class="quickBookmarkVideoIcon"
:class="{
bookmarked: isInQuickBookmarkPlaylist,
}"
:theme="quickBookmarkIconTheme"
@click="toggleQuickBookmarked"
/>
<FtIconButton
v-if="canSaveWatchedProgress && watchedProgressSavingInSemiAutoMode"
:title="t('Video.Save Watched Progress')"
:icon="['fas', 'bars-progress']"
@click="saveWatchedProgressManually"
/>
</span>
<span class="videoOptionsMobileRow">
<FtIconButton
v-if="USING_ELECTRON && externalPlayer !== ''"
:title="t('Video.External Player.OpenInTemplate', { externalPlayer })"
:icon="['fas', 'external-link-alt']"
theme="secondary"
@click="handleExternalPlayer"
/>
<FtIconButton
v-if="!isUpcoming && downloadLinks.length > 0"
ref="downloadButton"
:title="t('Video.Download Video')"
theme="secondary"
:icon="['fas', 'download']"
:return-index="true"
:dropdown-options="downloadLinks"
@click="handleDownload"
/>
<FtIconButton
v-if="!isUpcoming"
:title="t('Change Format.Change Media Formats')"
theme="secondary"
:icon="['fas', 'file-video']"
:dropdown-options="formatTypeOptions"
@click="changeFormat"
/>
<FtShareButton
v-if="!hideSharingActions"
:id="id"
:get-timestamp="getTimestamp"
:playlist-id="playlistId"
/>
</span>
</div>
</div>
</FtCard>
</template>
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import FtCard from '../ft-card/ft-card.vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
import FtShareButton from '../FtShareButton/FtShareButton.vue'
import FtSubscribeButton from '../FtSubscribeButton/FtSubscribeButton.vue'
import store from '../../store'
import { formatNumber, openExternalLink, showToast } from '../../helpers/utils'
const props = defineProps({
id: {
type: String,
required: true
},
title: {
type: String,
required: true
},
channelId: {
type: String,
required: true
},
channelName: {
type: String,
required: true
},
channelThumbnail: {
type: String,
required: true
},
published: {
type: Number,
required: true
},
premiereDate: {
type: Date,
default: undefined
},
viewCount: {
type: Number,
default: null
},
subscriptionCountText: {
type: String,
required: true
},
likeCount: {
type: Number,
default: 0
},
dislikeCount: {
type: Number,
default: 0
},
getTimestamp: {
type: Function,
required: true
},
isLive: {
type: Boolean,
required: false
},
isLiveContent: {
type: Boolean,
required: true
},
isUpcoming: {
type: Boolean,
required: true
},
downloadLinks: {
type: Array,
required: true
},
playlistId: {
type: String,
default: null
},
getPlaylistIndex: {
type: Function,
required: true
},
getPlaylistReverse: {
type: Function,
required: true
},
getPlaylistShuffle: {
type: Function,
required: true
},
getPlaylistLoop: {
type: Function,
required: true
},
lengthSeconds: {
type: Number,
required: true
},
videoThumbnail: {
type: String,
required: true
},
inUserPlaylist: {
type: Boolean,
required: true
},
isUnlisted: {
type: Boolean,
required: false
},
canSaveWatchedProgress: {
type: Boolean,
required: true
},
})
const emit = defineEmits([
'change-format',
'pause-player',
'set-info-area-sticky',
'scroll-to-info-area',
'save-watched-progress',
])
const USING_ELECTRON = process.env.IS_ELECTRON
const { locale, t } = useI18n()
/** @type {import('vue').ComputedRef<boolean>} */
const hideSharingActions = computed(() => store.getters.getHideSharingActions)
/** @type {import('vue').ComputedRef<boolean>} */
const hideUnsubscribeButton = computed(() => store.getters.getHideUnsubscribeButton)
/** @type {import('vue').ComputedRef<boolean>} */
const hideUploader = computed(() => store.getters.getHideUploader)
/** @type {import('vue').ComputedRef<boolean>} */
const hideVideoLikesAndDislikes = computed(() => store.getters.getHideVideoLikesAndDislikes)
const parsedLikeCount = computed(() => {
if (hideVideoLikesAndDislikes.value || props.likeCount === null) {
return null
}
return formatNumber(props.likeCount)
})
/** @type {import('vue').ComputedRef<boolean>} */
const hideVideoViews = computed(() => store.getters.getHideVideoViews)
const parsedViewCount = computed(() => {
if (hideVideoViews.value || props.viewCount == null) {
return null
}
return t('Global.Counts.View Count', { count: formatNumber(props.viewCount) }, props.viewCount)
})
const dateString = computed(() => {
const formatter = new Intl.DateTimeFormat([locale.value, 'en'], { dateStyle: 'medium' })
const localeDateString = formatter.format(props.published)
// replace spaces with no break spaces to make the date act as a single entity while wrapping
return localeDateString.replaceAll(' ', '\u00A0')
})
const publishedString = computed(() => {
if (props.isLive) {
return t('Video.Started streaming on')
} else if (props.isLiveContent && !props.isLive) {
return t('Video.Streamed on')
} else {
return t('Video.Published on')
}
})
const formatTypeOptions = computed(() => [
{
label: t('Change Format.Use Dash Formats'),
value: 'dash'
},
{
label: t('Change Format.Use Legacy Formats'),
value: 'legacy'
},
{
label: t('Change Format.Use Audio Formats'),
value: 'audio'
}
])
/**
* @param {'dash' | 'legacy' | 'audio'} value
*/
function changeFormat(value) {
emit('change-format', value)
}
const watchedProgressSavingInSemiAutoMode = computed(() => {
return store.getters.getWatchedProgressSavingMode === 'semi-auto'
})
function saveWatchedProgressManually() {
emit('save-watched-progress')
}
/** @type {import('vue').ComputedRef<boolean>} */
const rememberHistory = computed(() => store.getters.getRememberHistory)
const historyEntryExists = computed(() => store.getters.getHistoryCacheById[props.id] !== undefined)
/** @type {import('vue').ComputedRef<string>} */
const externalPlayer = computed(() => store.getters.getExternalPlayer)
/** @type {import('vue').ComputedRef<number>} */
const defaultPlayback = computed(() => store.getters.getDefaultPlayback)
function handleExternalPlayer() {
emit('pause-player')
let payload
// Only play video in non playlist mode when user playlist detected
if (props.inUserPlaylist) {
payload = {
watchProgress: props.getTimestamp(),
playbackRate: defaultPlayback.value,
videoId: props.id,
videoLength: props.lengthSeconds
}
} else {
payload = {
watchProgress: props.getTimestamp(),
playbackRate: defaultPlayback.value,
videoId: props.id,
videoLength: props.lengthSeconds,
playlistId: props.playlistId,
playlistIndex: props.getPlaylistIndex(),
playlistReverse: props.getPlaylistReverse(),
playlistShuffle: props.getPlaylistShuffle(),
playlistLoop: props.getPlaylistLoop()
}
}
store.dispatch('openInExternalPlayer', payload)
if (rememberHistory.value) {
// Marking as watched
const videoData = {
videoId: props.id,
title: props.title,
author: props.channelName,
authorId: props.channelId,
published: props.published,
description: props.description,
viewCount: props.viewCount,
lengthSeconds: props.lengthSeconds,
watchProgress: 0,
timeWatched: Date.now(),
isLive: false,
type: 'video'
}
store.dispatch('updateHistory', videoData)
if (!historyEntryExists.value) {
showToast(t('Video.Video has been marked as watched'))
}
}
}
const downloadButton = useTemplateRef('downloadButton')
/** @type {import('vue').WatchHandle | undefined} */
let downloadDropdownWatcher
onMounted(() => {
if (process.env.IS_ELECTRON || 'mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: props.title,
artist: props.channelName,
artwork: [{
src: props.videoThumbnail,
sizes: '128x128',
type: 'img/png'
}]
})
}
downloadDropdownWatcher = watch(() => downloadButton.value.dropdownShown, (dropdownShown) => {
emit('set-info-area-sticky', !dropdownShown)
if (dropdownShown && window.innerWidth >= 901) {
// adds a slight delay so we know that the dropdown has shown up
// and won't mess up our scrolling
nextTick(() => {
emit('scroll-to-info-area')
})
}
})
})
onBeforeUnmount(() => {
if (downloadDropdownWatcher) {
downloadDropdownWatcher.stop()
downloadDropdownWatcher = undefined
}
})
/** @type {import('vue').ComputedRef<'download' | 'open'>} */
const downloadBehavior = computed(() => store.getters.getDownloadBehavior)
/**
* @param {number} index
*/
function handleDownload(index) {
const selectedDownloadLinkOption = props.downloadLinks[index]
const mimeTypeUrl = selectedDownloadLinkOption.value.split('||')
if (!process.env.IS_ELECTRON || downloadBehavior.value === 'open') {
openExternalLink(mimeTypeUrl[1])
} else {
store.dispatch('downloadMedia', {
url: mimeTypeUrl[1],
title: props.title,
mimeType: mimeTypeUrl[0]
})
}
}
const showPlaylists = computed(() => !store.getters.getHidePlaylists)
function togglePlaylistPrompt() {
const videoData = {
videoId: props.id,
title: props.title,
author: props.channelName,
authorId: props.channelId,
description: props.description,
viewCount: props.viewCount,
lengthSeconds: props.lengthSeconds,
published: props.published,
premiereDate: props.premiereDate
}
store.dispatch('showAddToPlaylistPromptForManyVideos', { videos: [videoData] })
}
const quickBookmarkPlaylist = computed(() => store.getters.getQuickBookmarkPlaylist)
const isQuickBookmarkEnabled = computed(() => quickBookmarkPlaylist.value != null)
const isInQuickBookmarkPlaylist = computed(() => {
if (!isQuickBookmarkEnabled.value) { return false }
// Accessing a reactive property has a negligible amount of overhead,
// however as we know that some users have playlists that have more than 10k items in them
// it adds up quickly. So create a temporary variable outside of the array, so we only have to do it once.
// Also the search is retriggered every time any playlist is modified.
const id = props.id
return quickBookmarkPlaylist.value.videos.some((video) => {
return video.videoId === id
})
})
const quickBookmarkIconText = computed(() => {
if (!isQuickBookmarkEnabled.value) { return '' }
const translationProperties = {
playlistName: quickBookmarkPlaylist.value.playlistName,
}
return isInQuickBookmarkPlaylist.value
? t('User Playlists.Remove from Favorites', translationProperties)
: t('User Playlists.Add to Favorites', translationProperties)
})
const quickBookmarkIconTheme = computed(() => isInQuickBookmarkPlaylist.value ? 'base favorite' : 'base')
function toggleQuickBookmarked() {
if (!isQuickBookmarkEnabled.value) {
// This should be prevented by UI
return
}
if (isInQuickBookmarkPlaylist.value) {
removeFromQuickBookmarkPlaylist()
} else {
addToQuickBookmarkPlaylist()
}
}
function addToQuickBookmarkPlaylist() {
const videoData = {
videoId: props.id,
title: props.title,
author: props.channelName,
authorId: props.channelId,
lengthSeconds: props.lengthSeconds,
published: props.published,
premiereDate: props.premiereDate
}
store.dispatch('addVideo', {
_id: quickBookmarkPlaylist.value._id,
videoData,
})
// TODO: Maybe show playlist name
showToast(t('Video.Video has been saved'))
}
function removeFromQuickBookmarkPlaylist() {
store.dispatch('removeVideo', {
_id: quickBookmarkPlaylist.value._id,
// Remove all playlist items with same videoId
videoId: props.id,
})
// TODO: Maybe show playlist name
showToast(t('Video.Video has been removed from your saved list'))
}
</script>
<style scoped src="./WatchVideoInfo.css" />

View File

@@ -29,7 +29,6 @@
</div>
<div
v-else-if="comments.length === 0"
ref="liveChatMessage"
class="messageContainer liveChatMessage"
>
<p
@@ -225,7 +224,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import autolinker from 'autolinker'
import { computed, nextTick, onBeforeUnmount, ref, shallowReactive } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, shallowReactive, useTemplateRef } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import { YTNodes } from 'youtubei.js'
@@ -370,8 +369,7 @@ function startLiveChatLocal() {
liveChatInstance.start()
}
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const commentsRef = ref(null)
const commentsRef = useTemplateRef('commentsRef')
/**
* @param {import ('youtubei.js/dist/src/parser/continuations').LiveChatContinuation} initialData

View File

@@ -92,7 +92,9 @@ export default defineComponent({
},
defaultPages: function () {
let includedPageNames = this.includedDefaultPageNames
if (this.hideTrendingVideos) includedPageNames = includedPageNames.filter((pageName) => pageName !== 'trending')
if (!process.env.SUPPORTS_LOCAL_API || this.hideTrendingVideos || !this.backendFallback || this.backendPreference !== 'local') {
includedPageNames = includedPageNames.filter((pageName) => pageName !== 'trending')
}
if (this.hidePlaylists) includedPageNames = includedPageNames.filter((pageName) => pageName !== 'userPlaylists')
if (!(!this.hidePopularVideos && (this.backendFallback || this.backendPreference === 'invidious'))) includedPageNames = includedPageNames.filter((pageName) => pageName !== 'popular')
return this.$router.getRoutes().filter((route) => includedPageNames.includes(route.name))

View File

@@ -1,470 +0,0 @@
import { defineComponent, nextTick } from 'vue'
import { mapActions } from 'vuex'
import FtCard from '../ft-card/ft-card.vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
import FtShareButton from '../FtShareButton/FtShareButton.vue'
import FtSubscribeButton from '../FtSubscribeButton/FtSubscribeButton.vue'
import { formatNumber, openExternalLink, showToast } from '../../helpers/utils'
export default defineComponent({
name: 'WatchVideoInfo',
components: {
'ft-card': FtCard,
'ft-icon-button': FtIconButton,
'ft-share-button': FtShareButton,
'ft-subscribe-button': FtSubscribeButton
},
props: {
id: {
type: String,
required: true
},
title: {
type: String,
required: true
},
channelId: {
type: String,
required: true
},
channelName: {
type: String,
required: true
},
channelThumbnail: {
type: String,
required: true
},
published: {
type: Number,
required: true
},
premiereDate: {
type: Date,
default: undefined
},
viewCount: {
type: Number,
default: null
},
subscriptionCountText: {
type: String,
required: true
},
likeCount: {
type: Number,
default: 0
},
dislikeCount: {
type: Number,
default: 0
},
getTimestamp: {
type: Function,
required: true
},
isLive: {
type: Boolean,
required: false
},
isLiveContent: {
type: Boolean,
required: true
},
isUpcoming: {
type: Boolean,
required: true
},
downloadLinks: {
type: Array,
required: true
},
playlistId: {
type: String,
default: null
},
getPlaylistIndex: {
type: Function,
required: true
},
getPlaylistReverse: {
type: Function,
required: true
},
getPlaylistShuffle: {
type: Function,
required: true
},
getPlaylistLoop: {
type: Function,
required: true
},
lengthSeconds: {
type: Number,
required: true
},
videoThumbnail: {
type: String,
required: true
},
inUserPlaylist: {
type: Boolean,
required: true
},
isUnlisted: {
type: Boolean,
required: false
},
canSaveWatchedProgress: {
type: Boolean,
required: true
},
},
emits: [
'change-format',
'pause-player',
'set-info-area-sticky',
'scroll-to-info-area',
'save-watched-progress',
],
data: function () {
return {
usingElectron: process.env.IS_ELECTRON
}
},
computed: {
hideSharingActions: function() {
return this.$store.getters.getHideSharingActions
},
hideUnsubscribeButton: function() {
return this.$store.getters.getHideUnsubscribeButton
},
currentLocale: function () {
return this.$i18n.locale
},
hideVideoLikesAndDislikes: function () {
return this.$store.getters.getHideVideoLikesAndDislikes
},
hideVideoViews: function () {
return this.$store.getters.getHideVideoViews
},
hideUploader: function () {
return this.$store.getters.getHideUploader
},
showPlaylists: function () {
return !this.$store.getters.getHidePlaylists
},
watchedProgressSavingInSemiAutoMode() {
return this.$store.getters.getWatchedProgressSavingMode === 'semi-auto'
},
rememberHistory() {
return this.$store.getters.getRememberHistory
},
downloadBehavior: function () {
return this.$store.getters.getDownloadBehavior
},
formatTypeOptions: function () {
return [
{
label: this.$t('Change Format.Use Dash Formats'),
value: 'dash'
},
{
label: this.$t('Change Format.Use Legacy Formats'),
value: 'legacy'
},
{
label: this.$t('Change Format.Use Audio Formats'),
value: 'audio'
}
]
},
totalLikeCount: function () {
return this.likeCount + this.dislikeCount
},
parsedLikeCount: function () {
if (this.hideVideoLikesAndDislikes || this.likeCount === null) {
return null
}
return formatNumber(this.likeCount)
},
parsedDislikeCount: function () {
if (this.hideVideoLikesAndDislikes || this.dislikeCount === null) {
return null
}
return formatNumber(this.dislikeCount)
},
likePercentageRatio: function () {
return parseInt(this.likeCount / this.totalLikeCount * 100)
},
parsedViewCount: function () {
if (this.hideVideoViews || this.viewCount == null) {
return null
}
return this.$t('Global.Counts.View Count', { count: formatNumber(this.viewCount) }, this.viewCount)
},
dateString: function () {
const date = new Date(this.published)
const localeDateString = new Intl.DateTimeFormat([this.currentLocale, 'en'], { dateStyle: 'medium' }).format(date)
// replace spaces with no break spaces to make the date act as a single entity while wrapping
return `${localeDateString}`.replaceAll(' ', '\u00A0')
},
publishedString: function () {
if (this.isLive) {
return this.$t('Video.Started streaming on')
} else if (this.isLiveContent && !this.isLive) {
return this.$t('Video.Streamed on')
} else {
return this.$t('Video.Published on')
}
},
externalPlayer: function () {
return this.$store.getters.getExternalPlayer
},
historyEntry: function () {
return this.$store.getters.getHistoryCacheById[this.id]
},
historyEntryExists: function () {
return typeof this.historyEntry !== 'undefined'
},
defaultPlayback: function () {
return this.$store.getters.getDefaultPlayback
},
quickBookmarkPlaylist() {
return this.$store.getters.getQuickBookmarkPlaylist
},
isQuickBookmarkEnabled() {
return this.quickBookmarkPlaylist != null
},
isInQuickBookmarkPlaylist: function () {
if (!this.isQuickBookmarkEnabled) { return false }
// Accessing a reactive property has a negligible amount of overhead,
// however as we know that some users have playlists that have more than 10k items in them
// it adds up quickly. So create a temporary variable outside of the array, so we only have to do it once.
// Also the search is retriggered every time any playlist is modified.
const id = this.id
return this.quickBookmarkPlaylist.videos.some((video) => {
return video.videoId === id
})
},
quickBookmarkIconText: function () {
if (!this.isQuickBookmarkEnabled) { return false }
const translationProperties = {
playlistName: this.quickBookmarkPlaylist.playlistName,
}
return this.isInQuickBookmarkPlaylist
? this.$t('User Playlists.Remove from Favorites', translationProperties)
: this.$t('User Playlists.Add to Favorites', translationProperties)
},
quickBookmarkIconTheme: function () {
return this.isInQuickBookmarkPlaylist ? 'base favorite' : 'base'
},
},
mounted: function () {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: this.title,
artist: this.channelName,
artwork: [
{
src: this.videoThumbnail,
sizes: '128x128',
type: 'image/png'
}
]
})
this.$watch('$refs.downloadButton.dropdownShown', (dropdownShown) => {
this.$emit('set-info-area-sticky', !dropdownShown)
if (dropdownShown && window.innerWidth >= 901) {
// adds a slight delay so we know that the dropdown has shown up
// and won't mess up our scrolling
nextTick(() => {
this.$emit('scroll-to-info-area')
})
}
})
}
},
methods: {
handleExternalPlayer: function () {
this.$emit('pause-player')
const payload = {
watchProgress: this.getTimestamp(),
playbackRate: this.defaultPlayback,
videoId: this.id,
videoLength: this.lengthSeconds,
playlistId: this.playlistId,
playlistIndex: this.getPlaylistIndex(),
playlistReverse: this.getPlaylistReverse(),
playlistShuffle: this.getPlaylistShuffle(),
playlistLoop: this.getPlaylistLoop(),
}
// Only play video in non playlist mode when user playlist detected
if (this.inUserPlaylist) {
Object.assign(payload, {
playlistId: null,
playlistIndex: null,
playlistReverse: null,
playlistShuffle: null,
playlistLoop: null,
})
}
this.openInExternalPlayer(payload)
if (this.rememberHistory) {
// Marking as watched
const videoData = {
videoId: this.id,
title: this.title,
author: this.channelName,
authorId: this.channelId,
published: this.published,
description: this.description,
viewCount: this.viewCount,
lengthSeconds: this.lengthSeconds,
watchProgress: 0,
timeWatched: Date.now(),
isLive: false,
type: 'video'
}
this.updateHistory(videoData)
if (!this.historyEntryExists) {
showToast(this.$t('Video.Video has been marked as watched'))
}
}
},
handleDownload: function (index) {
const selectedDownloadLinkOption = this.downloadLinks[index]
const mimeTypeUrl = selectedDownloadLinkOption.value.split('||')
if (!process.env.IS_ELECTRON || this.downloadBehavior === 'open') {
openExternalLink(mimeTypeUrl[1])
} else {
this.downloadMedia({
url: mimeTypeUrl[1],
title: this.title,
mimeType: mimeTypeUrl[0]
})
}
},
grabExtensionFromUrl: function (url) {
const regex = /\/(\w*)/i
const group = url.match(regex)
if (group.length === 0) {
return ''
}
return group[1]
},
togglePlaylistPrompt: function () {
const videoData = {
videoId: this.id,
title: this.title,
author: this.channelName,
authorId: this.channelId,
description: this.description,
viewCount: this.viewCount,
lengthSeconds: this.lengthSeconds,
published: this.published,
premiereDate: this.premiereDate,
}
this.showAddToPlaylistPromptForManyVideos({ videos: [videoData] })
},
toggleQuickBookmarked() {
if (!this.isQuickBookmarkEnabled) {
// This should be prevented by UI
return
}
if (this.isInQuickBookmarkPlaylist) {
this.removeFromQuickBookmarkPlaylist()
} else {
this.addToQuickBookmarkPlaylist()
}
},
addToQuickBookmarkPlaylist() {
const videoData = {
videoId: this.id,
title: this.title,
author: this.channelName,
authorId: this.channelId,
lengthSeconds: this.lengthSeconds,
published: this.published,
premiereDate: this.premiereDate,
}
this.addVideo({
_id: this.quickBookmarkPlaylist._id,
videoData,
})
// TODO: Maybe show playlist name
showToast(this.$t('Video.Video has been saved'))
},
removeFromQuickBookmarkPlaylist() {
this.removeVideo({
_id: this.quickBookmarkPlaylist._id,
// Remove all playlist items with same videoId
videoId: this.id,
})
// TODO: Maybe show playlist name
showToast(this.$t('Video.Video has been removed from your saved list'))
},
changeFormat: function(value) {
this.$emit('change-format', value)
},
saveWatchedProgressManually() {
this.$emit('save-watched-progress')
},
...mapActions([
'openInExternalPlayer',
'downloadMedia',
'showAddToPlaylistPromptForManyVideos',
'addVideo',
'updateHistory',
'removeVideo',
])
}
})

View File

@@ -1,159 +0,0 @@
<template>
<ft-card class="watchVideoInfo">
<div>
<h1
class="videoTitle"
>
{{ title }}
</h1>
<div
v-if="isUnlisted"
class="unlistedBadge"
>
{{ $t('Video.Unlisted') }}
</div>
</div>
<div class="videoMetrics">
<div class="datePublishedAndViewCount">
{{ publishedString }} {{ dateString }}
<template
v-if="!hideVideoViews"
>
<span class="seperator"> </span><span class="videoViews">{{ parsedViewCount }}</span>
</template>
</div>
<div
v-if="!hideVideoLikesAndDislikes"
class="likeBarContainer"
>
<div
class="likeSection"
>
<span class="likeCount"><font-awesome-icon :icon="['fas', 'thumbs-up']" /> {{ parsedLikeCount }}</span>
</div>
</div>
<!--
// Uncomment if suitable solution for bringing back dislikes is introduced
<div
v-if="!hideVideoLikesAndDislikes"
class="likeBarContainer"
>
<div
class="likeSection"
>
<div
class="likeBar"
:style="{ background: `linear-gradient(to right, var(--accent-color) ${likePercentageRatio}%, #9E9E9E ${likePercentageRatio}%` }"
/>
<div>
<span class="likeCount"><font-awesome-icon :icon="['fas', 'thumbs-up']" /> {{ parsedLikeCount }}</span>
<span class="dislikeCount"><font-awesome-icon :icon="['fas', 'thumbs-down']" /> {{ parsedDislikeCount }}</span>
</div>
</div>
</div>
-->
</div>
<div class="videoButtons">
<div
class="profileRow"
>
<div
v-if="!hideUploader"
>
<router-link
:to="`/channel/${channelId}`"
>
<img
:src="channelThumbnail"
class="channelThumbnail"
alt=""
>
</router-link>
</div>
<div>
<div
v-if="!hideUploader"
>
<router-link
:to="`/channel/${channelId}`"
class="channelName"
>
{{ channelName }}
</router-link>
</div>
<ft-subscribe-button
v-if="!hideUnsubscribeButton"
:channel-id="channelId"
:channel-name="channelName"
:channel-thumbnail="channelThumbnail"
:subscription-count-text="subscriptionCountText"
/>
</div>
</div>
<div class="videoOptions">
<span class="videoOptionsMobileRow">
<ft-icon-button
v-if="showPlaylists && !isUpcoming"
:title="$t('User Playlists.Add to Playlist')"
:icon="['fas', 'plus']"
theme="base"
@click="togglePlaylistPrompt"
/>
<ft-icon-button
v-if="isQuickBookmarkEnabled"
:title="quickBookmarkIconText"
:icon="isInQuickBookmarkPlaylist ? ['fas', 'check'] : ['fas', 'bookmark']"
class="quickBookmarkVideoIcon"
:class="{
bookmarked: isInQuickBookmarkPlaylist,
}"
:theme="quickBookmarkIconTheme"
@click="toggleQuickBookmarked"
/>
<ft-icon-button
v-if="canSaveWatchedProgress && watchedProgressSavingInSemiAutoMode"
:title="$t('Video.Save Watched Progress')"
:icon="['fas', 'bars-progress']"
@click="saveWatchedProgressManually"
/>
</span>
<span class="videoOptionsMobileRow">
<ft-icon-button
v-if="usingElectron && externalPlayer !== ''"
:title="$t('Video.External Player.OpenInTemplate', { externalPlayer })"
:icon="['fas', 'external-link-alt']"
theme="secondary"
@click="handleExternalPlayer"
/>
<ft-icon-button
v-if="!isUpcoming && downloadLinks.length > 0"
ref="downloadButton"
:title="$t('Video.Download Video')"
theme="secondary"
:icon="['fas', 'download']"
:return-index="true"
:dropdown-options="downloadLinks"
@click="handleDownload"
/>
<ft-icon-button
v-if="!isUpcoming"
:title="$t('Change Format.Change Media Formats')"
theme="secondary"
:icon="['fas', 'file-video']"
:dropdown-options="formatTypeOptions"
@click="changeFormat"
/>
<ft-share-button
v-if="!hideSharingActions"
:id="id"
:get-timestamp="getTimestamp"
:playlist-id="playlistId"
/>
</span>
</div>
</div>
</ft-card>
</template>
<script src="./watch-video-info.js" />
<style scoped src="./watch-video-info.css" />

View File

@@ -529,43 +529,6 @@ export async function getInvidiousPopularFeed() {
return items
}
/**
* @param {'default' | 'music' | 'gaming' | 'movies'} tab
* @param {string} region
* @returns {Promise<InvidiousVideoType[] | null>}
*/
export async function getInvidiousTrending(tab, region) {
const params = {
resource: 'trending',
id: '',
params: {
region
}
}
if (tab !== 'default') {
params.params.type = tab.charAt(0).toUpperCase() + tab.substring(1)
}
const response = await invidiousAPICall(params)
if (!response) {
return null
}
const items = response.filter((item) => {
return item.type === 'video' || item.type === 'channel' || item.type === 'playlist'
})
items.forEach((item) => {
if (item.type === 'video') {
setPublishedTimestamp(item)
}
})
return items
}
/**
* @param {string} query
* @param {number} page

View File

@@ -1,4 +1,4 @@
import { ClientType, Innertube, Misc, Parser, Platform, UniversalCache, Utils, YT, YTNodes } from 'youtubei.js'
import { ClientType, Innertube, Misc, Mixins, Parser, Platform, UniversalCache, Utils, YT, YTNodes } from 'youtubei.js'
import Autolinker from 'autolinker'
import { SEARCH_CHAR_LIMIT } from '../../../constants'
@@ -361,42 +361,43 @@ export async function untilEndOfLocalPlayList(playlist, callback, options = { ru
/**
* @param {string} location
* @param {'default'|'music'|'gaming'|'movies'} tab
* @param {import('youtubei.js').Mixins.TabbedFeed<import('youtubei.js').IBrowseResponse> | null} instance
* @param {'gaming' | 'sports' | 'podcasts'} tab
*/
export async function getLocalTrending(location, tab, instance) {
if (instance === null) {
const innertube = await createInnertube({ location })
instance = await innertube.getTrending()
}
export async function getLocalTrending(location, tab) {
const innertube = await createInnertube({ location })
// youtubei.js's tab names are localised, so we need to use the index to get tab name that youtubei.js expects
const tabIndex = ['default', 'music', 'gaming', 'movies'].indexOf(tab)
const resultsInstance = await instance.getTabByName(instance.tabs[tabIndex])
let args
let results
// the default tab can have duplicate videos so we need to deduplicate them
if (tab === 'default') {
const alreadySeenIds = []
results = []
resultsInstance.videos.forEach(video => {
if (video.type === 'Video' && !alreadySeenIds.includes(video.video_id)) {
alreadySeenIds.push(video.video_id)
results.push(parseLocalListVideo(video))
switch (tab) {
case 'gaming':
// https://www.youtube.com/gaming/trending
args = {
browseId: 'UCOpNcN46UbXVtpKMrmU4Abg',
params: 'Egh0cmVuZGluZ7gBAJIDAPIGBAoCMgA'
}
})
} else {
results = resultsInstance.videos
.filter((video) => video.type === 'Video')
.map(parseLocalListVideo)
break
case 'sports':
// https://www.youtube.com/channel/UCEgdi0XIXXZ-qJOFPf4JSKw/sportstab?ss=CMMG
args = {
browseId: 'UCEgdi0XIXXZ-qJOFPf4JSKw',
params: 'EglzcG9ydHN0YWK4AQCSAwDyBgQKAjIA'
}
break
case 'podcasts':
// https://www.youtube.com/podcasts/popularepisodes
args = {
browseId: 'FEpodcasts_destination',
params: 'qgcCCAM%3D'
}
break
default:
throw new Error('Unknown trending tab')
}
return {
results,
instance: resultsInstance
}
const response = await innertube.actions.execute('/browse', args)
const feed = new Mixins.Feed(innertube.actions, response)
return feed.videos.map(video => parseLocalListVideo(video))
}
/**
@@ -1292,27 +1293,12 @@ export function parseLocalPlaylistVideo(video) {
let viewCount = null
// the accessiblity label contains the full view count
// the video info only contains the short view count
if (video_.accessibility_label) {
// the `.*\s+` at the start of the regex, ensures we match the last occurence
// just in case the video title also contains that pattern
const match = video_.accessibility_label.match(/.*\s+([\d,.]+|no)\s+views?/)
const viewsText = video_.video_info.runs?.find(run => VIEWS_OR_WATCHING_REGEX.test(run.text))?.text
if (match) {
const count = match[1]
// as it's rare that a video has no views,
// checking the length allows us to avoid running toLowerCase unless we have to
if (count.length === 2 && count === 'no') {
viewCount = 0
} else {
const views = extractNumberFromString(count)
if (!isNaN(views)) {
viewCount = views
}
}
if (viewsText) {
const views = parseLocalSubscriberCount(viewsText)
if (!isNaN(views)) {
viewCount = views
}
}

View File

@@ -79,13 +79,13 @@ import {
faLock,
faMessage,
faMoneyCheckDollar,
faMusic,
faNetworkWired,
faNewspaper,
faPalette,
faPhotoFilm,
faPlay,
faPlus,
faPodcast,
faQuestionCircle,
faRandom,
faRetweet,
@@ -111,6 +111,7 @@ import {
faTimesCircle,
faTowerBroadcast,
faTrash,
faTrophy,
faUserCheck,
faUserLock,
faUsers,
@@ -203,13 +204,13 @@ library.add(
faLock,
faMessage,
faMoneyCheckDollar,
faMusic,
faNetworkWired,
faNewspaper,
faPalette,
faPhotoFilm,
faPlay,
faPlus,
faPodcast,
faQuestionCircle,
faRandom,
faRetweet,
@@ -235,6 +236,7 @@ library.add(
faTimesCircle,
faTowerBroadcast,
faTrash,
faTrophy,
faUserCheck,
faUserLock,
faUsers,

View File

@@ -42,14 +42,16 @@ const router = createRouter({
},
component: SubscribedChannels
},
{
path: '/trending',
name: 'trending',
meta: {
title: 'Trending'
},
component: Trending
},
...(process.env.SUPPORTS_LOCAL_API
? [{
path: '/trending',
name: 'trending',
meta: {
title: 'Trending'
},
component: Trending
}]
: []),
{
path: '/popular',
name: 'popular',

View File

@@ -17,10 +17,9 @@ const state = {
sessionSearchHistory: [],
popularCache: null,
trendingCache: {
default: null,
music: null,
gaming: null,
movies: null
sports: null,
podcasts: null
},
cachedPlaylist: null,
deArrowCache: {},
@@ -49,10 +48,9 @@ const state = {
externalPlayerCmdArguments: {},
lastPopularRefreshTimestamp: '',
lastTrendingRefreshTimestamp: {
default: '',
music: '',
gaming: '',
movies: ''
sports: '',
podcasts: ''
},
subscriptionFirstAutoFetchRunData: {
videos: false,
@@ -656,9 +654,10 @@ const actions = {
if (feedType === 'playlists' || feedType === 'you' || feedType === 'library') {
return { urlType: 'userplaylists' }
} else {
} else if (process.env.SUPPORTS_LOCAL_API || feedType !== 'trending') {
return { urlType: feedType }
}
// Can fall through if a trending URL is detected in a build without the local API
}
default: {
@@ -911,7 +910,7 @@ const mutations = {
/**
* @param {typeof state} state
* @param {{page: 'default' | 'music' | 'gaming' | 'movies', timestamp: Date}} param1
* @param {{page: 'gaming' | 'sports' | 'podcasts', timestamp: Date}} param1
*/
setLastTrendingRefreshTimestamp (state, { page, timestamp }) {
state.lastTrendingRefreshTimestamp[page] = timestamp
@@ -923,7 +922,7 @@ const mutations = {
/**
* @param {typeof state} state
* @param {'default' | 'music' | 'gaming' | 'movies'} page
* @param {'gaming' | 'sports' | 'podcasts'} page
*/
clearTrendingCache(state, page) {
state.trendingCache[page] = null

View File

@@ -68,6 +68,13 @@ body:not(.hotPink, .pastelPink) {
--logo-tertiary-color: #000;
}
/* Text "app" should be visible but retain FreeTube color logo */
.system[data-system-theme*='dark'],
.dark,
.black {
--logo-tertiary-color: #fff;
}
/* Given that the Hot Pink theme does not need link underlining due to meeting
WCAG 2 Level AA (https://webaim.org/resources/linkcontrastchecker/?fcolor=FFFFFF&bcolor=DE1C85&lcolor=000000),
it can be safely elided. This looks quite pleasant on this theme. */

View File

@@ -82,7 +82,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import { isNavigationFailure, NavigationFailureType, useRoute, useRouter } from 'vue-router'
@@ -270,7 +270,7 @@ if (oldQuery != null && oldQuery !== '') {
filterHistory()
}
const searchBar = ref(null)
const searchBar = useTemplateRef('searchBar')
/**
* @param {KeyboardEvent} event

View File

@@ -7,7 +7,7 @@ import ExternalPlayerSettings from '../../components/ExternalPlayerSettings.vue'
import SubscriptionSettings from '../../components/SubscriptionSettings/SubscriptionSettings.vue'
import DownloadSettings from '../../components/DownloadSettings/DownloadSettings.vue'
import PrivacySettings from '../../components/PrivacySettings.vue'
import DataSettings from '../../components/DataSettings.vue'
import DataSettings from '../../components/DataSettings/DataSettings.vue'
import DistractionSettings from '../../components/DistractionSettings/DistractionSettings.vue'
import ProxySettings from '../../components/ProxySettings/ProxySettings.vue'
import SponsorBlockSettings from '../../components/SponsorBlockSettings.vue'

View File

@@ -82,7 +82,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref, watch, useTemplateRef } from 'vue'
import { isNavigationFailure, NavigationFailureType, useRoute, useRouter } from 'vue-router'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
@@ -110,8 +110,7 @@ const query = ref('')
const subscribedChannels = ref([])
const filteredChannels = ref([])
/** @type {import('vue').Ref<HTMLInputElement | null>} */
const searchBarChannels = ref(null)
const searchBarChannels = useTemplateRef('searchBarChannels')
/** @type {import('vue').ComputedRef<object>} */
const activeProfile = computed(() => {

View File

@@ -131,7 +131,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, ref, watch } from 'vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
@@ -244,14 +244,10 @@ function changeTab(tab) {
}
}
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const videosTab = ref(null)
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const liveTab = ref(null)
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const shortsTab = ref(null)
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const communityTab = ref(null)
const videosTab = useTemplateRef('videosTab')
const liveTab = useTemplateRef('liveTab')
const shortsTab = useTemplateRef('shortsTab')
const communityTab = useTemplateRef('communityTab')
/**
* @param {KeyboardEvent} event

View File

@@ -7,7 +7,7 @@
.trendingInfoTabs {
inline-size: 100%;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
margin-block: -3px 10px;
color: var(--tertiary-text-color);
}

View File

@@ -16,48 +16,6 @@
role="tablist"
:aria-label="$t('Trending.Trending Tabs')"
>
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
<div
ref="defaultTab"
class="tab"
role="tab"
:aria-selected="currentTab === 'default'"
aria-controls="trendingPanel"
:tabindex="currentTab === 'default' ? 0 : -1"
:class="{ selectedTab: currentTab === 'default' }"
@click="changeTab('default')"
@keydown.space.enter.prevent="changeTab('default')"
@keydown.left="focusTab('movies', $event)"
@keydown.right="focusTab('music', $event)"
>
<FontAwesomeIcon
:icon="['fas', 'fire']"
class="trendingIcon"
fixed-width
/>
{{ $t("Trending.Default") }}
</div>
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
<div
ref="musicTab"
class="tab"
role="tab"
:aria-selected="currentTab === 'music'"
aria-controls="trendingPanel"
:tabindex="currentTab === 'music' ? 0 : -1"
:class="{ selectedTab: currentTab === 'music' }"
@click="changeTab('music')"
@keydown.space.enter.prevent="changeTab('music')"
@keydown.left="focusTab('default', $event)"
@keydown.right="focusTab('gaming', $event)"
>
<FontAwesomeIcon
:icon="['fas', 'music']"
class="trendingIcon"
fixed-width
/>
{{ $t("Trending.Music") }}
</div>
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
<div
ref="gamingTab"
@@ -69,8 +27,8 @@
:class="{ selectedTab: currentTab === 'gaming' }"
@click="changeTab('gaming')"
@keydown.space.enter.prevent="changeTab('gaming')"
@keydown.left="focusTab('music', $event)"
@keydown.right="focusTab('movies', $event)"
@keydown.left="focusTab('podcasts', $event)"
@keydown.right="focusTab('sports', $event)"
>
<FontAwesomeIcon
:icon="['fas', 'gamepad']"
@@ -81,24 +39,45 @@
</div>
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
<div
ref="moviesTab"
ref="sportsTab"
class="tab"
role="tab"
:aria-selected="currentTab === 'movies'"
:aria-selected="currentTab === 'sports'"
aria-controls="trendingPanel"
:tabindex="currentTab === 'movies' ? 0 : -1"
:class="{ selectedTab: currentTab === 'movies' }"
@click="changeTab('movies')"
@keydown.space.enter.prevent="changeTab('movies')"
:tabindex="currentTab === 'sports' ? 0 : -1"
:class="{ selectedTab: currentTab === 'sports' }"
@click="changeTab('sports')"
@keydown.space.enter.prevent="changeTab('sports')"
@keydown.left="focusTab('gaming', $event)"
@keydown.right="focusTab('default', $event)"
@keydown.right="focusTab('podcasts', $event)"
>
<FontAwesomeIcon
:icon="['fas', 'film']"
:icon="['fas', 'trophy']"
class="trendingIcon"
fixed-width
/>
{{ $t("Trending.Movies") }}
{{ t("Trending.Sports") }}
</div>
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
<div
ref="podcastsTab"
class="tab"
role="tab"
:aria-selected="currentTab === 'podcasts'"
aria-controls="trendingPanel"
:tabindex="currentTab === 'podcasts' ? 0 : -1"
:class="{ selectedTab: currentTab === 'podcasts' }"
@click="changeTab('podcasts')"
@keydown.space.enter.prevent="changeTab('podcasts')"
@keydown.left="focusTab('sports', $event)"
@keydown.right="focusTab('gaming', $event)"
>
<FontAwesomeIcon
:icon="['fas', 'podcast']"
class="trendingIcon"
fixed-width
/>
{{ t("Channel.Podcasts.Podcasts") }}
</div>
</FtFlexBox>
<div
@@ -125,7 +104,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import FtCard from '../../components/ft-card/ft-card.vue'
@@ -138,7 +117,6 @@ import store from '../../store/index'
import { copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils'
import { getLocalTrending } from '../../helpers/api/local'
import { getInvidiousTrending } from '../../helpers/api/invidious'
import { KeyboardShortcuts } from '../../../constants'
const { t } = useI18n()
@@ -162,16 +140,16 @@ const region = computed(() => {
return store.getters.getRegion.toUpperCase()
})
/** @type {import('vue').ComputedRef<{ default: any[] | null, music: any[] | null, gaming: any[] | null, movies: any[] | null }>} */
/** @type {import('vue').ComputedRef<{ gaming: any[] | null, sports: any[] | null, podcasts: any[] | null }>} */
const trendingCache = computed(() => {
return store.getters.getTrendingCache
})
const isLoading = ref({ default: false, music: false, gaming: false, movies: false })
const isLoading = ref({ gaming: false, sports: false, podcasts: false })
const shownResults = shallowRef([])
/** @type {import('vue').Ref<'default' | 'music' | 'gaming' | 'movies'>} */
const currentTab = ref('default')
/** @type {import('vue').Ref<'gaming' | 'sports' | 'podcasts'>} */
const currentTab = ref('gaming')
const cacheEntry = trendingCache.value[currentTab.value]
@@ -183,21 +161,12 @@ if (cacheEntry && cacheEntry.length > 0) {
})
}
/** @type {import('youtubei.js').Mixins.TabbedFeed<import('youtubei.js').IBrowseResponse> | null} */
let trendingInstance = null
function getTrendingInfo(refresh = false) {
if (refresh) {
if (process.env.SUPPORTS_LOCAL_API) {
trendingInstance = null
}
store.commit('clearTrendingCache', currentTab.value)
}
if (!process.env.SUPPORTS_LOCAL_API || backendPreference.value === 'invidious') {
getTrendingInfoInvidious()
} else {
if (process.env.SUPPORTS_LOCAL_API && (backendFallback.value || backendPreference.value === 'local')) {
getTrendingInfoLocal()
}
@@ -208,11 +177,10 @@ async function getTrendingInfoLocal() {
isLoading.value[currentTab.value] = true
try {
const { results, instance } = await getLocalTrending(region.value, currentTab.value, trendingInstance)
const results = await getLocalTrending(region.value, currentTab.value)
shownResults.value = results
isLoading.value[currentTab.value] = false
trendingInstance = instance
store.commit('setTrendingCache', { value: results, page: currentTab.value })
nextTick(() => {
@@ -224,56 +192,16 @@ async function getTrendingInfoLocal() {
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
if (backendPreference.value === 'local' && backendFallback.value) {
showToast(t('Falling back to Invidious API'))
getTrendingInfoInvidious()
} else {
isLoading.value[currentTab.value] = false
}
isLoading.value[currentTab.value] = false
}
}
function getTrendingInfoInvidious() {
isLoading.value[currentTab.value] = true
getInvidiousTrending(currentTab.value, region.value).then((items) => {
if (!items) {
return
}
shownResults.value = items
isLoading.value[currentTab.value] = false
store.commit('setTrendingCache', { value: items, page: currentTab.value })
nextTick(() => {
focusTab(currentTab.value)
})
}).catch((err) => {
console.error(err)
const errorMessage = t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
if (process.env.SUPPORTS_LOCAL_API && backendPreference.value === 'invidious' && backendFallback.value) {
showToast(t('Falling back to Local API'))
getTrendingInfoLocal()
} else {
isLoading.value[currentTab.value] = false
}
})
}
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const defaultTab = ref(null)
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const musicTab = ref(null)
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const gamingTab = ref(null)
/** @type {import('vue').Ref<HTMLDivElement | null>} */
const moviesTab = ref(null)
const gamingTab = useTemplateRef('gamingTab')
const sportsTab = useTemplateRef('sportsTab')
const podcastsTab = useTemplateRef('podcastsTab')
/**
* @param {'default' | 'music' | 'gaming' | 'movies'} tab
* @param {'gaming' | 'sports' | 'podcasts'} tab
* @param {KeyboardEvent | undefined} event
*/
function focusTab(tab, event = undefined) {
@@ -285,23 +213,20 @@ function focusTab(tab, event = undefined) {
}
switch (tab) {
case 'default':
defaultTab.value?.focus()
break
case 'music':
musicTab.value?.focus()
break
case 'gaming':
gamingTab.value?.focus()
break
case 'movies':
moviesTab.value?.focus()
case 'sports':
sportsTab.value?.focus()
break
case 'podcasts':
podcastsTab.value?.focus()
break
}
}
/**
* @param {'default' | 'music' | 'gaming' | 'movies'} tab
* @param {'gaming' | 'sports' | 'podcasts'} tab
*/
function changeTab(tab) {
if (tab === currentTab.value) {

View File

@@ -97,7 +97,7 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from '../../composables/use-i18n-polyfill'
import { isNavigationFailure, NavigationFailureType, useRoute, useRouter } from 'vue-router'
@@ -409,7 +409,7 @@ if (oldQuery != null && oldQuery !== '') {
filterPlaylist()
}
const searchBar = ref(null)
const searchBar = useTemplateRef('searchBar')
/**
* @param {KeyboardEvent} event

View File

@@ -4,7 +4,7 @@ import shaka from 'shaka-player'
import { Utils, YTNodes } from 'youtubei.js'
import FtLoader from '../../components/FtLoader/FtLoader.vue'
import FtShakaVideoPlayer from '../../components/ft-shaka-video-player/ft-shaka-video-player.vue'
import WatchVideoInfo from '../../components/watch-video-info/watch-video-info.vue'
import WatchVideoInfo from '../../components/WatchVideoInfo/WatchVideoInfo.vue'
import WatchVideoChapters from '../../components/WatchVideoChapters/WatchVideoChapters.vue'
import WatchVideoDescription from '../../components/WatchVideoDescription/WatchVideoDescription.vue'
import CommentSection from '../../components/CommentSection/CommentSection.vue'

View File

@@ -138,10 +138,7 @@ Channels:
Unsubscribe Prompt: ''
Trending:
Trending: ''
Default: ''
Music: ''
Gaming: ''
Movies: ''
Trending Tabs: ''
Most Popular: ''
Feed:

View File

@@ -140,10 +140,7 @@ Channels:
Unsubscribe Prompt: ''
Trending:
Trending: ''
Default: ''
Music: ''
Gaming: ''
Movies: ''
Trending Tabs: ''
Most Popular: ''
Feed:

View File

@@ -106,10 +106,7 @@ Subscriptions:
Trending:
Trending: 'المحتوى الرائج'
Trending Tabs: علامات التبويب الشائعة
Movies: أفلام
Gaming: الالعاب
Music: الموسيقى
Default: الإفتراضي
Most Popular: 'الأكثر شعبية'
Playlists: 'قوائم التشغيل'
User Playlists:

View File

@@ -148,10 +148,7 @@ Channels:
Unsubscribe Prompt: ''
Trending:
Trending: ''
Default: ''
Music: ''
Gaming: ''
Movies: ''
Trending Tabs: ''
Most Popular: ''
Feed:

View File

@@ -98,10 +98,7 @@ Subscriptions:
More: 'Daha Çox'
Trending:
Trending: 'Trenddədir'
Default: 'İlkin'
Music: 'Musiqi'
Gaming: 'Oyun'
Movies: 'Filmlər'
Trending Tabs: 'Trend Səhifələr'
Most Popular: 'Ən Tanınmış'
Playlists: 'Pleylistlər'

View File

@@ -125,10 +125,7 @@ Channels:
Unsubscribe Prompt: 'Сапраўды хочаце адпісацца ад «{channelName}»?'
Trending:
Trending: 'У трэндзе'
Default: 'Па змаўчанні'
Music: 'Музыка'
Gaming: 'Гульні'
Movies: 'Фільмы'
Trending Tabs: 'Укладкі Трэндаў'
Most Popular: 'Папулярныя'
Playlists: 'Плэй-лісты'

View File

@@ -110,10 +110,7 @@ Subscriptions:
Trending:
Trending: 'Набиращи популярност'
Trending Tabs: Раздели за набиращи популярност
Movies: Филми
Gaming: Игри
Music: Музика
Default: По подразбиране
Most Popular: 'Най-популярни'
Playlists: 'Плейлисти'
User Playlists:

View File

@@ -84,9 +84,6 @@ Subscriptions:
Trending:
Trending: 'চলছে'
Gaming: গেমিং
Default: পূর্বনির্ধারিত
Music: সঙ্গীত
Movies: সিনেমা
Trending Tabs: চলমান ভিডিও ট্যাব
History:
# On History Page

View File

@@ -141,10 +141,7 @@ Channels:
Unsubscribe Prompt: 'Ha sur oc''h e faot deoc''h digoumanantiñ deus "{channelName}"?'
Trending:
Trending: 'Diouzh ar cʼhiz'
Default: 'Dre ziouer'
Music: 'Sonerezh'
Gaming: 'C''hoarioù video'
Movies: 'Filmoù'
Trending Tabs: 'Ivinelloù ar re diouzh ar c''hiz'
Most Popular: 'Ar muiañ a verzh ganto'
Feed:

View File

@@ -84,11 +84,8 @@ Subscriptions:
Error Channels: Canals amb errors
Trending:
Trending: 'Tendències'
Default: Per defecte
Music: Música
Gaming: Jocs
Trending Tabs: Pestanyes de tendència
Movies: Pel·lícules
Most Popular: 'Més populars'
Playlists: 'Llistes de reproducció'
User Playlists:

View File

@@ -122,10 +122,7 @@ Channels:
Unsubscribe Prompt: ''
Trending:
Trending: 'باو'
Default: 'بنەڕەت'
Music: 'مۆسیقا'
Gaming: 'یاری'
Movies: 'فیلم'
Trending Tabs: ''
Most Popular: 'باوترین'
Playlists: 'پێڕستی لێدانەکان'

View File

@@ -110,10 +110,8 @@ Subscriptions:
Trending:
Trending: 'Trendy'
Trending Tabs: Tabulka trendů
Movies: Filmy
Gaming: Hry
Music: Hudba
Default: Výchozí
Sports: Sporty
Most Popular: 'Populární'
Playlists: 'Playlisty'
User Playlists:

View File

@@ -126,10 +126,7 @@ Channels:
Unsubscribe Prompt: 'Ydych chi''n siŵr eich bod am ddad-danysgrifio o "{channelName}"?'
Trending:
Trending: 'Trendio'
Default: 'Rhagosodedig'
Music: 'Cerddoriaeth'
Gaming: 'Gemau'
Movies: 'Ffilmiau'
Trending Tabs: 'Tabiau Trendio'
Most Popular: 'Poblogaidd'
Playlists: 'Rhestrau'

View File

@@ -108,11 +108,8 @@ Subscriptions:
All Subscription Tabs Hidden: Alle abonnement-faner er skjult. Gør nogle faner synlige i "{subsection}" under "{settingsSection}" for at se indhold her.
Trending:
Trending: 'Hot lige nu'
Music: Musik
Trending Tabs: Hot lige nu-faner
Gaming: Gaming
Default: Standard
Movies: Film
Most Popular: 'Mest populære'
Playlists: 'Playlister'
User Playlists:

View File

@@ -103,10 +103,7 @@ Subscriptions:
Trending:
Trending: Trends
Trending Tabs: Trends-Tabs
Movies: Filme
Gaming: Gaming
Music: Musik
Default: Standard
Most Popular: Am beliebtesten
Playlists: Playlists
User Playlists:

View File

@@ -105,9 +105,6 @@ Subscriptions:
Trending:
Trending: 'Τάσεις'
Gaming: Παιχνίδια
Music: Μουσική
Movies: Ταινίες
Default: Προεπιλογή
Trending Tabs: Καρτέλες Τάσεων
Most Popular: 'Δημοφιλέστερα'
Playlists: 'Λίστες αναπαραγωγής'

View File

@@ -110,10 +110,8 @@ Subscriptions:
Trending:
Trending: 'Trending'
Trending Tabs: Trending Tabs
Movies: Films
Gaming: Gaming
Music: Music
Default: Default
Sports: Sports
Most Popular: 'Most Popular'
Playlists: 'Playlists'
User Playlists:

View File

@@ -143,10 +143,8 @@ Channels:
Unsubscribe Prompt: Are you sure you want to unsubscribe from "{channelName}"?
Trending:
Trending: Trending
Default: Default
Music: Music
Gaming: Gaming
Movies: Movies
Sports: Sports
Trending Tabs: Trending Tabs
Most Popular: Most Popular
Feed:

View File

@@ -63,9 +63,6 @@ Subscriptions:
Trending:
Trending: 'Popularaj'
Gaming: Ludismo
Movies: Filmoj
Music: Muziko
Default: Defaŭlte
Most Popular: 'Plej popularaj'
Playlists: 'Ludlistoj'
User Playlists:

View File

@@ -84,10 +84,7 @@ Subscriptions:
Error Channels: Canales con errores
Trending:
Trending: 'Tendencias'
Movies: Películas
Gaming: Juegos
Music: Música
Default: Por defecto
Trending Tabs: Lo más destacado
Most Popular: 'Más popular'
Playlists: 'Listas de reproducción'

View File

@@ -102,11 +102,8 @@ Subscriptions:
Load More Posts: Cargar más publicaciones
Trending:
Trending: 'Tendencias'
Default: Predeterminado
Music: Música
Gaming: Juegos
Trending Tabs: Tendencias
Movies: Películas
Most Popular: 'Más Popular'
Playlists: 'Listas de Reproducción'
User Playlists:

View File

@@ -105,11 +105,8 @@ Subscriptions:
Empty Posts: Tus canales suscritos no tienen actualmente ninguna publicación.
Trending:
Trending: 'Tendencias'
Default: Predeterminado
Trending Tabs: Pestañas de tendencias
Movies: Películas
Gaming: Videojuegos
Music: Música
Most Popular: 'Más populares'
Playlists: 'Listas de reproducción'
User Playlists:

View File

@@ -110,10 +110,8 @@ Subscriptions:
Trending:
Trending: 'Populaarsust koguvad videod'
Trending Tabs: Populaarsust koguvad kaardid
Movies: Filmid
Gaming: Arvutimängud
Music: Muusika
Default: Vaikimisi
Sports: Sport
Most Popular: 'Populaarseimad'
Playlists: 'Esitusloendid'
User Playlists:

View File

@@ -110,10 +110,7 @@ Subscriptions:
More: 'Gehiago'
Trending:
Trending: 'Joerak'
Default: Lehenetsia
Music: Musika
Gaming: Bideo jokoak
Movies: Filmak
Trending Tabs: Joeren fitxak
Most Popular: 'Ikusienak'
Playlists: 'Erreprodukzio zerrendak'

View File

@@ -90,10 +90,7 @@ Subscriptions:
More: 'بیشتر'
Trending:
Trending: 'پربازدید ها'
Default: 'حالت عادی'
Music: 'موسیقی'
Gaming: 'بازی'
Movies: 'فیلم ها'
Trending Tabs: 'سربرگ پربازدید ها'
Most Popular: 'پر طرفدارترین ها'
Playlists: 'لیست های پخش'

View File

@@ -102,10 +102,7 @@ Subscriptions:
Trending:
Trending: 'Nousussa'
Trending Tabs: Nousussa olevat välilehdet
Movies: Elokuvat
Gaming: Pelaaminen
Music: Musiikki
Default: Oletus
Most Popular: 'Suosituimmat'
Playlists: 'Soittolistat'
User Playlists:

View File

@@ -103,10 +103,8 @@ Subscriptions:
Trending:
Trending: 'Tendance'
Trending Tabs: Onglets des Tendances
Movies: Films
Gaming: Jeux vidéo
Music: Musique
Default: Par défaut
Sports: Sports
Most Popular: 'Les plus populaires'
Playlists: 'Listes de lecture'
User Playlists:

View File

@@ -85,9 +85,6 @@ Subscriptions:
Trending:
Trending: 'Tendencias'
Gaming: Xogos
Default: Por defecto
Music: Música
Movies: Películas
Trending Tabs: Pestanas das tendencias
Most Popular: 'Máis populares'
Playlists: 'Listaxes de reprodución'

View File

@@ -110,10 +110,7 @@ Subscriptions:
Trending:
Trending: 'הסרטונים החמים'
Trending Tabs: לשוניות מובילים
Movies: סרטים
Gaming: משחקים
Music: מוזיקה
Default: ברירת מחדל
Most Popular: 'הכי פופולרי'
Playlists: 'רשימות נגינה'
User Playlists:

View File

@@ -96,9 +96,6 @@ Subscriptions:
Empty Channels: आपके सब्सक्राइब किए गए चैनल में वर्तमान में कोई वीडियो नहीं है।
Trending:
Trending: 'रुझान में'
Music: संगीत
Default: डिफ़ॉल्ट
Movies: मूवीज
Trending Tabs: ट्रेंडिंग टैब्स
Gaming: गेमिंग
Most Popular: 'सबसे लोकप्रिय'

View File

@@ -106,10 +106,7 @@ Subscriptions:
Trending:
Trending: 'U trendu'
Trending Tabs: Kartice „U trendu”
Movies: Filmovi
Gaming: Igre
Music: Glazba
Default: Standardno
Most Popular: 'Najpopularniji'
Playlists: 'Zbirke'
User Playlists:

View File

@@ -110,10 +110,8 @@ Subscriptions:
Trending:
Trending: 'Felkapott'
Trending Tabs: Felkapott lapok
Movies: Filmek
Gaming: Játék
Music: Zene
Default: Alapértelmezett
Sports: Sportok
Most Popular: 'Legnépszerűbb'
Playlists: 'Lejátszási listák'
User Playlists:

View File

@@ -109,10 +109,7 @@ Subscriptions:
All Subscription Tabs Hidden: Semua tab langganan disembunyikan. Untuk melihat konten di sini, harap tampilkan beberapa tab di bagian "{subsection}" di "{settingsSection}".
Trending:
Trending: 'Sedang Tren'
Movies: Film
Music: Musik
Gaming: Bermain game
Default: Bawaan
Trending Tabs: Tab Trending
Most Popular: 'Paling Populer'
Playlists: 'Daftar Putar'

View File

@@ -111,10 +111,8 @@ More: 'Meira'
Trending:
Trending: 'Í umræðunni'
Trending Tabs: Vinsælir flipar
Movies: Kvikmyndir
Gaming: Leikir
Music: Tónlist
Default: Sjálfgefið
Sports: Íþróttir
Most Popular: 'Vinsælast'
Playlists: 'Spilunarlistar'
User Playlists:
@@ -287,6 +285,7 @@ Settings:
Everforest Dark Low: Everforest Dökkt-lítið
Everforest Light Hard: Everforest Ljóst-skarpt
Everforest Light Medium: Everforest Ljóst-miðlungs
Catppuccin Latte: Catppuccin Latte
Main Color Theme:
Main Color Theme: 'Aðallitur þema'
Red: 'Rautt'
@@ -372,6 +371,8 @@ Settings:
Everforest Light Aqua: Everforest Ljóssægrænt
Everforest Light Blue: Everforest Ljósblátt
Everforest Light Purple: Everforest Ljóspurpuralitað
Catppuccin Latte Mauve: Catppuccin Latte fjólublár
Catppuccin Latte Red: Catppuccin Latte rautt
Secondary Color Theme: 'Aukalitur þema'
#* Main Color Theme
Hide Side Bar Labels: Fela skýringar á hliðarstiku
@@ -548,6 +549,12 @@ Settings:
Export Playlists For Older FreeTube Versions:
Label: Flytja út spilunarlista fyrir eldri útgáfur FreeTube
Tooltip: "Þessi valkostur flytur út myndskeið úr öllum spilunarlistum inn í einn spilunarlista sem kallast 'Eftirlæti'.\nHér er skýrt hvernig eigi að flytja út eða inn myndskeið í spilunarlistum fyrir eldri útgáfur FreeTube:\n 1. Flyttu út spilunarlistana þína með þennan valkost virkann.\n2. Eyddu út öllum fyrirliggjandi spilunarlistum hjá þér með valkostinum 'Fjarlægja alla spilunarlista' í stillingum gagnaleyndar.\n3. Ræstu eldri útgáfu FreeTube og flyttu inn spilunarlistana sem þú fluttir út.\""
Search history file: Leita í ferilskrá
Search history: Leitarferill
Import search history: Flytja inn leitarferil
Export search history: Flytja út leitarferil
All search history has been successfully imported: Allur leitarferill hefur verið fluttur inn
All search history has been successfully exported: Allur leitarferill hefur verið fluttur út
Proxy Settings:
Proxy Settings: 'Milliþjónn (proxy)'
Enable Tor / Proxy: 'Virkja Tor / milliþjón'
@@ -925,10 +932,13 @@ Comments:
Show More Replies: Birta fleiri svör
Pinned by: Fest af
Member: Meðlimur
View {replyCount} replies: Skoða {replyCount} svör
View {replyCount} replies: Skoða 1 svar | Skoða {replyCount} svör
Hearted: Líkað
Subscribed: Áskrifandi
There are no comments available for this post: Engar athugasemdir finnast fyrir þessa færslu
Hide {replyCount} replies: Fela 1 svar | Fela {replyCount} svör
View 1 reply from {channelName}: Skoða 1 svar frá {channelName}
View {replyCount} replies from {channelName} and others: Skoða {replyCount} svör frá {channelName} og fleirum
Up Next: 'Næst í spilun'
#Tooltips

View File

@@ -105,11 +105,9 @@ Subscriptions:
Empty Posts: I canali a cui sei iscritto attualmente non hanno post.
Trending:
Trending: 'Tendenze'
Music: Musica
Default: Predefinito
Trending Tabs: Schede di tendenza
Movies: Film
Gaming: Videogiochi
Sports: Sport
Most Popular: 'Più popolari'
Playlists: 'Playlist'
User Playlists:

View File

@@ -103,10 +103,7 @@ Subscriptions:
Trending:
Trending: '急上昇'
Trending Tabs: 急上昇のタブ
Movies: 映画
Gaming: ゲーム
Music: 音楽
Default: デフォルト
Most Popular: '人気'
Playlists: '再生リスト'
User Playlists:

View File

@@ -107,10 +107,7 @@ Channels:
Unsubscribe Prompt: 'დარწმუნებული ხართ, რომ გსურთ "{channelName}"ის გამოწერის გაუქმება?'
Trending:
Trending: 'პოპულარული'
Default: 'სტანდარტულად'
Music: 'მუსიკები'
Gaming: 'თამაშები'
Movies: 'ფილმები'
Trending Tabs: 'პოპულარული'
Most Popular: 'ყველაზე პოპულარული'
Playlists: 'დასაკრავი სიები'

View File

@@ -109,10 +109,7 @@ Subscriptions:
Trending:
Trending: '트렌딩'
Trending Tabs: 트렌딩 탭
Default: 기본
Movies: 영화
Gaming: 게임
Music: 음악
Most Popular: '인기 동영상'
Playlists: '재생 목록'
User Playlists:

View File

@@ -106,10 +106,7 @@ Subscriptions:
Subscriptions Tabs: تابەکانی بەشداریکردن
Trending:
Trending: 'زۆر باسکراو'
Default: بنەڕەتی
Trending Tabs: تابەکانی ترێندنگ
Movies: فیلمەکان
Music: مووزیک
Gaming: یاریکردن
Most Popular: 'بەناوبانگترین'
Playlists: 'پلەیلیست'

View File

@@ -93,10 +93,7 @@ More: 'Daugiau'
Trending:
Trending: 'Dabar populiaru'
Trending Tabs: „Dabar populiaru“ kortelės
Default: Numatytoji
Movies: Filmai
Gaming: Žaidimai
Music: Muzika
Most Popular: 'Populiariausia'
Playlists: 'Grojaraščiai'
User Playlists:

View File

@@ -124,10 +124,7 @@ Channels:
Unsubscribe Prompt: 'Vai esi drošs, ka vēlies atcelt abonementu no "{channelName}"?'
Trending:
Trending: 'Pašlaik topā'
Default: 'Viss'
Music: 'Mūzika'
Gaming: 'Spēles'
Movies: 'Filmas'
Trending Tabs: 'Tendenču cilnes'
Most Popular: 'Vispopulārākie'
Playlists: 'Atskaņošanas saraksti'

View File

@@ -140,10 +140,7 @@ Channels:
Unsubscribe Prompt: ''
Trending:
Trending: ''
Default: ''
Music: ''
Gaming: ''
Movies: ''
Trending Tabs: ''
Most Popular: ''
Feed:

View File

@@ -137,10 +137,7 @@ Channels:
Unsubscribe Prompt: ''
Trending:
Trending: ''
Default: ''
Music: ''
Gaming: ''
Movies: ''
Trending Tabs: ''
Most Popular: ''
Feed:

View File

@@ -106,10 +106,8 @@ Subscriptions:
Trending:
Trending: 'På vei opp'
Trending Tabs: På vei opp-faner
Movies: Filmer
Gaming: Videospill
Music: Musikk
Default: Standard
Sports: Sport
Most Popular: 'Mest populære'
Playlists: 'Spillelister'
User Playlists:

View File

@@ -83,10 +83,7 @@ Subscriptions:
More: 'थप'
Trending:
Trending: 'प्रचलित'
Default: 'पूर्वनिर्धारित'
Music: 'सङ्गीत'
Gaming: 'गेमिङ्ग'
Movies: 'चलचित्रहरू'
Trending Tabs: 'प्रचलित ट्याबहरू'
Most Popular: 'सबैभन्दा लोकप्रिय'
Playlists: 'प्लेसूचीहरू'

View File

@@ -106,10 +106,7 @@ Subscriptions:
Trending:
Trending: 'Trending'
Trending Tabs: Trending
Movies: Films
Gaming: Gaming
Music: Muziek
Default: Standaard
Most Popular: 'Populairste'
Playlists: 'Afspeellijsten'
User Playlists:

View File

@@ -88,11 +88,8 @@ Subscriptions:
Empty Channels: Kanalane du abonnerer på har ingen videoar for augeblikket.
Trending:
Trending: 'På veg opp'
Music: Musikk
Trending Tabs: På veg opp
Movies: Filmar
Gaming: Dataspel
Default: Standard
Most Popular: 'Mest populært'
Playlists: 'Spelelister'
User Playlists:

View File

@@ -41,10 +41,7 @@ Channels:
Channels: 'ଚ୍ୟାନେଲ'
Title: 'ଚ୍ୟାନେଲ ତାଲିକା'
Trending:
Default: 'ଡିଫଲ୍ଟ'
Music: 'ସଙ୍ଗୀତ'
Gaming: 'ଖେଳ'
Movies: 'ଚଳଚ୍ଚିତ୍ର'
Playlists: 'ଚାଳନାତାଲିକା'
User Playlists:
Your Playlists: 'ଆପଣଙ୍କ ଚାଳନାତାଲିକା'

View File

@@ -103,10 +103,7 @@ Subscriptions:
Trending:
Trending: 'Na czasie'
Trending Tabs: Karty „Na czasie”
Default: Domyślna
Music: Muzyka
Gaming: Gry
Movies: Filmy
Most Popular: 'Popularne'
Playlists: 'Playlisty'
User Playlists:

View File

@@ -103,10 +103,8 @@ Subscriptions:
Trending:
Trending: 'Em alta'
Trending Tabs: Guias em destaque
Movies: Filmes
Gaming: Jogos
Music: Música
Default: Padrão
Sports: Esportes
Most Popular: 'Mais populares'
Playlists: 'Playlists'
User Playlists:

View File

@@ -110,10 +110,7 @@ Subscriptions:
Trending:
Trending: Tendências
Trending Tabs: Separadores de tendências
Movies: Filmes
Gaming: Jogos
Music: Música
Default: Padrão
Most Popular: Mais populares
Playlists: Listas de reprodução
User Playlists:

View File

@@ -110,10 +110,7 @@ Subscriptions:
Trending:
Trending: 'Tendências'
Trending Tabs: Separadores de tendências
Movies: Filmes
Gaming: Jogos
Music: Músicas
Default: Padrão
Most Popular: 'Mais populares'
Playlists: 'Listas de reprodução'
User Playlists:

View File

@@ -109,10 +109,7 @@ Subscriptions:
Trending:
Trending: 'Tendințe'
Trending Tabs: File în tendințe
Movies: Filme
Gaming: Gaming
Music: Muzică
Default: Implicit
Most Popular: 'Cele mai populare'
Playlists: 'Liste de redare'
User Playlists:

View File

@@ -103,10 +103,7 @@ Subscriptions:
Trending:
Trending: 'В тренде'
Trending Tabs: Вкладки В тренде
Movies: Фильмы
Gaming: Игры
Music: Музыка
Default: По умолчанию
Most Popular: 'Самые популярные'
Playlists: 'Плейлисты'
User Playlists:

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