Add Tabs to subscriptions page for live streams and shorts (#3725)

* Add Tabs to subscriptions page for live streams and shorts

* Fix naming issue with fetching live streams via Invidious RSS

* Remove console log

* Better error handling and better live stream sorting

* Fix linter issues

* Change videos RSS feed. Make live stream call more efficient.

* Store last used tab in memory. Return to last used tab on mount

* Fix live sorting. Reorganize tabs and check for currentTab via created instead of mounted

* Fix linting issue

* Start Global locales object, add distraction free checks for subscriptions tab

* Start Global locales object for all locales

* Cleanup and reduce duplicate code

* Undo original distraction free settings change

* Fix missing change in previous commit

* Add distraction free settings to hide tabs

* Improve accessibility

* Make app-wide hide live streams setting override hide subscriptions live

* Fix incorrect all tabs disabled message

* Fix arrow key navigation

* Create shared UI component for the subscription tabs

---------

Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
This commit is contained in:
Preston 2023-07-21 07:33:34 -04:00 committed by GitHub
parent 8fbc53da7a
commit b9eb2a76fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 1895 additions and 574 deletions

View File

@ -80,11 +80,27 @@ export default defineComponent({
hideChannelCommunity: function() {
return this.$store.getters.getHideChannelCommunity
},
hideSubscriptionsVideos: function() {
return this.$store.getters.getHideSubscriptionsVideos
},
hideSubscriptionsShorts: function() {
return this.$store.getters.getHideSubscriptionsShorts
},
hideSubscriptionsLive: function() {
return this.$store.getters.getHideSubscriptionsLive
},
showDistractionFreeTitles: function () {
return this.$store.getters.getShowDistractionFreeTitles
},
channelsHidden: function () {
return JSON.parse(this.$store.getters.getChannelsHidden)
},
hideSubscriptionsLiveTooltip: function () {
return this.$t('Tooltips.Distraction Free Settings.Hide Subscriptions Live', {
appWideSetting: this.$t('Settings.Distraction Free Settings.Hide Live Streams'),
subsection: this.$t('Settings.Distraction Free Settings.Sections.General'),
settingsSection: this.$t('Settings.Distraction Free Settings.Distraction Free Settings')
})
}
},
methods: {
@ -125,7 +141,10 @@ export default defineComponent({
'updateHideChannelPlaylists',
'updateHideChannelCommunity',
'updateHideChannelPodcasts',
'updateHideChannelReleases'
'updateHideChannelReleases',
'updateHideSubscriptionsVideos',
'updateHideSubscriptionsShorts',
'updateHideSubscriptionsLive'
])
}
})

View File

@ -37,6 +37,37 @@
/>
</div>
</div>
<h4
class="groupTitle"
>
{{ $t('Settings.Distraction Free Settings.Sections.Subscriptions Page') }}
</h4>
<div class="switchColumnGrid">
<div class="switchColumn">
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Subscriptions Videos')"
:compact="true"
:default-value="hideSubscriptionsVideos"
@change="updateHideSubscriptionsVideos"
/>
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Subscriptions Shorts')"
:compact="true"
:default-value="hideSubscriptionsShorts"
@change="updateHideSubscriptionsShorts"
/>
</div>
<div class="switchColumn">
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Subscriptions Live')"
:compact="true"
:disabled="hideLiveStreams"
:default-value="hideLiveStreams || hideSubscriptionsLive"
:tooltip="hideLiveStreams ? hideSubscriptionsLiveTooltip : ''"
v-on="!hideLiveStreams ? { change: updateHideSubscriptionsLive } : {}"
/>
</div>
</div>
<h4
class="groupTitle"
>

View File

@ -0,0 +1,335 @@
import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue'
import { copyToClipboard, showToast } from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannelLiveStreams } from '../../helpers/api/local'
import { addPublishedDatesInvidious, addPublishedDatesLocal, parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions'
export default defineComponent({
name: 'SubscriptionsLive',
components: {
'subscriptions-tab-ui': SubscriptionsTabUI
},
data: function () {
return {
isLoading: false,
videoList: [],
errorChannels: [],
attemptedFetch: false,
}
},
computed: {
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
useRssFeeds: function () {
return this.$store.getters.getUseRssFeeds
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
activeProfileId: function () {
return this.activeProfile._id
},
cacheEntriesForAllActiveProfileChannels() {
const entries = []
this.activeSubscriptionList.forEach((channel) => {
const cacheEntry = this.$store.getters.getLiveCacheByChannel(channel.id)
if (cacheEntry == null) { return }
entries.push(cacheEntry)
})
return entries
},
videoCacheForAllActiveProfileChannelsPresent() {
if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false }
if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false }
return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => {
return cacheEntry.videos != null
})
},
activeSubscriptionList: function () {
return this.activeProfile.subscriptions
},
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
},
},
watch: {
activeProfile: async function (_) {
this.isLoading = true
this.loadVideosFromCacheSometimes()
},
},
mounted: async function () {
this.isLoading = true
this.loadVideosFromCacheSometimes()
},
methods: {
loadVideosFromCacheSometimes() {
// This method is called on view visible
if (this.videoCacheForAllActiveProfileChannelsPresent) {
this.loadVideosFromCacheForAllActiveProfileChannels()
return
}
this.maybeLoadVideosForSubscriptionsFromRemote()
},
async loadVideosFromCacheForAllActiveProfileChannels() {
const videoList = []
this.activeSubscriptionList.forEach((channel) => {
const channelCacheEntry = this.$store.getters.getLiveCacheByChannel(channel.id)
videoList.push(...channelCacheEntry.videos)
})
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
},
loadVideosForSubscriptionsFromRemote: async function () {
if (this.activeSubscriptionList.length === 0) {
this.isLoading = false
this.videoList = []
return
}
const channelsToLoadFromRemote = this.activeSubscriptionList
const videoList = []
let channelCount = 0
this.isLoading = true
let useRss = this.useRssFeeds
if (channelsToLoadFromRemote.length >= 125 && !useRss) {
showToast(
this.$t('Subscriptions["This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting"]'),
10000
)
useRss = true
}
this.updateShowProgressBar(true)
this.setProgressBarPercentage(0)
this.attemptedFetch = true
this.errorChannels = []
const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let videos = []
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
if (useRss) {
videos = await this.getChannelLiveInvidiousRSS(channel)
} else {
videos = await this.getChannelLiveInvidious(channel)
}
} else {
if (useRss) {
videos = await this.getChannelLiveLocalRSS(channel)
} else {
videos = await this.getChannelLiveLocal(channel)
}
}
channelCount++
const percentageComplete = (channelCount / channelsToLoadFromRemote.length) * 100
this.setProgressBarPercentage(percentageComplete)
this.updateSubscriptionLiveCacheByChannel({
channelId: channel.id,
videos: videos,
})
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
this.updateShowProgressBar(false)
},
maybeLoadVideosForSubscriptionsFromRemote: async function () {
if (this.fetchSubscriptionsAutomatically) {
// `this.isLoading = false` is called inside `loadVideosForSubscriptionsFromRemote` when needed
await this.loadVideosForSubscriptionsFromRemote()
} else {
this.videoList = []
this.attemptedFetch = false
this.isLoading = false
}
},
getChannelLiveLocal: async function (channel, failedAttempts = 0) {
try {
const entries = await getLocalChannelLiveStreams(channel.id)
if (entries === null) {
this.errorChannels.push(channel)
return []
}
addPublishedDatesLocal(entries)
return entries
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
switch (failedAttempts) {
case 0:
return await this.getChannelLiveLocalRSS(channel, failedAttempts + 1)
case 1:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return await this.getChannelLiveInvidious(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return await this.getChannelLiveLocalRSS(channel, failedAttempts + 1)
default:
return []
}
}
},
getChannelLiveLocalRSS: async function (channel, failedAttempts = 0) {
const playlistId = channel.id.replace('UC', 'UULV')
const feedUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}`
try {
const response = await fetch(feedUrl)
if (response.status === 404) {
return []
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
} catch (error) {
console.error(error)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
switch (failedAttempts) {
case 0:
return this.getChannelLiveLocal(channel, failedAttempts + 1)
case 1:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return this.getChannelLiveInvidiousRSS(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return this.getChannelLiveLocal(channel, failedAttempts + 1)
default:
return []
}
}
},
getChannelLiveInvidious: function (channel, failedAttempts = 0) {
return new Promise((resolve, reject) => {
const subscriptionsPayload = {
resource: 'channels',
id: channel.id,
subResource: 'streams',
params: {}
}
invidiousAPICall(subscriptionsPayload).then((result) => {
const videos = result.videos.filter(e => e.type === 'video')
addPublishedDatesInvidious(videos)
resolve(videos)
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err.responseText}`, 10000, () => {
copyToClipboard(err.responseText)
})
switch (failedAttempts) {
case 0:
resolve(this.getChannelLiveInvidiousRSS(channel, failedAttempts + 1))
break
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
resolve(this.getChannelLiveLocal(channel, failedAttempts + 1))
} else {
resolve([])
}
break
case 2:
resolve(this.getChannelLiveInvidiousRSS(channel, failedAttempts + 1))
break
default:
resolve([])
}
})
})
},
getChannelLiveInvidiousRSS: async function (channel, failedAttempts = 0) {
const playlistId = channel.id.replace('UC', 'UULV')
const feedUrl = `${this.currentInvidiousInstance}/feed/playlist/${playlistId}`
try {
const response = await fetch(feedUrl)
if (response.status === 500) {
return []
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
} catch (error) {
console.error(error)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
switch (failedAttempts) {
case 0:
return this.getChannelLiveInvidious(channel, failedAttempts + 1)
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
return this.getChannelLiveLocalRSS(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return this.getChannelLiveInvidious(channel, failedAttempts + 1)
default:
return []
}
}
},
...mapActions([
'updateShowProgressBar',
'updateSubscriptionLiveCacheByChannel',
]),
...mapMutations([
'setProgressBarPercentage'
])
}
})

View File

@ -0,0 +1,11 @@
<template>
<subscriptions-tab-ui
:is-loading="isLoading"
:video-list="videoList"
:error-channels="errorChannels"
:attempted-fetch="attemptedFetch"
@refresh="loadVideosForSubscriptionsFromRemote"
/>
</template>
<script src="./subscriptions-live.js" />

View File

@ -0,0 +1,225 @@
import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue'
import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions'
import { copyToClipboard, showToast } from '../../helpers/utils'
export default defineComponent({
name: 'SubscriptionsShorts',
components: {
'subscriptions-tab-ui': SubscriptionsTabUI
},
data: function () {
return {
isLoading: false,
videoList: [],
errorChannels: [],
attemptedFetch: false,
}
},
computed: {
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
activeProfileId: function () {
return this.activeProfile._id
},
cacheEntriesForAllActiveProfileChannels() {
const entries = []
this.activeSubscriptionList.forEach((channel) => {
const cacheEntry = this.$store.getters.getShortsCacheByChannel(channel.id)
if (cacheEntry == null) { return }
entries.push(cacheEntry)
})
return entries
},
videoCacheForAllActiveProfileChannelsPresent() {
if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false }
if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false }
return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => {
return cacheEntry.videos != null
})
},
activeSubscriptionList: function () {
return this.activeProfile.subscriptions
},
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
},
},
watch: {
activeProfile: async function (_) {
this.isLoading = true
this.loadVideosFromCacheSometimes()
},
},
mounted: async function () {
this.isLoading = true
this.loadVideosFromCacheSometimes()
},
methods: {
loadVideosFromCacheSometimes() {
// This method is called on view visible
if (this.videoCacheForAllActiveProfileChannelsPresent) {
this.loadVideosFromCacheForAllActiveProfileChannels()
return
}
this.maybeLoadVideosForSubscriptionsFromRemote()
},
async loadVideosFromCacheForAllActiveProfileChannels() {
const videoList = []
this.activeSubscriptionList.forEach((channel) => {
const channelCacheEntry = this.$store.getters.getShortsCacheByChannel(channel.id)
videoList.push(...channelCacheEntry.videos)
})
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
},
loadVideosForSubscriptionsFromRemote: async function () {
if (this.activeSubscriptionList.length === 0) {
this.isLoading = false
this.videoList = []
return
}
const channelsToLoadFromRemote = this.activeSubscriptionList
const videoList = []
let channelCount = 0
this.isLoading = true
this.updateShowProgressBar(true)
this.setProgressBarPercentage(0)
this.attemptedFetch = true
this.errorChannels = []
const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let videos = []
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
videos = await this.getChannelShortsInvidious(channel)
} else {
videos = await this.getChannelShortsLocal(channel)
}
channelCount++
const percentageComplete = (channelCount / channelsToLoadFromRemote.length) * 100
this.setProgressBarPercentage(percentageComplete)
this.updateSubscriptionShortsCacheByChannel({
channelId: channel.id,
videos: videos,
})
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
this.updateShowProgressBar(false)
},
maybeLoadVideosForSubscriptionsFromRemote: async function () {
if (this.fetchSubscriptionsAutomatically) {
// `this.isLoading = false` is called inside `loadVideosForSubscriptionsFromRemote` when needed
await this.loadVideosForSubscriptionsFromRemote()
} else {
this.videoList = []
this.attemptedFetch = false
this.isLoading = false
}
},
getChannelShortsLocal: async function (channel, failedAttempts = 0) {
const playlistId = channel.id.replace('UC', 'UUSH')
const feedUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}`
try {
const response = await fetch(feedUrl)
if (response.status === 404) {
return []
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
} catch (error) {
console.error(error)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
switch (failedAttempts) {
case 0:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return this.getChannelShortsInvidious(channel, failedAttempts + 1)
} else {
return []
}
default:
return []
}
}
},
getChannelShortsInvidious: async function (channel, failedAttempts = 0) {
const playlistId = channel.id.replace('UC', 'UUSH')
const feedUrl = `${this.currentInvidiousInstance}/feed/playlist/${playlistId}`
try {
const response = await fetch(feedUrl)
if (response.status === 500) {
return []
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
} catch (error) {
console.error(error)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
switch (failedAttempts) {
case 0:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
return this.getChannelShortsLocal(channel, failedAttempts + 1)
} else {
return []
}
default:
return []
}
}
},
...mapActions([
'updateShowProgressBar',
'updateSubscriptionShortsCacheByChannel',
]),
...mapMutations([
'setProgressBarPercentage'
])
}
})

View File

@ -0,0 +1,11 @@
<template>
<subscriptions-tab-ui
:is-loading="isLoading"
:video-list="videoList"
:error-channels="errorChannels"
:attempted-fetch="attemptedFetch"
@refresh="loadVideosForSubscriptionsFromRemote"
/>
</template>
<script src="./subscriptions-shorts.js" />

View File

@ -0,0 +1,31 @@
.card {
width: 85%;
margin: 0 auto;
margin-bottom: 60px;
}
.message {
color: var(--tertiary-text-color);
}
.floatingTopButton {
position: fixed;
top: 70px;
right: 10px;
}
.channelBubble {
display: inline-block;
}
@media only screen and (max-width: 350px) {
.floatingTopButton {
position: absolute
}
}
@media only screen and (max-width: 680px) {
.card {
width: 90%;
}
}

View File

@ -0,0 +1,111 @@
import { defineComponent } from 'vue'
import FtLoader from '../ft-loader/ft-loader.vue'
import FtCard from '../ft-card/ft-card.vue'
import FtButton from '../ft-button/ft-button.vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtElementList from '../ft-element-list/ft-element-list.vue'
import FtChannelBubble from '../ft-channel-bubble/ft-channel-bubble.vue'
export default defineComponent({
name: 'SubscriptionsTabUI',
components: {
'ft-loader': FtLoader,
'ft-card': FtCard,
'ft-button': FtButton,
'ft-icon-button': FtIconButton,
'ft-flex-box': FtFlexBox,
'ft-element-list': FtElementList,
'ft-channel-bubble': FtChannelBubble
},
props: {
isLoading: {
type: Boolean,
default: false
},
videoList: {
type: Array,
default: () => ([])
},
errorChannels: {
type: Array,
default: () => ([])
},
attemptedFetch: {
type: Boolean,
default: false
},
},
data: function () {
return {
dataLimit: 100,
}
},
computed: {
activeVideoList: function () {
if (this.videoList.length < this.dataLimit) {
return this.videoList
} else {
return this.videoList.slice(0, this.dataLimit)
}
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
activeSubscriptionList: function () {
return this.activeProfile.subscriptions
},
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
},
},
created: function () {
const dataLimit = sessionStorage.getItem('subscriptionLimit')
if (dataLimit !== null) {
this.dataLimit = dataLimit
}
},
mounted: async function () {
document.addEventListener('keydown', this.keyboardShortcutHandler)
},
beforeDestroy: function () {
document.removeEventListener('keydown', this.keyboardShortcutHandler)
},
methods: {
goToChannel: function (id) {
this.$router.push({ path: `/channel/${id}` })
},
increaseLimit: function () {
this.dataLimit += 100
sessionStorage.setItem('subscriptionLimit', this.dataLimit)
},
/**
* This function `keyboardShortcutHandler` should always be at the bottom of this file
* @param {KeyboardEvent} event the keyboard event
*/
keyboardShortcutHandler: function (event) {
if (event.ctrlKey || document.activeElement.classList.contains('ft-input')) {
return
}
// Avoid handling events due to user holding a key (not released)
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat
if (event.repeat) { return }
switch (event.key) {
case 'r':
case 'R':
if (!this.isLoading) {
this.$emit('refresh')
}
break
}
}
}
})

View File

@ -0,0 +1,72 @@
<template>
<div>
<ft-loader
v-if="isLoading"
/>
<div
v-if="!isLoading && errorChannels.length !== 0"
>
<h3> {{ $t("Subscriptions.Error Channels") }}</h3>
<div>
<ft-channel-bubble
v-for="(channel, index) in errorChannels"
:key="index"
:channel-name="channel.name"
:channel-id="channel.id"
:channel-thumbnail="channel.thumbnail"
class="channelBubble"
@click="goToChannel(channel.id)"
/>
</div>
</div>
<ft-flex-box
v-if="!isLoading && activeVideoList.length === 0"
>
<p
v-if="activeSubscriptionList.length === 0"
class="message"
>
{{ $t("Subscriptions['Your Subscription list is currently empty. Start adding subscriptions to see them here.']") }}
</p>
<p
v-else-if="!fetchSubscriptionsAutomatically && !attemptedFetch"
class="message"
>
{{ $t("Subscriptions.Disabled Automatic Fetching") }}
</p>
<p
v-else
class="message"
>
{{ $t("Subscriptions.Empty Channels") }}
</p>
</ft-flex-box>
<ft-element-list
v-if="!isLoading && activeVideoList.length > 0"
:data="activeVideoList"
:use-channels-hidden-preference="false"
/>
<ft-flex-box
v-if="!isLoading && videoList.length > dataLimit"
>
<ft-button
:label="$t('Subscriptions.Load More Videos')"
background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@click="increaseLimit"
/>
</ft-flex-box>
<ft-icon-button
v-if="!isLoading"
:icon="['fas', 'sync']"
class="floatingTopButton"
:title="$t('Subscriptions.Refresh Subscriptions')"
:size="12"
theme="primary"
@click="$emit('refresh')"
/>
</div>
</template>
<script src="./subscriptions-tab-ui.js" />
<style scoped src="./subscriptions-tab-ui.css" />

View File

@ -0,0 +1,334 @@
import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue'
import { copyToClipboard, showToast } from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannelVideos } from '../../helpers/api/local'
import { addPublishedDatesInvidious, addPublishedDatesLocal, parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions'
export default defineComponent({
name: 'SubscriptionsVideos',
components: {
'subscriptions-tab-ui': SubscriptionsTabUI
},
data: function () {
return {
isLoading: false,
videoList: [],
errorChannels: [],
attemptedFetch: false,
}
},
computed: {
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
useRssFeeds: function () {
return this.$store.getters.getUseRssFeeds
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
activeProfileId: function () {
return this.activeProfile._id
},
cacheEntriesForAllActiveProfileChannels() {
const entries = []
this.activeSubscriptionList.forEach((channel) => {
const cacheEntry = this.$store.getters.getVideoCacheByChannel(channel.id)
if (cacheEntry == null) { return }
entries.push(cacheEntry)
})
return entries
},
videoCacheForAllActiveProfileChannelsPresent() {
if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false }
if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false }
return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => {
return cacheEntry.videos != null
})
},
activeSubscriptionList: function () {
return this.activeProfile.subscriptions
},
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
},
},
watch: {
activeProfile: async function (_) {
this.isLoading = true
this.loadVideosFromCacheSometimes()
},
},
mounted: async function () {
this.isLoading = true
this.loadVideosFromCacheSometimes()
},
methods: {
loadVideosFromCacheSometimes() {
// This method is called on view visible
if (this.videoCacheForAllActiveProfileChannelsPresent) {
this.loadVideosFromCacheForAllActiveProfileChannels()
return
}
this.maybeLoadVideosForSubscriptionsFromRemote()
},
async loadVideosFromCacheForAllActiveProfileChannels() {
const videoList = []
this.activeSubscriptionList.forEach((channel) => {
const channelCacheEntry = this.$store.getters.getVideoCacheByChannel(channel.id)
videoList.push(...channelCacheEntry.videos)
})
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
},
loadVideosForSubscriptionsFromRemote: async function () {
if (this.activeSubscriptionList.length === 0) {
this.isLoading = false
this.videoList = []
return
}
const channelsToLoadFromRemote = this.activeSubscriptionList
const videoList = []
let channelCount = 0
this.isLoading = true
let useRss = this.useRssFeeds
if (channelsToLoadFromRemote.length >= 125 && !useRss) {
showToast(
this.$t('Subscriptions["This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting"]'),
10000
)
useRss = true
}
this.updateShowProgressBar(true)
this.setProgressBarPercentage(0)
this.attemptedFetch = true
this.errorChannels = []
const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let videos = []
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
if (useRss) {
videos = await this.getChannelVideosInvidiousRSS(channel)
} else {
videos = await this.getChannelVideosInvidiousScraper(channel)
}
} else {
if (useRss) {
videos = await this.getChannelVideosLocalRSS(channel)
} else {
videos = await this.getChannelVideosLocalScraper(channel)
}
}
channelCount++
const percentageComplete = (channelCount / channelsToLoadFromRemote.length) * 100
this.setProgressBarPercentage(percentageComplete)
this.updateSubscriptionVideosCacheByChannel({
channelId: channel.id,
videos: videos,
})
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
this.videoList = updateVideoListAfterProcessing(videoList)
this.isLoading = false
this.updateShowProgressBar(false)
},
maybeLoadVideosForSubscriptionsFromRemote: async function () {
if (this.fetchSubscriptionsAutomatically) {
// `this.isLoading = false` is called inside `loadVideosForSubscriptionsFromRemote` when needed
await this.loadVideosForSubscriptionsFromRemote()
} else {
this.videoList = []
this.attemptedFetch = false
this.isLoading = false
}
},
getChannelVideosLocalScraper: async function (channel, failedAttempts = 0) {
try {
const videos = await getLocalChannelVideos(channel.id)
if (videos === null) {
this.errorChannels.push(channel)
return []
}
addPublishedDatesLocal(videos)
return videos
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
switch (failedAttempts) {
case 0:
return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
case 1:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return await this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
default:
return []
}
}
},
getChannelVideosLocalRSS: async function (channel, failedAttempts = 0) {
const playlistId = channel.id.replace('UC', 'UULF')
const feedUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}`
try {
const response = await fetch(feedUrl)
if (response.status === 404) {
this.errorChannels.push(channel)
return []
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
} catch (error) {
console.error(error)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
switch (failedAttempts) {
case 0:
return this.getChannelVideosLocalScraper(channel, failedAttempts + 1)
case 1:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return this.getChannelVideosLocalScraper(channel, failedAttempts + 1)
default:
return []
}
}
},
getChannelVideosInvidiousScraper: function (channel, failedAttempts = 0) {
return new Promise((resolve, reject) => {
const subscriptionsPayload = {
resource: 'channels/latest',
id: channel.id,
params: {}
}
invidiousAPICall(subscriptionsPayload).then((result) => {
addPublishedDatesInvidious(result.videos)
resolve(result.videos)
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err.responseText}`, 10000, () => {
copyToClipboard(err.responseText)
})
switch (failedAttempts) {
case 0:
resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1))
break
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
resolve(this.getChannelVideosLocalScraper(channel, failedAttempts + 1))
} else {
resolve([])
}
break
case 2:
resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1))
break
default:
resolve([])
}
})
})
},
getChannelVideosInvidiousRSS: async function (channel, failedAttempts = 0) {
const playlistId = channel.id.replace('UC', 'UULF')
const feedUrl = `${this.currentInvidiousInstance}/feed/playlist/${playlistId}`
try {
const response = await fetch(feedUrl)
if (response.status === 500) {
this.errorChannels.push(channel)
return []
}
return await parseYouTubeRSSFeed(await response.text(), channel.id)
} catch (error) {
console.error(error)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
switch (failedAttempts) {
case 0:
return this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
return this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
default:
return []
}
}
},
...mapActions([
'updateShowProgressBar',
'updateSubscriptionVideosCacheByChannel',
]),
...mapMutations([
'setProgressBarPercentage'
])
}
})

View File

@ -0,0 +1,11 @@
<template>
<subscriptions-tab-ui
:is-loading="isLoading"
:video-list="videoList"
:error-channels="errorChannels"
:attempted-fetch="attemptedFetch"
@refresh="loadVideosForSubscriptionsFromRemote"
/>
</template>
<script src="./subscriptions-videos.js" />

View File

@ -256,6 +256,36 @@ export async function getLocalChannelVideos(id) {
}
}
export async function getLocalChannelLiveStreams(id) {
const innertube = await createInnertube()
try {
const response = await innertube.actions.execute(Endpoints.BrowseEndpoint.PATH, Endpoints.BrowseEndpoint.build({
browse_id: id,
params: 'EgdzdHJlYW1z8gYECgJ6AA%3D%3D'
// protobuf for the live tab (this is the one that YouTube uses,
// it has some empty fields in the protobuf but it doesn't work if you remove them)
}))
const liveStreamsTab = new YT.Channel(null, response)
// 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)
} else {
return []
}
} catch (error) {
console.error(error)
if (error instanceof Utils.ChannelError) {
return null
} else {
throw error
}
}
}
/**
* @param {import('youtubei.js').YTNodes.Video[]} videos
* @param {Misc.Author} author

View File

@ -0,0 +1,146 @@
import store from '../store/index'
import { calculatePublishedDate } from './utils'
/**
* Filtering and sort based on user preferences
* @param {any[]} videos
*/
export function updateVideoListAfterProcessing(videos) {
let videoList = videos
// Filtering and sorting based in preference
videoList.sort((a, b) => {
return b.publishedDate - a.publishedDate
})
if (store.getters.getHideLiveStreams) {
videoList = videoList.filter(item => {
return (!item.liveNow && !item.isUpcoming)
})
}
if (store.getters.getHideUpcomingPremieres) {
videoList = videoList.filter(item => {
if (item.isRSS) {
// viewCount is our only method of detecting premieres in RSS
// data without sending an additional request.
// If we ever get a better flag, use it here instead.
return item.viewCount !== '0'
}
// Observed for premieres in Local API Subscriptions.
return (item.premiereDate == null ||
// Invidious API
// `premiereTimestamp` only available on premiered videos
// https://docs.invidious.io/api/common_types/#videoobject
item.premiereTimestamp == null
)
})
}
if (store.getters.getHideWatchedSubs) {
const historyCache = store.getters.getHistoryCache
videoList = videoList.filter((video) => {
const historyIndex = historyCache.findIndex((x) => {
return x.videoId === video.videoId
})
return historyIndex === -1
})
}
return videoList
}
/**
* @param {string} rssString
* @param {string} channelId
*/
export async function parseYouTubeRSSFeed(rssString, channelId) {
// doesn't need to be asynchronous, but doing it allows us to do the relatively slow DOM querying in parallel
try {
const xmlDom = new DOMParser().parseFromString(rssString, 'application/xml')
const channelName = xmlDom.querySelector('author > name').textContent
const entries = xmlDom.querySelectorAll('entry')
const promises = []
for (const entry of entries) {
promises.push(parseRSSEntry(entry, channelId, channelName))
}
return await Promise.all(promises)
} catch (e) {
return []
}
}
/**
* @param {Element} entry
* @param {string} channelId
* @param {string} channelName
*/
async function parseRSSEntry(entry, channelId, channelName) {
// doesn't need to be asynchronous, but doing it allows us to do the relatively slow DOM querying in parallel
const published = new Date(entry.querySelector('published').textContent)
return {
authorId: channelId,
author: channelName,
// querySelector doesn't support xml namespaces so we have to use getElementsByTagName here
videoId: entry.getElementsByTagName('yt:videoId')[0].textContent,
title: entry.querySelector('title').textContent,
publishedDate: published,
publishedText: published.toLocaleString(),
viewCount: entry.getElementsByTagName('media:statistics')[0]?.getAttribute('views') || null,
type: 'video',
lengthSeconds: '0:00',
isRSS: true
}
}
/**
* @param {{
* liveNow: boolean,
* isUpcoming: boolean,
* premiereDate: Date,
* publishedText: string,
* publishedDate: number
* }[]} videos publishedDate is added by this function,
* but adding it to the type definition stops vscode warning that the property doesn't exist
*/
export function addPublishedDatesLocal(videos) {
videos.forEach(video => {
if (video.liveNow) {
video.publishedDate = new Date().getTime()
} else if (video.isUpcoming) {
video.publishedDate = video.premiereDate
} else {
video.publishedDate = calculatePublishedDate(video.publishedText)
}
return video
})
}
/**
* @param {{
* liveNow: boolean,
* isUpcoming: boolean,
* premiereTimestamp: number,
* published: number,
* publishedDate: number
* }[]} videos publishedDate is added by this function,
* but adding it to the type definition stops vscode warning that the property doesn't exist
*/
export function addPublishedDatesInvidious(videos) {
videos.forEach(video => {
if (video.liveNow) {
video.publishedDate = new Date().getTime()
} else if (video.isUpcoming) {
video.publishedDate = new Date(video.premiereTimestamp * 1000)
} else {
video.publishedDate = new Date(video.published * 1000)
}
return video
})
}

View File

@ -211,6 +211,9 @@ const state = {
hideRecommendedVideos: false,
hideSearchBar: false,
hideSharingActions: false,
hideSubscriptionsVideos: false,
hideSubscriptionsShorts: false,
hideSubscriptionsLive: false,
hideTrendingVideos: false,
hideUnsubscribeButton: false,
hideUpcomingPremieres: false,

View File

@ -7,35 +7,91 @@ function deepCopy(obj) {
}
const state = {
subscriptionsCachePerChannel: {},
videoCache: {},
liveCache: {},
shortsCache: {}
}
const getters = {
getSubscriptionsCacheEntriesForOneChannel: (state) => (channelId) => {
return state.subscriptionsCachePerChannel[channelId]
getVideoCache: (state) => {
return state.videoCache
},
getVideoCacheByChannel: (state) => (channelId) => {
return state.videoCache[channelId]
},
getShortsCache: (state) => {
return state.shortsCache
},
getShortsCacheByChannel: (state) => (channelId) => {
return state.shortsCache[channelId]
},
getLiveCache: (state) => {
return state.liveCache
},
getLiveCacheByChannel: (state) => (channelId) => {
return state.liveCache[channelId]
}
}
const actions = {
clearSubscriptionsCache: ({ commit }) => {
commit('clearSubscriptionsCachePerChannel')
clearSubscriptionVideosCache: ({ commit }) => {
commit('clearVideoCache')
},
updateSubscriptionsCacheForOneChannel: ({ commit }, payload) => {
commit('updateSubscriptionsCacheForOneChannel', payload)
updateSubscriptionVideosCacheByChannel: ({ commit }, payload) => {
commit('updateVideoCacheByChannel', payload)
},
clearSubscriptionShortsCache: ({ commit }) => {
commit('clearShortsCache')
},
updateSubscriptionShortsCacheByChannel: ({ commit }, payload) => {
commit('updateShortsCacheByChannel', payload)
},
clearSubscriptionLiveCache: ({ commit }) => {
commit('clearLiveCache')
},
updateSubscriptionLiveCacheByChannel: ({ commit }, payload) => {
commit('updateLiveCacheByChannel', payload)
}
}
const mutations = {
updateSubscriptionsCacheForOneChannel(state, { channelId, videos }) {
const existingObject = state.subscriptionsCachePerChannel[channelId]
updateVideoCacheByChannel(state, { channelId, videos }) {
const existingObject = state.videoCache[channelId]
const newObject = existingObject != null ? existingObject : deepCopy(defaultCacheEntryValueForForOneChannel)
if (videos != null) { newObject.videos = videos }
state.subscriptionsCachePerChannel[channelId] = newObject
state.videoCache[channelId] = newObject
},
clearSubscriptionsCachePerChannel(state) {
state.subscriptionsCachePerChannel = {}
clearVideoCache(state) {
state.videoCache = {}
},
updateShortsCacheByChannel(state, { channelId, videos }) {
const existingObject = state.shortsCache[channelId]
const newObject = existingObject != null ? existingObject : deepCopy(defaultCacheEntryValueForForOneChannel)
if (videos != null) { newObject.videos = videos }
state.shortsCache[channelId] = newObject
},
clearShortsCache(state) {
state.shortsCache = {}
},
updateLiveCacheByChannel(state, { channelId, videos }) {
const existingObject = state.liveCache[channelId]
const newObject = existingObject != null ? existingObject : deepCopy(defaultCacheEntryValueForForOneChannel)
if (videos != null) { newObject.videos = videos }
state.liveCache[channelId] = newObject
},
clearLiveCache(state) {
state.liveCache = {}
}
}
export default {

View File

@ -111,7 +111,7 @@
@click="changeTab('shorts')"
@keydown.left.right.enter.space="changeTab('shorts', $event)"
>
{{ $t("Channel.Shorts.Shorts").toUpperCase() }}
{{ $t("Global.Shorts").toUpperCase() }}
</div>
<div
v-if="tabInfoValues.includes('live') && !hideLiveStreams"

View File

@ -8,20 +8,31 @@
color: var(--tertiary-text-color);
}
.floatingTopButton {
position: fixed;
top: 70px;
right: 10px;
.subscriptionTabs {
width: 100%;
margin-top: -3px;
color: var(--tertiary-text-color);
margin-bottom: 10px;
}
.channelBubble {
display: inline-block;
.selectedTab {
border-bottom: 3px solid var(--primary-color);
color: var(--primary-text-color);
font-weight: bold;
box-sizing: border-box;
margin-bottom: -3px;
}
@media only screen and (max-width: 350px) {
.floatingTopButton {
position: absolute
}
.tab {
text-align: center;
padding: 15px;
font-size: 15px;
cursor: pointer;
align-self: flex-end;
}
.tab:hover {
font-weight: bold;
}
@media only screen and (max-width: 680px) {

View File

@ -1,497 +1,128 @@
import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtButton from '../../components/ft-button/ft-button.vue'
import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
import { calculatePublishedDate, copyToClipboard, showToast } from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannelVideos } from '../../helpers/api/local'
import SubscriptionsVideos from '../../components/subscriptions-videos/subscriptions-videos.vue'
import SubscriptionsLive from '../../components/subscriptions-live/subscriptions-live.vue'
import SubscriptionsShorts from '../../components/subscriptions-shorts/subscriptions-shorts.vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
export default defineComponent({
name: 'Subscriptions',
components: {
'ft-loader': FtLoader,
'subscriptions-videos': SubscriptionsVideos,
'subscriptions-live': SubscriptionsLive,
'subscriptions-shorts': SubscriptionsShorts,
'ft-card': FtCard,
'ft-button': FtButton,
'ft-icon-button': FtIconButton,
'ft-flex-box': FtFlexBox,
'ft-element-list': FtElementList,
'ft-channel-bubble': FtChannelBubble
'ft-flex-box': FtFlexBox
},
data: function () {
return {
isLoading: false,
dataLimit: 100,
videoList: [],
errorChannels: [],
attemptedFetch: false,
currentTab: 'videos'
}
},
computed: {
backendPreference: function () {
return this.$store.getters.getBackendPreference
hideSubscriptionsVideos: function () {
return this.$store.getters.getHideSubscriptionsVideos
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
hideSubscriptionsShorts: function () {
return this.$store.getters.getHideSubscriptionsShorts
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
hideSubscriptionsLive: function () {
return this.$store.getters.getHideLiveStreams || this.$store.getters.getHideSubscriptionsLive
},
visibleTabs: function () {
const tabs = []
hideWatchedSubs: function () {
return this.$store.getters.getHideWatchedSubs
},
useRssFeeds: function () {
return this.$store.getters.getUseRssFeeds
},
activeVideoList: function () {
if (this.videoList.length < this.dataLimit) {
return this.videoList
} else {
return this.videoList.slice(0, this.dataLimit)
if (!this.hideSubscriptionsVideos) {
tabs.push('videos')
}
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
activeProfileId: function () {
return this.activeProfile._id
},
if (!this.hideSubscriptionsShorts) {
tabs.push('shorts')
}
cacheEntriesForAllActiveProfileChannels() {
const entries = []
this.activeSubscriptionList.forEach((channel) => {
const cacheEntry = this.$store.getters.getSubscriptionsCacheEntriesForOneChannel(channel.id)
if (cacheEntry == null) { return }
if (!this.hideSubscriptionsLive) {
tabs.push('live')
}
entries.push(cacheEntry)
})
return entries
},
videoCacheForAllActiveProfileChannelsPresent() {
if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false }
if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false }
return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => {
return cacheEntry.videos != null
})
},
historyCache: function () {
return this.$store.getters.getHistoryCache
},
activeSubscriptionList: function () {
return this.activeProfile.subscriptions
},
hideLiveStreams: function() {
return this.$store.getters.getHideLiveStreams
},
hideUpcomingPremieres: function () {
return this.$store.getters.getHideUpcomingPremieres
},
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
},
return tabs
}
},
watch: {
activeProfile: async function (_) {
this.isLoading = true
this.loadVideosFromCacheSometimes()
currentTab(value) {
if (value !== null) {
// Save last used tab, restore when view mounted again
sessionStorage.setItem('Subscriptions/currentTab', value)
} else {
sessionStorage.removeItem('Subscriptions/currentTab')
}
},
},
mounted: async function () {
document.addEventListener('keydown', this.keyboardShortcutHandler)
this.isLoading = true
const dataLimit = sessionStorage.getItem('subscriptionLimit')
if (dataLimit !== null) {
this.dataLimit = dataLimit
/**
* @param {string[]} newValue
*/
visibleTabs: function (newValue) {
if (newValue.length === 0) {
this.currentTab = null
} else if (!newValue.includes(this.currentTab)) {
this.currentTab = newValue[0]
}
}
this.loadVideosFromCacheSometimes()
},
beforeDestroy: function () {
document.removeEventListener('keydown', this.keyboardShortcutHandler)
created: async function () {
if (this.visibleTabs.length === 0) {
this.currentTab = null
} else {
// Restore currentTab
const lastCurrentTabId = sessionStorage.getItem('Subscriptions/currentTab')
if (lastCurrentTabId !== null) { this.changeTab(lastCurrentTabId) }
}
},
methods: {
loadVideosFromCacheSometimes() {
// This method is called on view visible
if (this.videoCacheForAllActiveProfileChannelsPresent) {
this.loadVideosFromCacheForAllActiveProfileChannels()
changeTab: function (tab) {
if (tab === this.currentTab) {
return
}
this.maybeLoadVideosForSubscriptionsFromRemote()
},
async loadVideosFromCacheForAllActiveProfileChannels() {
const videoList = []
this.activeSubscriptionList.forEach((channel) => {
const channelCacheEntry = this.$store.getters.getSubscriptionsCacheEntriesForOneChannel(channel.id)
videoList.push(...channelCacheEntry.videos)
})
this.updateVideoListAfterProcessing(videoList)
this.isLoading = false
},
goToChannel: function (id) {
this.$router.push({ path: `/channel/${id}` })
},
loadVideosForSubscriptionsFromRemote: async function () {
if (this.activeSubscriptionList.length === 0) {
this.isLoading = false
this.videoList = []
return
}
const channelsToLoadFromRemote = this.activeSubscriptionList
const videoList = []
let channelCount = 0
this.isLoading = true
let useRss = this.useRssFeeds
if (channelsToLoadFromRemote.length >= 125 && !useRss) {
showToast(
this.$t('Subscriptions["This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting"]'),
10000
)
useRss = true
}
this.updateShowProgressBar(true)
this.setProgressBarPercentage(0)
this.attemptedFetch = true
this.errorChannels = []
const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let videos = []
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
if (useRss) {
videos = await this.getChannelVideosInvidiousRSS(channel)
} else {
videos = await this.getChannelVideosInvidiousScraper(channel)
}
} else {
if (useRss) {
videos = await this.getChannelVideosLocalRSS(channel)
} else {
videos = await this.getChannelVideosLocalScraper(channel)
}
}
channelCount++
const percentageComplete = (channelCount / channelsToLoadFromRemote.length) * 100
this.setProgressBarPercentage(percentageComplete)
this.updateSubscriptionsCacheForOneChannel({
channelId: channel.id,
videos: videos,
})
return videos
}))).flatMap((o) => o)
videoList.push(...videoListFromRemote)
this.updateVideoListAfterProcessing(videoList)
this.isLoading = false
this.updateShowProgressBar(false)
},
updateVideoListAfterProcessing(videoList) {
// Filtering and sorting based in preference
videoList.sort((a, b) => {
return b.publishedDate - a.publishedDate
})
if (this.hideLiveStreams) {
videoList = videoList.filter(item => {
return (!item.liveNow && !item.isUpcoming)
})
}
if (this.hideUpcomingPremieres) {
videoList = videoList.filter(item => {
if (item.isRSS) {
// viewCount is our only method of detecting premieres in RSS
// data without sending an additional request.
// If we ever get a better flag, use it here instead.
return item.viewCount !== '0'
}
// Observed for premieres in Local API Subscriptions.
return (item.premiereDate == null ||
// Invidious API
// `premiereTimestamp` only available on premiered videos
// https://docs.invidious.io/api/common_types/#videoobject
item.premiereTimestamp == null
)
})
}
this.videoList = videoList.filter((video) => {
if (this.hideWatchedSubs) {
const historyIndex = this.historyCache.findIndex((x) => {
return x.videoId === video.videoId
})
return historyIndex === -1
} else {
return true
}
})
},
maybeLoadVideosForSubscriptionsFromRemote: async function () {
if (this.fetchSubscriptionsAutomatically) {
// `this.isLoading = false` is called inside `loadVideosForSubscriptionsFromRemote` when needed
await this.loadVideosForSubscriptionsFromRemote()
if (this.visibleTabs.includes(tab)) {
this.currentTab = tab
} else {
this.videoList = []
this.attemptedFetch = false
this.isLoading = false
this.currentTab = null
}
},
getChannelVideosLocalScraper: async function (channel, failedAttempts = 0) {
try {
const videos = await getLocalChannelVideos(channel.id)
if (videos === null) {
this.errorChannels.push(channel)
return []
}
videos.map(video => {
if (video.liveNow) {
video.publishedDate = new Date().getTime()
} else if (video.isUpcoming) {
video.publishedDate = video.premiereDate
} else {
video.publishedDate = calculatePublishedDate(video.publishedText)
}
return video
})
return videos
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
switch (failedAttempts) {
case 0:
return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
case 1:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return await this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
default:
return []
}
}
},
getChannelVideosLocalRSS: async function (channel, failedAttempts = 0) {
const feedUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channel.id}`
try {
const response = await fetch(feedUrl)
if (response.status === 404) {
this.errorChannels.push(channel)
return []
}
return await this.parseYouTubeRSSFeed(await response.text(), channel.id)
} catch (error) {
console.error(error)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
switch (failedAttempts) {
case 0:
return this.getChannelVideosLocalScraper(channel, failedAttempts + 1)
case 1:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return this.getChannelVideosLocalScraper(channel, failedAttempts + 1)
default:
return []
}
}
},
getChannelVideosInvidiousScraper: function (channel, failedAttempts = 0) {
return new Promise((resolve, reject) => {
const subscriptionsPayload = {
resource: 'channels/latest',
id: channel.id,
params: {}
}
invidiousAPICall(subscriptionsPayload).then(async (result) => {
resolve(await Promise.all(result.videos.map((video) => {
if (video.liveNow) {
video.publishedDate = new Date().getTime()
} else if (video.isUpcoming) {
video.publishedDate = new Date(video.premiereTimestamp * 1000)
} else {
video.publishedDate = new Date(video.published * 1000)
}
return video
})))
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err.responseText}`, 10000, () => {
copyToClipboard(err.responseText)
})
switch (failedAttempts) {
case 0:
resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1))
break
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
resolve(this.getChannelVideosLocalScraper(channel, failedAttempts + 1))
} else {
resolve([])
}
break
case 2:
resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1))
break
default:
resolve([])
}
})
})
},
getChannelVideosInvidiousRSS: async function (channel, failedAttempts = 0) {
const feedUrl = `${this.currentInvidiousInstance}/feed/channel/${channel.id}`
try {
const response = await fetch(feedUrl)
if (response.status === 500) {
this.errorChannels.push(channel)
return []
}
return await this.parseYouTubeRSSFeed(await response.text(), channel.id)
} catch (error) {
console.error(error)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${error}`, 10000, () => {
copyToClipboard(error)
})
switch (failedAttempts) {
case 0:
return this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
case 1:
if (process.env.IS_ELECTRON && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
return this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
default:
return []
}
}
},
async parseYouTubeRSSFeed(rssString, channelId) {
const xmlDom = new DOMParser().parseFromString(rssString, 'application/xml')
const channelName = xmlDom.querySelector('author > name').textContent
const entries = xmlDom.querySelectorAll('entry')
const promises = []
for (const entry of entries) {
promises.push(this.parseRSSEntry(entry, channelId, channelName))
}
return await Promise.all(promises)
},
async parseRSSEntry(entry, channelId, channelName) {
const published = new Date(entry.querySelector('published').textContent)
return {
authorId: channelId,
author: channelName,
// querySelector doesn't support xml namespaces so we have to use getElementsByTagName here
videoId: entry.getElementsByTagName('yt:videoId')[0].textContent,
title: entry.querySelector('title').textContent,
publishedDate: published,
publishedText: published.toLocaleString(),
viewCount: entry.getElementsByTagName('media:statistics')[0]?.getAttribute('views') || null,
type: 'video',
lengthSeconds: '0:00',
isRSS: true
}
},
increaseLimit: function () {
this.dataLimit += 100
sessionStorage.setItem('subscriptionLimit', this.dataLimit)
},
/**
* This function `keyboardShortcutHandler` should always be at the bottom of this file
* @param {KeyboardEvent} event the keyboard event
* @param {KeyboardEvent} event
* @param {string} currentTab
*/
keyboardShortcutHandler: function (event) {
if (event.ctrlKey || document.activeElement.classList.contains('ft-input')) {
return
focusTab: function (event, currentTab) {
if (!event.altKey) {
event.preventDefault()
const visibleTabs = this.visibleTabs
if (visibleTabs.length === 1) {
this.$emit('showOutlines')
return
}
let index = visibleTabs.indexOf(currentTab)
if (event.key === 'ArrowLeft') {
index--
} else {
index++
}
if (index < 0) {
index = visibleTabs.length - 1
} else if (index > visibleTabs.length - 1) {
index = 0
}
this.$refs[visibleTabs[index]].focus()
this.$emit('showOutlines')
}
// Avoid handling events due to user holding a key (not released)
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat
if (event.repeat) { return }
switch (event.key) {
case 'r':
case 'R':
if (!this.isLoading) {
this.loadVideosForSubscriptionsFromRemote()
}
break
}
},
...mapActions([
'updateShowProgressBar',
'updateSubscriptionsCacheForOneChannel',
]),
...mapMutations([
'setProgressBarPercentage'
])
}
}
})

View File

@ -1,76 +1,80 @@
<template>
<div>
<ft-loader
v-if="isLoading"
:fullscreen="true"
/>
<ft-card
v-else
class="card"
>
<div
v-if="errorChannels.length !== 0"
>
<h3> {{ $t("Subscriptions.Error Channels") }}</h3>
<div>
<ft-channel-bubble
v-for="(channel, index) in errorChannels"
:key="index"
:channel-name="channel.name"
:channel-id="channel.id"
:channel-thumbnail="channel.thumbnail"
class="channelBubble"
@click="goToChannel(channel.id)"
/>
</div>
</div>
<ft-card class="card">
<h3>{{ $t("Subscriptions.Subscriptions") }}</h3>
<ft-flex-box
v-if="activeVideoList.length === 0"
class="subscriptionTabs"
role="tablist"
:aria-label="$t('Subscriptions.Subscriptions Tabs')"
>
<p
v-if="activeSubscriptionList.length === 0"
class="message"
<div
v-if="!hideSubscriptionsVideos"
ref="videos"
class="tab"
role="tab"
:aria-selected="String(currentTab === 'videos')"
aria-controls="subscriptionsPanel"
:tabindex="currentTab === 'videos' ? 0 : -1"
:class="{ selectedTab: currentTab === 'videos' }"
@click="changeTab('videos')"
@keydown.space.enter.prevent="changeTab('videos')"
@keydown.left.right="focusTab($event, 'videos')"
>
{{ $t("Subscriptions['Your Subscription list is currently empty. Start adding subscriptions to see them here.']") }}
</p>
<p
v-else-if="!fetchSubscriptionsAutomatically && !attemptedFetch"
class="message"
{{ $t("Global.Videos").toUpperCase() }}
</div>
<div
v-if="!hideSubscriptionsShorts"
ref="shorts"
class="tab"
role="tab"
:aria-selected="String(currentTab === 'shorts')"
aria-controls="subscriptionsPanel"
:tabindex="currentTab === 'shorts' ? 0 : -1"
:class="{ selectedTab: currentTab === 'shorts' }"
@click="changeTab('shorts')"
@keydown.space.enter.prevent="changeTab('shorts')"
@keydown.left.right="focusTab($event, 'shorts')"
>
{{ $t("Subscriptions.Disabled Automatic Fetching") }}
</p>
<p
v-else
class="message"
{{ $t("Global.Shorts").toUpperCase() }}
</div>
<div
v-if="!hideSubscriptionsLive"
ref="live"
class="tab"
role="tab"
:aria-selected="String(currentTab === 'live')"
aria-controls="subscriptionsPanel"
:tabindex="currentTab === 'live' ? 0 : -1"
:class="{ selectedTab: currentTab === 'live' }"
@click="changeTab('live')"
@keydown.space.enter.prevent="changeTab('live')"
@keydown.left.right="focusTab($event, 'live')"
>
{{ $t("Subscriptions.Empty Channels") }}
</p>
{{ $t("Global.Live").toUpperCase() }}
</div>
</ft-flex-box>
<ft-element-list
v-else
:data="activeVideoList"
:use-channels-hidden-preference="false"
<subscriptions-videos
v-if="currentTab === 'videos'"
id="subscriptionsPanel"
role="tabpanel"
/>
<ft-flex-box>
<ft-button
v-if="videoList.length > dataLimit"
:label="$t('Subscriptions.Load More Videos')"
background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@click="increaseLimit"
/>
</ft-flex-box>
<subscriptions-shorts
v-if="currentTab === 'shorts'"
id="subscriptionsPanel"
role="tabpanel"
/>
<subscriptions-live
v-if="currentTab === 'live'"
id="subscriptionsPanel"
role="tabpanel"
/>
<p v-if="currentTab === null">
{{ $t("Subscriptions.All Subscription Tabs Hidden", {
subsection: $t('Settings.Distraction Free Settings.Sections.Subscriptions Page'),
settingsSection: $t('Settings.Distraction Free Settings.Distraction Free Settings')
}) }}
</p>
</ft-card>
<ft-icon-button
v-if="!isLoading"
:icon="['fas', 'sync']"
class="floatingTopButton"
:title="$t('Subscriptions.Refresh Subscriptions')"
:size="12"
theme="primary"
@click="loadVideosForSubscriptionsFromRemote"
/>
</div>
</template>

View File

@ -29,6 +29,13 @@ Close: 'إغلاق'
Back: 'رجوع'
Forward: 'إلى الأمام'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'الفيديوهات'
Shorts: القصيرة
Live: مباشر
# Search Bar
Search / Go to URL: 'ابحث / اذهب إلى رابط'
# In Filter Button
@ -623,7 +630,6 @@ Channel:
This channel does not currently have any live streams: لا يوجد حاليا أي بث مباشر
على هذه القناة
Shorts:
Shorts: القصيرة
This channel does not currently have any shorts: هذه القناة ليس لديها حاليا أي
أفلام قصيرة (shorts)
Podcasts:

View File

@ -30,6 +30,13 @@ Close: 'Затваряне'
Back: 'Назад'
Forward: 'Напред'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'видеа'
Shorts: Кратки видеа
Live: На живо
Version {versionNumber} is now available! Click for more details: 'Версия {versionNumber}
е вече налична! Щракнете за повече детайли'
Download From Site: 'Сваляне от сайта'
@ -639,7 +646,6 @@ Channel:
Shorts:
This channel does not currently have any shorts: В момента този канал няма никакви
кратки видеа
Shorts: Кратки видеа
Video:
Mark As Watched: 'Отбелязване като гледано'
Remove From History: 'Премахване от историята'

View File

@ -30,6 +30,11 @@ Close: 'Tancar'
Back: 'Enrere'
Forward: 'Endavant'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Vídeos'
Version {versionNumber} is now available! Click for more details: 'La versió {versionNumber}
està disponible! Fes clic per a més detalls'
Download From Site: 'Descarrega des del web'

View File

@ -29,6 +29,13 @@ Close: 'Zavřít'
Back: 'Zpět'
Forward: 'Dopředu'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videa'
Shorts: Shorts
Live: Živě
Version {versionNumber} is now available! Click for more details: 'Verze {versionNumber}
je k dispozici! Klikněte pro více informací'
Download From Site: 'Stáhnout ze stránky'
@ -623,7 +630,6 @@ Channel:
době nemá žádné živé přenosy
Shorts:
This channel does not currently have any shorts: Tento kanál nemá žádné shorts
Shorts: Shorts
Video:
Mark As Watched: 'Označit jako zhlédnuto'
Remove From History: 'Odstranit z historie'

View File

@ -29,6 +29,11 @@ Close: 'Luk'
Back: 'Tilbage'
Forward: 'Fremad'
# Global
# Anything shared among components / views should be put here
Global:
Videos: Videoer
Version {versionNumber} is now available! Click for more details: 'Version {versionNumber}
er nu tilgængelig! Klik for flere detaljer'
Download From Site: 'Hent Fra Netsted'

View File

@ -28,6 +28,13 @@ Close: Schließen
Back: Zurück
Forward: Vorwärts
# Global
# Anything shared among components / views should be put here
Global:
Videos: Videos
Shorts: Shorts
Live: Live
# Search Bar
Search / Go to URL: Suche / Geh zu URL
# In Filter Button
@ -609,7 +616,6 @@ Channel:
This channel does not currently have any live streams: Dieser Kanal hat derzeit
keine Live-Streams
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: Dieser Kanal hat derzeit keine
Shorts
Video:

View File

@ -30,6 +30,13 @@ Close: 'Κλείσιμο'
Back: 'Μετάβαση πίσω'
Forward: 'Μετάβαση μπροστά'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Βίντεο'
Shorts: Shorts
Live: Ζωντανά
# Search Bar
Search / Go to URL: 'Αναζήτηση/Μετάβαση στη URL'
# In Filter Button
@ -655,7 +662,6 @@ Channel:
This channel does not currently have any live streams: Αυτό το κανάλι δεν έχει
προς το παρόν ζωντανές ροές
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: Αυτό το κανάλι δεν έχει προς
το παρόν κανένα shorts
Releases:

View File

@ -39,6 +39,13 @@ A new blog is now available, {blogTitle}. Click to view more: A new blog is now
Click to view more
Are you sure you want to open this link?: Are you sure you want to open this link?
# Global
# Anything shared among components / views should be put here
Global:
Videos: Videos
Shorts: Shorts
Live: Live
# Search Bar
Search / Go to URL: Search / Go to URL
Search Bar:
@ -93,6 +100,8 @@ Subscriptions:
'Getting Subscriptions. Please wait.': Getting Subscriptions. Please wait.
Refresh Subscriptions: Refresh Subscriptions
Load More Videos: Load More Videos
Subscriptions Tabs: Subscriptions Tabs
All Subscription Tabs Hidden: 'All subscription tabs are hidden. To see content here, please unhide some tabs in the "{subsection}" section in "{settingsSection}".'
More: More
Channels:
Channels: Channels
@ -319,6 +328,7 @@ Settings:
Distraction Free Settings: Distraction Free Settings
Sections:
Side Bar: Side Bar
Subscriptions Page: Subscriptions Page
Channel Page: Channel Page
Watch Page: Watch Page
General: General
@ -347,6 +357,9 @@ Settings:
Hide Channel Shorts: Hide Channel Shorts
Hide Channel Podcasts: Hide Channel Podcasts
Hide Channel Releases: Hide Channel Releases
Hide Subscriptions Videos: Hide Subscriptions Videos
Hide Subscriptions Shorts: Hide Subscriptions Shorts
Hide Subscriptions Live: Hide Subscriptions Live
Data Settings:
Data Settings: Data Settings
Select Import Type: Select Import Type
@ -543,7 +556,6 @@ Channel:
Oldest: Oldest
Most Popular: Most Popular
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: This channel does not currently have any shorts
Live:
Live: Live
@ -839,6 +851,7 @@ Tooltips:
Distraction Free Settings:
Hide Channels: Enter a channel name or channel ID to hide all videos, playlists and the channel itself from appearing in search, trending, most popular and recommended.
The channel name entered must be a complete match and is case sensitive.
Hide Subscriptions Live: 'This setting is overridden by the app-wide "{appWideSetting}" setting, in the "{subsection}" section of the "{settingsSection}"'
Subscription Settings:
Fetch Feeds from RSS: When enabled, FreeTube will use RSS instead of its default
method for grabbing your subscription feed. RSS is faster and prevents IP blocking,

View File

@ -29,6 +29,13 @@ Close: 'Close'
Back: 'Back'
Forward: 'Forward'
# Global
# Anything shared among components / views should be put here
Global:
Videos: Videos
Shorts: Shorts
Live: Live
Version {versionNumber} is now available! Click for more details: 'Version {versionNumber}
is now available! Click for more details'
Download From Site: 'Download From Site'
@ -611,7 +618,6 @@ Channel:
have any live streams
Live: Live
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: This channel does not currently
have any shorts
Video:

View File

@ -27,6 +27,11 @@ Close: 'Cerrar'
Back: 'Volver'
Forward: 'Adelante'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videos'
# Search Bar
Search / Go to URL: 'Buscar / Ir a la URL'
# In Filter Button

View File

@ -30,6 +30,13 @@ Close: 'Cerrar'
Back: 'Atrás'
Forward: 'Adelante'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Vídeos'
Shorts: Cortos
Live: En directo
# Search Bar
Search / Go to URL: 'Buscar / Ir a la dirección'
# In Filter Button
@ -634,7 +641,6 @@ Channel:
This channel does not currently have any live streams: Este canal no tiene actualmente
ninguna retransmisión en directo
Shorts:
Shorts: Cortos
This channel does not currently have any shorts: Este canal no tiene actualmente
ningún vídeo corto
Podcasts:

View File

@ -29,6 +29,13 @@ Close: 'Sulge'
Back: 'Tagasi'
Forward: 'Edasi'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videod'
Shorts: Lühivideod
Live: Otseeeter
Version {versionNumber} is now available! Click for more details: 'Versioon {versionNumber}
in nüüd saadaval! Lisateavet leiad siit'
Download From Site: 'Laadi veebisaidist alla'
@ -565,7 +572,6 @@ Channel:
This channel does not allow searching: See kanal ei luba otsingu kasutamist
Channel Tabs: Kanali kaardid
Shorts:
Shorts: Lühivideod
This channel does not currently have any shorts: Sellel kanalil pole lühivideosid
Live:
Live: Otseeeter

View File

@ -29,6 +29,11 @@ Close: 'Itxi'
Back: 'Atzera'
Forward: 'Aurrera'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Bideoak'
Version {versionNumber} is now available! Click for more details: '{versionNumber}
bertsioa erabilgarri! Klikatu azalpen gehiagorako'
Download From Site: 'Webgunetik jaitsi'

View File

@ -27,6 +27,13 @@ Close: 'Sulje'
Back: 'Takaisin'
Forward: 'Eteenpäin'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videot'
Shorts: Lyhyet
Live: Livenä
# Search Bar
Search / Go to URL: 'Etsi / Mene osoitteeseen'
# In Filter Button
@ -580,7 +587,6 @@ Channel:
This channel does not currently have any live streams: Tällä kanavalla ei ole
tällä hetkellä yhtään suoraa lähetystä
Shorts:
Shorts: Lyhyet
This channel does not currently have any shorts: Tällä kanavalla ei juuri nyt
ole lyhyitä
Video:

View File

@ -28,6 +28,13 @@ Close: 'Fermer'
Back: 'Retour'
Forward: 'Avancer'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Vidéos'
Shorts: Shorts
Live: En direct
# Search Bar
Search / Go to URL: 'Rechercher / ouvrir l''URL'
# In Filter Button
@ -623,7 +630,6 @@ Channel:
This channel does not currently have any live streams: Cette chaîne n'a actuellement
aucun flux en direct
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: Cette chaîne n'a actuellement
aucun shorts
Video:

View File

@ -30,6 +30,11 @@ Close: 'Pechar'
Back: 'Atrás'
Forward: 'Adiante'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Vídeos'
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'

View File

@ -29,6 +29,13 @@ Close: 'סגירה'
Back: 'אחורה'
Forward: 'קדימה'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'סרטונים'
Shorts: Shorts
Live: חי
Version {versionNumber} is now available! Click for more details: 'גרסה {versionNumber}
זמינה מעתה! לחיצה תציג פרטים נוספים'
Download From Site: 'הורדה מהאתר'
@ -622,7 +629,6 @@ Channel:
This channel does not currently have any live streams: לערוץ הזה אין שידורים חיים
כרגע
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: אין כרגע Shorts בערוץ הזה
Podcasts:
Podcasts: הסכתים

View File

@ -29,6 +29,13 @@ Close: 'Zatvori'
Back: 'Natrag'
Forward: 'Naprijed'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videa'
Shorts: Kratka videa
Live: Uživo
# Search Bar
Search / Go to URL: 'Pretraži / Idi na URL'
# In Filter Button
@ -638,7 +645,6 @@ Channel:
Shorts:
This channel does not currently have any shorts: Ovaj kanal trenutačno nema kratka
videa
Shorts: Kratka videa
Releases:
Releases: Izdanja
This channel does not currently have any releases: Ovaj kanal trenutačno nema

View File

@ -30,6 +30,13 @@ Close: 'Bezárás'
Back: 'Vissza'
Forward: 'Előre'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videók'
Shorts: Rövidfilmek
Live: Élő
Version {versionNumber} is now available! Click for more details: 'A(z) {versionNumber}
verzió már elérhető! Kattintson a további részletekért'
Download From Site: 'Letöltés a webhelyről'
@ -640,7 +647,6 @@ Channel:
bejegyzések
Community: Közösség
Shorts:
Shorts: Rövidfilmek
This channel does not currently have any shorts: Ezen a csatornán jelenleg nincsenek
rövidfilmek
This channel does not exist: Nem létezik ez a csatorna

View File

@ -30,6 +30,11 @@ Close: 'Tutup'
Back: 'Kembali'
Forward: 'Maju'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Video'
Version {versionNumber} is now available! Click for more details: 'Versi {versionNumber}
sekarang tersedia! Klik untuk detail lebih lanjut'
Download From Site: 'Unduh dari Situs'

View File

@ -30,6 +30,13 @@ Close: 'Loka'
Back: 'Til baka'
Forward: 'Áfram'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Myndskeið'
Shorts: Stuttmyndir
Live: Í beinni
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'
@ -577,7 +584,6 @@ Channel:
Community: Samfélag
This channel currently does not have any posts: Þessi rás er ekki með neinar færslur
Shorts:
Shorts: Stuttmyndir
This channel does not currently have any shorts: Þessi rás er í augnablikinu ekki
með neinar stuttmyndir
Video:

View File

@ -30,6 +30,13 @@ Close: 'Chiudi'
Back: 'Indietro'
Forward: 'Avanti'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Video'
Shorts: Video brevi
Live: Dal vivo
# Search Bar
Search / Go to URL: 'Cerca o aggiungi URL YouTube'
# In Filter Button
@ -602,7 +609,6 @@ Channel:
This channel does not currently have any live streams: Questo canale attualmente
non ha alcun video dal vivo
Shorts:
Shorts: Video brevi
This channel does not currently have any shorts: Questo canale attualmente non
ha video brevi
Podcasts:

View File

@ -27,6 +27,13 @@ Close: '閉じる'
Back: '戻る'
Forward: '進む'
# Global
# Anything shared among components / views should be put here
Global:
Videos: '動画'
Shorts: ショート動画
Live: ライブ配信
# Search Bar
Search / Go to URL: '検索 / URL の表示'
# In Filter Button
@ -91,8 +98,7 @@ Playlists: '再生リスト'
User Playlists:
Your Playlists: 'あなたの再生リスト'
Your saved videos are empty. Click on the save button on the corner of a video to have it listed here: 保存した動画はありません。一覧に表示させるには、ビデオの角にある保存ボタンをクリックします
Playlist Message:
このページは、完全に動作する動画リストではありません。保存またはお気に入りと設定した動画のみが表示されます。操作が完了すると、現在ここにあるすべての動画は「お気に入り」の動画リストに移動します。
Playlist Message: このページは、完全に動作する動画リストではありません。保存またはお気に入りと設定した動画のみが表示されます。操作が完了すると、現在ここにあるすべての動画は「お気に入り」の動画リストに移動します。
Search bar placeholder: 動画リスト内の検索
Empty Search Message: この再生リストに、検索に一致する動画はありません
History:
@ -539,7 +545,6 @@ Channel:
This channel does not currently have any live streams: このチャンネルは現在、ライブ配信を行っていません
Live: ライブ配信
Shorts:
Shorts: ショート動画
This channel does not currently have any shorts: このチャンネルには現在ショート動画がありません
Video:
Open in YouTube: 'YouTube で表示'
@ -849,7 +854,7 @@ Tooltips:
Replace HTTP Cache: Electron のディスクに基づく HTTP キャッシュを無効化し、メモリ内で独自の画像キャッシュを使用します。このことにより
RAM の使用率は増加します。
Distraction Free Settings:
Hide Channels: チャンネル名またはチャンネル ID
Hide Channels: チャンネル名またはチャンネル ID
を入力すると、すべてのビデオ、再生リスト、およびチャンネル自体が検索や人気に表示されなくなります。入力するチャンネル名は完全に一致することが必要で、大文字と小文字を区別します。
SponsorBlock Settings:
UseDeArrowTitles: 動画のタイトルを DeArrow からユーザーが投稿したタイトルに置き換えます。

View File

@ -29,6 +29,11 @@ Close: '닫기'
Back: '뒤로가기'
Forward: '앞으로가기'
# Global
# Anything shared among components / views should be put here
Global:
Videos: '비디오'
Version {versionNumber} is now available! Click for more details: '{versionNumber}
버전이 사용가능합니다! 클릭하여 자세한 정보를 확인하세요'
Download From Site: '사이트로부터 다운로드'

View File

@ -30,6 +30,11 @@ Back: 'Atgal'
Forward: 'Pirmyn'
Open New Window: 'Atidaryti naują langą'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Vaizdo įrašai'
Version {versionNumber} is now available! Click for more details: 'Versija {versionNumber}
jau prieinama! Spustelėkite, jei norite gauti daugiau informacijos'
Download From Site: 'Atsisiųsti iš svetainės'

View File

@ -29,6 +29,13 @@ Close: 'Lukk'
Back: 'Tilbake'
Forward: 'Framover'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videoer'
Shorts: Kortvideoer
Live: Direkte
# Search Bar
Search / Go to URL: 'Søk/gå til nettadresse'
# In Filter Button
@ -545,7 +552,6 @@ Channel:
This channel currently does not have any posts: Denne kanalen har ingen oppføringer
Shorts:
This channel does not currently have any shorts: Denne kanalen har ingen kortvideoer
Shorts: Kortvideoer
Live:
Live: Direkte
This channel does not currently have any live streams: Denne kanalen har ikke

View File

@ -30,6 +30,11 @@ Close: 'Sluiten'
Back: 'Ga terug'
Forward: 'Ga vooruit'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Video''s'
# Search Bar
Search / Go to URL: 'Zoeken / Ga naar URL'
# In Filter Button

View File

@ -30,6 +30,11 @@ Close: 'Lukk'
Back: 'Tilbake'
Forward: 'Framover'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videoar'
Version {versionNumber} is now available! Click for more details: 'Versjon {versionNumber}
er no tilgjengeleg! Klikk for meir informasjon'
Download From Site: 'Last ned frå nettstaden'

View File

@ -28,6 +28,13 @@ Close: 'Zamknij'
Back: 'Wstecz'
Forward: 'Naprzód'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Filmy'
Shorts: Filmy Short
Live: Transmisje
# Search Bar
Search / Go to URL: 'Szukaj / Przejdź do adresu URL'
# In Filter Button
@ -606,7 +613,6 @@ Channel:
This channel does not currently have any live streams: Ten kanał nie ma obecnie
żadnych transmisji
Shorts:
Shorts: Filmy Short
This channel does not currently have any shorts: Ten kanał nie ma obecnie żadnych
filmów Short
Releases:

View File

@ -28,6 +28,13 @@ Close: 'Fechar'
Back: 'Voltar'
Forward: 'Avançar'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Vídeos'
Shorts: Shorts
Live: Ao vivo
# Search Bar
Search / Go to URL: 'Buscar/Ir ao URL'
# In Filter Button
@ -593,7 +600,6 @@ Channel:
This channel does not currently have any live streams: Este canal não tem nenhuma
transmissão ao vivo no momento
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: Este canal não tem atualmente
nenhum short
Video:

View File

@ -30,6 +30,13 @@ Close: Fechar
Back: Recuar
Forward: Avançar
# Global
# Anything shared among components / views should be put here
Global:
Videos: Vídeos
Shorts: Curtas
Live: Em directo
Version {versionNumber} is now available! Click for more details: A versão {versionNumber}
já está disponível! Clique para mais detalhes
Download From Site: Descarregar do site
@ -587,7 +594,6 @@ Channel:
This channel does not currently have any live streams: Este canal não tem atualmente
nenhuma transmissão ao vivo
Shorts:
Shorts: Curtas
This channel does not currently have any shorts: Este canal não tem atualmente
nenhum canal curto
Releases:

View File

@ -29,6 +29,13 @@ Close: 'Fechar'
Back: 'Recuar'
Forward: 'Avançar'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Vídeos'
Shorts: Curtas
Live: Em directo
Version {versionNumber} is now available! Click for more details: 'A versão {versionNumber}
está disponível! Clique aqui para mais informações'
Download From Site: 'Descarregar do site'
@ -634,7 +641,6 @@ Channel:
This channel does not currently have any live streams: Este canal não tem atualmente
nenhuma transmissão ao vivo
Shorts:
Shorts: Curtas
This channel does not currently have any shorts: Este canal não tem atualmente
nenhum canal curto
Releases:

View File

@ -29,6 +29,11 @@ Close: 'Închideți'
Back: 'Înapoi'
Forward: 'Înainte'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videoclipuri'
Version {versionNumber} is now available! Click for more details: 'Versiunea {versionNumber}
este acum disponibilă! Click pentru mai multe detalii'
Download From Site: 'Descărcați de pe site'

View File

@ -27,6 +27,13 @@ Close: 'Закрыть'
Back: 'Назад'
Forward: 'Вперёд'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Видео'
Shorts: Короткие видео
Live: Трансляции
# Search Bar
Search / Go to URL: 'Поиск / Перейти по адресу'
# In Filter Button
@ -589,7 +596,6 @@ Channel:
This channel does not currently have any live streams: На этом канале в настоящее
время нет прямых трансляций
Shorts:
Shorts: Короткие видео
This channel does not currently have any shorts: На этом канале пока что нет коротких
видео
Video:

View File

@ -28,6 +28,11 @@ Close: 'Zavrieť'
Back: 'Späť'
Forward: 'Vpred'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videá'
# Search Bar
Search / Go to URL: 'Hľadať / Ísť na adresu URL'
# In Filter Button

View File

@ -30,6 +30,11 @@ Close: 'Zapri'
Back: 'Nazaj'
Forward: 'Naprej'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videoposnetki'
Version {versionNumber} is now available! Click for more details: 'Na voljo je različica
{versionNumber}!· Za več podrobnosti kliknite tukaj'
Download From Site: 'Prenesi iz spletne strani'

View File

@ -29,6 +29,11 @@ Close: 'Затвори'
Back: 'Назад'
Forward: 'Напред'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Видео'
Version {versionNumber} is now available! Click for more details: 'Верзија {versionNumber}
је сада достуна! Кликните за више детаља'
Download From Site: 'Преузми са сајта'

View File

@ -29,6 +29,13 @@ Close: 'Stäng'
Back: 'Tillbaka'
Forward: 'Framåt'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videor'
Shorts: Shorts
Live: Live
Version {versionNumber} is now available! Click for more details: 'Versionen {versionNumber}
är nu tillgänglig! Klicka för mer detaljer'
Download From Site: 'Ladda ner från sajten'
@ -618,7 +625,6 @@ Channel:
This channel currently does not have any posts: Denna kanal har för närvarande
inga inlägg
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: Den här kanalen har för närvarande
inga shorts
Live:

View File

@ -29,6 +29,13 @@ Close: 'Kapat'
Back: 'Geri'
Forward: 'İleri'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Videolar'
Shorts: Kısa Videolar
Live: Canlı
Version {versionNumber} is now available! Click for more details: '{versionNumber}
sürümü çıktı! Daha fazla ayrıntı için tıklayın'
Download From Site: 'Siteden indir'
@ -642,7 +649,6 @@ Channel:
This channel does not currently have any live streams: Bu kanalda şu anda herhangi
bir canlı yayın yok
Shorts:
Shorts: Kısa Videolar
This channel does not currently have any shorts: Bu kanalda şu anda hiç kısa video
yok
Podcasts:

View File

@ -29,6 +29,13 @@ Close: 'Закрити'
Back: 'Назад'
Forward: 'Вперед'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Відео'
Shorts: Shorts
Live: Наживо
Version {versionNumber} is now available! Click for more details: 'Доступна нова
версія {versionNumber} ! Натисніть щоб побачити деталі'
Download From Site: 'Завантажити з сайту'
@ -571,7 +578,6 @@ Channel:
немає прямих трансляцій
Live: Наживо
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: На цьому каналі немає Shorts
Video:
Mark As Watched: 'Позначити переглянутим'

View File

@ -28,6 +28,11 @@ Close: 'Đóng'
Back: 'Quay lại'
Forward: 'Tiến tới'
# Global
# Anything shared among components / views should be put here
Global:
Videos: 'Video'
# Search Bar
Search / Go to URL: 'Tìm kiếm / Đi đến URL'
# In Filter Button

View File

@ -27,6 +27,13 @@ Close: '关闭'
Back: '后退'
Forward: '前进'
# Global
# Anything shared among components / views should be put here
Global:
Videos: '视频'
Shorts: 短视频
Live: 直播
# Search Bar
Search / Go to URL: '搜索 / 前往URL'
# In Filter Button
@ -536,7 +543,6 @@ Channel:
This channel does not currently have any live streams: 此频道当前没有任何直播流
Shorts:
This channel does not currently have any shorts: 此频道目前没有任何短视频
Shorts: 短视频
Podcasts:
This channel does not currently have any podcasts: 此频道当前无任何播客节目
Podcasts: 播客

View File

@ -27,6 +27,13 @@ Close: '關閉'
Back: '返回'
Forward: '前進'
# Global
# Anything shared among components / views should be put here
Global:
Videos: '影片'
Shorts: 短片
Live: 直播
# Search Bar
Search / Go to URL: '搜尋/ 前往網址'
# In Filter Button
@ -546,7 +553,6 @@ Channel:
Live: 直播
This channel does not currently have any live streams: 此頻道目前沒有任何直播
Shorts:
Shorts: 短片
This channel does not currently have any shorts: 此頻道目前沒有任何短片
Podcasts:
This channel does not currently have any podcasts: 此頻道目前沒有 podcast