mirror of https://github.com/FreeTubeApp/FreeTube
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:
parent
8fbc53da7a
commit
b9eb2a76fb
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
|
@ -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" />
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
|
@ -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" />
|
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -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" />
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
})
|
|
@ -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" />
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -211,6 +211,9 @@ const state = {
|
|||
hideRecommendedVideos: false,
|
||||
hideSearchBar: false,
|
||||
hideSharingActions: false,
|
||||
hideSubscriptionsVideos: false,
|
||||
hideSubscriptionsShorts: false,
|
||||
hideSubscriptionsLive: false,
|
||||
hideTrendingVideos: false,
|
||||
hideUnsubscribeButton: false,
|
||||
hideUpcomingPremieres: false,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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'
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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: 'Премахване от историята'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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: הסכתים
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 からユーザーが投稿したタイトルに置き換えます。
|
||||
|
|
|
@ -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: '사이트로부터 다운로드'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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: 'Преузми са сајта'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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: 'Позначити переглянутим'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: 播客
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue