Subscriptions: Add community tab (#3973)

* Add subscription community tab

* fix up community tab

* simplify if statement

* use global.community for all references to community

* dont show community when useRss is set

* check visibleTabs for showing the community tab

* fix caching, decrease datalimit for community, add missing translations

* L: Hide shared posts, IV: Don't show errors for empty community tab

* add links to related issues
This commit is contained in:
ChunkyProgrammer 2023-09-22 20:19:50 -04:00 committed by GitHub
parent 324bdb608a
commit 47ef3e5746
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 465 additions and 54 deletions

View File

@ -92,6 +92,9 @@ export default defineComponent({
hideSubscriptionsLive: function () {
return this.$store.getters.getHideSubscriptionsLive
},
hideSubscriptionsCommunity: function() {
return this.$store.getters.getHideSubscriptionsCommunity
},
showDistractionFreeTitles: function () {
return this.$store.getters.getShowDistractionFreeTitles
},
@ -155,6 +158,7 @@ export default defineComponent({
'updateHideSubscriptionsVideos',
'updateHideSubscriptionsShorts',
'updateHideSubscriptionsLive',
'updateHideSubscriptionsCommunity',
'updateBlurThumbnails'
])
}

View File

@ -66,6 +66,12 @@
:tooltip="hideLiveStreams ? hideSubscriptionsLiveTooltip : ''"
v-on="!hideLiveStreams ? { change: updateHideSubscriptionsLive } : {}"
/>
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Subscriptions Community')"
:compact="true"
:default-value="hideSubscriptionsCommunity"
@change="updateHideSubscriptionsCommunity"
/>
</div>
</div>
<h4

View File

@ -6,7 +6,7 @@ import FtCommunityPoll from '../ft-community-poll/ft-community-poll.vue'
import autolinker from 'autolinker'
import VueTinySlider from 'vue-tiny-slider'
import { toLocalePublicationString } from '../../helpers/utils'
import { deepCopy, toLocalePublicationString } from '../../helpers/utils'
import { youtubeImageUrlToInvidious } from '../../helpers/api/invidious'
import 'tiny-slider/dist/tiny-slider.css'
@ -40,6 +40,7 @@ export default defineComponent({
commentCount: '',
isLoading: true,
author: '',
authorId: '',
}
},
computed: {
@ -77,18 +78,16 @@ export default defineComponent({
return
}
this.postText = autolinker.link(this.data.postText)
let authorThumbnails = this.data.authorThumbnails
const authorThumbnails = deepCopy(this.data.authorThumbnails)
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
authorThumbnails = authorThumbnails.map(thumbnail => {
authorThumbnails.forEach(thumbnail => {
thumbnail.url = youtubeImageUrlToInvidious(thumbnail.url)
return thumbnail
})
} else {
authorThumbnails = authorThumbnails.map(thumbnail => {
authorThumbnails.forEach(thumbnail => {
if (thumbnail.url.startsWith('//')) {
thumbnail.url = 'https:' + thumbnail.url
}
return thumbnail
})
}
this.authorThumbnails = authorThumbnails
@ -104,6 +103,7 @@ export default defineComponent({
this.commentCount = this.data.commentCount
this.type = (this.data.postContent !== null && this.data.postContent !== undefined) ? this.data.postContent.type : 'text'
this.author = this.data.author
this.authorId = this.data.authorId
this.isLoading = false
},

View File

@ -34,6 +34,10 @@
font-weight: bold;
margin-block: 5px 0;
margin-inline: 5px 6px;
.authorNameLink {
color: inherit;
text-decoration: none;
}
}
.publishedText {

View File

@ -8,16 +8,43 @@
<div
class="author-div"
>
<img
<template
v-if="authorThumbnails.length > 0"
:src="getBestQualityImage(authorThumbnails)"
class="communityThumbnail"
alt=""
>
<router-link
v-if="authorId"
:to="`/channel/${authorId}`"
tabindex="-1"
aria-hidden="true"
>
<img
:src="getBestQualityImage(authorThumbnails)"
class="communityThumbnail"
alt=""
>
</router-link>
<img
v-else
:src="getBestQualityImage(authorThumbnails)"
class="communityThumbnail"
alt=""
>
</template>
<p
class="authorName"
>
{{ author }}
<router-link
v-if="authorId"
:to="`/channel/${authorId}`"
class="authorNameLink"
>
{{ author }}
</router-link>
<template
v-else
>
{{ author }}
</template>
</p>
<p
class="publishedText"

View File

@ -0,0 +1,221 @@
import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue'
import { calculatePublishedDate, copyToClipboard, showToast } from '../../helpers/utils'
import { getLocalChannelCommunity } from '../../helpers/api/local'
import { invidiousGetCommunityPosts } from '../../helpers/api/invidious'
export default defineComponent({
name: 'SubscriptionsCommunity',
components: {
'subscriptions-tab-ui': SubscriptionsTabUI
},
data: function () {
return {
isLoading: false,
postList: [],
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.getPostsCacheByChannel(channel.id)
if (cacheEntry == null) { return }
entries.push(cacheEntry)
})
return entries
},
postCacheForAllActiveProfileChannelsPresent() {
if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false }
if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false }
return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => {
return cacheEntry.posts != null
})
},
activeSubscriptionList: function () {
return this.activeProfile.subscriptions
},
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
},
},
watch: {
activeProfile: async function (_) {
this.isLoading = true
this.loadpostsFromCacheSometimes()
},
},
mounted: async function () {
this.isLoading = true
this.loadpostsFromCacheSometimes()
},
methods: {
loadpostsFromCacheSometimes() {
// This method is called on view visible
if (this.postCacheForAllActiveProfileChannelsPresent) {
this.loadPostsFromCacheForAllActiveProfileChannels()
return
}
this.maybeLoadPostsForSubscriptionsFromRemote()
},
async loadPostsFromCacheForAllActiveProfileChannels() {
const postList = []
this.activeSubscriptionList.forEach((channel) => {
const channelCacheEntry = this.$store.getters.getPostsCacheByChannel(channel.id)
postList.push(...channelCacheEntry.posts)
})
postList.sort((a, b) => {
return calculatePublishedDate(b.publishedText) - calculatePublishedDate(a.publishedText)
})
this.postList = postList
this.isLoading = false
},
loadPostsForSubscriptionsFromRemote: async function () {
if (this.activeSubscriptionList.length === 0) {
this.isLoading = false
this.postList = []
return
}
const channelsToLoadFromRemote = this.activeSubscriptionList
const postList = []
let channelCount = 0
this.isLoading = true
this.updateShowProgressBar(true)
this.setProgressBarPercentage(0)
this.attemptedFetch = true
this.errorChannels = []
const postListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let posts = []
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
posts = await this.getChannelPostsInvidious(channel)
} else {
posts = await this.getChannelPostsLocal(channel)
}
channelCount++
const percentageComplete = (channelCount / channelsToLoadFromRemote.length) * 100
this.setProgressBarPercentage(percentageComplete)
this.updateSubscriptionPostsCacheByChannel({
channelId: channel.id,
posts: posts,
})
return posts
}))).flatMap((o) => o)
postList.push(...postListFromRemote)
postList.sort((a, b) => {
return calculatePublishedDate(b.publishedText) - calculatePublishedDate(a.publishedText)
})
this.postList = postList
this.isLoading = false
this.updateShowProgressBar(false)
},
maybeLoadPostsForSubscriptionsFromRemote: async function () {
if (this.fetchSubscriptionsAutomatically) {
// `this.isLoading = false` is called inside `loadPostsForSubscriptionsFromRemote` when needed
await this.loadPostsForSubscriptionsFromRemote()
} else {
this.postList = []
this.attemptedFetch = false
this.isLoading = false
}
},
getChannelPostsLocal: async function (channel) {
try {
const entries = await getLocalChannelCommunity(channel.id)
if (entries === null) {
this.errorChannels.push(channel)
return []
}
entries.forEach(post => {
post.authorId = channel.id
})
return entries
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
if (this.backendPreference === 'local' && this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return await this.getChannelPostsInvidious(channel)
}
return []
}
},
getChannelPostsInvidious: function (channel) {
return new Promise((resolve, reject) => {
invidiousGetCommunityPosts(channel.id).then(result => {
result.posts.forEach(post => {
post.authorId = channel.id
})
resolve(result.posts)
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) {
showToast(this.$t('Falling back to the local API'))
resolve(this.getChannelPostsLocal(channel))
} else {
resolve([])
}
})
})
},
...mapActions([
'updateShowProgressBar',
'updateSubscriptionPostsCacheByChannel',
]),
...mapMutations([
'setProgressBarPercentage'
])
}
})

View File

@ -0,0 +1,13 @@
<template>
<subscriptions-tab-ui
:is-loading="isLoading"
:video-list="postList"
:error-channels="errorChannels"
:attempted-fetch="attemptedFetch"
:is-community="true"
:initial-data-limit="20"
@refresh="loadPostsForSubscriptionsFromRemote"
/>
</template>
<script src="./subscriptions-community.js" />

View File

@ -28,6 +28,10 @@ export default defineComponent({
type: Array,
default: () => ([])
},
isCommunity: {
type: Boolean,
default: false
},
errorChannels: {
type: Array,
default: () => ([])
@ -36,6 +40,10 @@ export default defineComponent({
type: Boolean,
default: false
},
initialDataLimit: {
type: Number,
default: 100
}
},
data: function () {
return {
@ -68,6 +76,8 @@ export default defineComponent({
if (dataLimit !== null) {
this.dataLimit = dataLimit
} else {
this.dataLimit = this.initialDataLimit
}
},
mounted: async function () {
@ -78,7 +88,7 @@ export default defineComponent({
},
methods: {
increaseLimit: function () {
this.dataLimit += 100
this.dataLimit += this.initialDataLimit
sessionStorage.setItem('subscriptionLimit', this.dataLimit)
},

View File

@ -36,19 +36,20 @@
v-else
class="message"
>
{{ $t("Subscriptions.Empty Channels") }}
{{ isCommunity ? $t("Subscriptions.Empty Posts") : $t("Subscriptions.Empty Channels") }}
</p>
</ft-flex-box>
<ft-element-list
v-if="!isLoading && activeVideoList.length > 0"
:data="activeVideoList"
:use-channels-hidden-preference="false"
:display="isCommunity ? 'list' : ''"
/>
<ft-flex-box
v-if="!isLoading && videoList.length > dataLimit"
>
<ft-button
:label="$t('Subscriptions.Load More Videos')"
:label="isCommunity ? $t('Subscriptions.Load More Posts') : $t('Subscriptions.Load More Videos')"
background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@click="increaseLimit"

View File

@ -24,7 +24,13 @@ export function invidiousAPICall({ resource, id = '', params = {}, doLogError =
.then((response) => response.json())
.then((json) => {
if (json.error !== undefined) {
throw new Error(json.error)
// community is empty, no need to display error.
// This code can be removed when: https://github.com/iv-org/invidious/issues/3814 is reolved
if (json.error === 'This channel hasn\'t posted yet') {
resolve({ comments: [] })
} else {
throw new Error(json.error)
}
}
resolve(json)
})

View File

@ -286,6 +286,36 @@ export async function getLocalChannelLiveStreams(id) {
}
}
export async function getLocalChannelCommunity(id) {
const innertube = await createInnertube()
try {
const response = await innertube.actions.execute(Endpoints.BrowseEndpoint.PATH, Endpoints.BrowseEndpoint.build({
browse_id: id,
params: 'Egljb21tdW5pdHnyBgQKAkoA'
// protobuf for the community 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 communityTab = new YT.Channel(null, response)
// if the channel doesn't have a community tab, YouTube returns the home tab instead
// so we need to check that we got the right tab
if (communityTab.current_tab?.endpoint.metadata.url?.endsWith('/community')) {
return parseLocalCommunityPosts(communityTab.posts)
} 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
@ -879,9 +909,29 @@ export function parseLocalSubscriberCount(text) {
/**
* Parse community posts
* @param {import('youtubei.js').YTNodes.BackstagePost[] | import('youtubei.js').YTNodes.SharedPost[] | import('youtubei.js').YTNodes.Post[] } posts
*/
export function parseLocalCommunityPosts(posts) {
const foundIds = []
// `posts` includes the SharedPost's attached post for some reason so we need to filter that out.
// see: https://github.com/FreeTubeApp/FreeTube/issues/3252#issuecomment-1546675781
// we don't currently support SharedPost's so that is also filtered out
for (const post of posts) {
if (post.type === 'SharedPost') {
foundIds.push(post.original_post.id, post.id)
}
}
return posts.filter(post => {
return !foundIds.includes(post.id)
}).map(parseLocalCommunityPost)
}
/**
* Parse community post
* @param {import('youtubei.js').YTNodes.BackstagePost} post
*/
export function parseLocalCommunityPost(post) {
function parseLocalCommunityPost(post) {
let replyCount = post.action_buttons?.reply_button?.text ?? null
if (replyCount !== null) {
replyCount = parseLocalSubscriberCount(post?.action_buttons.reply_button.text)

View File

@ -216,6 +216,7 @@ const state = {
hideSubscriptionsVideos: false,
hideSubscriptionsShorts: false,
hideSubscriptionsLive: false,
hideSubscriptionsCommunity: false,
hideTrendingVideos: false,
hideUnsubscribeButton: false,
hideUpcomingPremieres: false,

View File

@ -7,7 +7,8 @@ const defaultCacheEntryValueForForOneChannel = {
const state = {
videoCache: {},
liveCache: {},
shortsCache: {}
shortsCache: {},
postsCache: {}
}
const getters = {
@ -34,6 +35,14 @@ const getters = {
getLiveCacheByChannel: (state) => (channelId) => {
return state.liveCache[channelId]
},
getPostsCache: (state) => {
return state.postsCache
},
getPostsCacheByChannel: (state) => (channelId) => {
return state.postsCache[channelId]
},
}
const actions = {
@ -49,10 +58,15 @@ const actions = {
commit('updateLiveCacheByChannel', payload)
},
updateSubscriptionPostsCacheByChannel: ({ commit }, payload) => {
commit('updatePostsCacheByChannel', payload)
},
clearSubscriptionsCache: ({ commit }, payload) => {
commit('clearVideoCache', payload)
commit('clearShortsCache', payload)
commit('clearLiveCache', payload)
commit('clearPostsCache', payload)
},
}
@ -84,6 +98,15 @@ const mutations = {
clearLiveCache(state) {
state.liveCache = {}
},
updatePostsCacheByChannel(state, { channelId, posts }) {
const existingObject = state.postsCache[channelId]
const newObject = existingObject != null ? existingObject : deepCopy(defaultCacheEntryValueForForOneChannel)
if (posts != null) { newObject.posts = posts }
state.postsCache[channelId] = newObject
},
clearPostsCache(state) {
state.postsCache = {}
},
}
export default {

View File

@ -27,7 +27,7 @@ import {
getLocalChannelId,
parseLocalChannelShorts,
parseLocalChannelVideos,
parseLocalCommunityPost,
parseLocalCommunityPosts,
parseLocalListPlaylist,
parseLocalListVideo,
parseLocalSubscriberCount
@ -1576,7 +1576,7 @@ export default defineComponent({
posts = communityTab.posts
}
this.latestCommunityPosts = posts.map(parseLocalCommunityPost)
this.latestCommunityPosts = parseLocalCommunityPosts(posts)
this.communityContinuationData = communityTab.has_continuation ? communityTab : null
} catch (err) {
console.error(err)
@ -1608,7 +1608,7 @@ export default defineComponent({
posts = continuation.posts
}
this.latestCommunityPosts = this.latestCommunityPosts.concat(posts.map(parseLocalCommunityPost))
this.latestCommunityPosts = this.latestCommunityPosts.concat(parseLocalCommunityPosts(posts))
this.communityContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)

View File

@ -186,7 +186,7 @@
@click="changeTab('community')"
@keydown.left.right.enter.space="changeTab('community', $event)"
>
{{ $t("Channel.Community.Community").toUpperCase() }}
{{ $t("Global.Community").toUpperCase() }}
</div>
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
<div

View File

@ -3,6 +3,7 @@ import { defineComponent } from 'vue'
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 SubscriptionsCommunity from '../../components/subscriptions-community/subscriptions-community.vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
@ -14,6 +15,7 @@ export default defineComponent({
'subscriptions-videos': SubscriptionsVideos,
'subscriptions-live': SubscriptionsLive,
'subscriptions-shorts': SubscriptionsShorts,
'subscriptions-community': SubscriptionsCommunity,
'ft-card': FtCard,
'ft-flex-box': FtFlexBox
},
@ -35,6 +37,19 @@ export default defineComponent({
hideSubscriptionsLive: function () {
return this.$store.getters.getHideLiveStreams || this.$store.getters.getHideSubscriptionsLive
},
hideSubscriptionsCommunity: function() {
return this.$store.getters.getHideSubscriptionsCommunity
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
activeSubscriptionList: function () {
return this.activeProfile.subscriptions
},
useRssFeeds: function () {
return this.$store.getters.getUseRssFeeds
},
visibleTabs: function () {
const tabs = []
@ -50,6 +65,11 @@ export default defineComponent({
tabs.push('live')
}
// community does not support rss
if (!this.hideSubscriptionsCommunity && !this.useRssFeeds && this.activeSubscriptionList.length < 125) {
tabs.push('community')
}
return tabs
}
},

View File

@ -55,6 +55,22 @@
>
{{ $t("Global.Live").toUpperCase() }}
</div>
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
<div
v-if="visibleTabs.includes('community')"
ref="community"
class="tab"
role="tab"
:aria-selected="String(currentTab === 'community')"
aria-controls="subscriptionsPanel"
:tabindex="currentTab === 'community' ? 0 : -1"
:class="{ selectedTab: currentTab === 'community' }"
@click="changeTab('community')"
@keydown.space.enter.prevent="changeTab('community')"
@keydown.left.right="focusTab($event, 'community')"
>
{{ $t("Global.Community").toUpperCase() }}
</div>
</ft-flex-box>
<subscriptions-videos
v-if="currentTab === 'videos'"
@ -71,6 +87,11 @@
id="subscriptionsPanel"
role="tabpanel"
/>
<subscriptions-community
v-else-if="currentTab === 'community'"
id="subscriptionsPanel"
role="tabpanel"
/>
<p v-else-if="currentTab === null">
{{ $t("Subscriptions.All Subscription Tabs Hidden", {
subsection: $t('Settings.Distraction Free Settings.Sections.Subscriptions Page'),

View File

@ -35,6 +35,7 @@ Global:
Videos: 'الفيديوهات'
Shorts: القصيرة
Live: مباشر
Community: المجتمع
# Search Bar
Counts:
@ -640,7 +641,6 @@ Channel:
This channel is age-restricted and currently cannot be viewed in FreeTube.: هذه
القناة مصنفة حسب العمر ولا يمكن عرضها حاليا في FreeTube.
Community:
Community: المجتمع
This channel currently does not have any posts: لا تحتوي هذه القناة حاليا على
أي مشاركات
Hide Answers: إخفاء الأجوبة

View File

@ -36,6 +36,7 @@ Global:
Videos: 'видеа'
Shorts: Кратки видеа
Live: На живо
Community: Общност
Counts:
Video Count: 1 видео | {count} видеа
@ -652,7 +653,6 @@ Channel:
Joined: Присъединен на
Location: Местоположение
Community:
Community: Общност
This channel currently does not have any posts: В момента този канал няма никакви
публикации
votes: '{votes} гласа'

View File

@ -35,6 +35,7 @@ Global:
Videos: 'Videa'
Shorts: Shorts
Live: Živě
Community: Komunita
Counts:
Video Count: 1 video | {count} videí
@ -642,7 +643,6 @@ Channel:
This channel does not allow searching: Tento kanál neumožňuje vyhledávání
Channel Tabs: Karty kanálů
Community:
Community: Komunita
This channel currently does not have any posts: Tento kanál v současné době nemá
žádné příspěvky
Hide Answers: Skrýt odpovědi

View File

@ -34,6 +34,7 @@ Global:
Videos: Videos
Shorts: Kurzvideos
Live: Live
Community: Gemeinschaft
# Search Bar
Counts:
@ -631,7 +632,6 @@ Channel:
Community:
This channel currently does not have any posts: Dieser Kanal enthält derzeit keine
Beiträge
Community: Gemeinschaft
votes: '{votes} Stimmen'
Reveal Answers: Antworten aufzeigen
Hide Answers: Antworten verbergen

View File

@ -36,6 +36,7 @@ Global:
Videos: 'Βίντεο'
Shorts: Shorts
Live: Ζωντανά
Community: Κοινότητα
# Search Bar
Counts:
@ -671,7 +672,6 @@ Channel:
Channel Tabs: Καρτέλες Καναλιών
This channel does not exist: Αυτό το κανάλι δεν υπάρχει
Community:
Community: Κοινότητα
This channel currently does not have any posts: Αυτό το κανάλι προς το παρόν δεν
έχει αναρτήσεις
Reveal Answers: Εμφάνιση Απαντήσεων

View File

@ -45,6 +45,7 @@ Global:
Videos: Videos
Shorts: Shorts
Live: Live
Community: Community
Counts:
Video Count: 1 video | {count} videos
Channel Count: 1 channel | {count} channels
@ -104,8 +105,10 @@ Subscriptions:
Disabled Automatic Fetching: You have disabled automatic subscription fetching. Refresh subscriptions to see them here.
Empty Channels: Your subscribed channels currently does not have any videos.
'Getting Subscriptions. Please wait.': Getting Subscriptions. Please wait.
Empty Posts: Your subscribed channels currently do not have any posts.
Refresh Subscriptions: Refresh Subscriptions
Load More Videos: Load More Videos
Load More Posts: Load More Posts
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
@ -371,6 +374,7 @@ Settings:
Hide Subscriptions Videos: Hide Subscriptions Videos
Hide Subscriptions Shorts: Hide Subscriptions Shorts
Hide Subscriptions Live: Hide Subscriptions Live
Hide Subscriptions Community: Hide Subscriptions Community
Data Settings:
Data Settings: Data Settings
Select Import Type: Select Import Type
@ -597,7 +601,6 @@ Channel:
Location: Location
Featured Channels: Featured Channels
Community:
Community: Community
This channel currently does not have any posts: This channel currently does not have any posts
votes: '{votes} votes'
Reveal Answers: Reveal Answers

View File

@ -35,6 +35,7 @@ Global:
Videos: Videos
Shorts: Shorts
Live: Live
Community: Community
Version {versionNumber} is now available! Click for more details: 'Version {versionNumber}
is now available! Click for more details'
@ -620,7 +621,6 @@ Channel:
Community:
This channel currently does not have any posts: This channel currently does not
have any posts
Community: Community
Live:
This channel does not currently have any live streams: This channel does not currently
have any live streams

View File

@ -21,6 +21,9 @@ Close: 'Fermi'
Back: 'Reen'
Forward: 'Antaŭen'
Globals:
Community: Komunumo
Version {versionNumber} is now available! Click for more details: 'Versio {versionNumber}
disponeblas nun! Alklaki por pli informoj.'
Download From Site: 'Elŝuti el retejo'
@ -105,8 +108,6 @@ About:
Channel:
About:
Details: Detaloj
Community:
Community: Komunumo
Video: {}
More: Pli
Search Bar:

View File

@ -36,6 +36,7 @@ Global:
Videos: 'Vídeos'
Shorts: Cortos
Live: En directo
Community: Comunidad
# Search Bar
Counts:
@ -654,7 +655,6 @@ Channel:
Community:
This channel currently does not have any posts: Este canal no tiene actualmente
ningún mensaje
Community: Comunidad
Reveal Answers: Revelar las respuestas
Hide Answers: Ocultar las respuestas
votes: '{votes} votos'

View File

@ -35,6 +35,7 @@ Global:
Videos: 'Videod'
Shorts: Lühivideod
Live: Otseeeter
Community: Kogukond
Counts:
Video Count: 1 video | {count} videot
@ -596,7 +597,6 @@ Channel:
This channel does not currently have any live streams: Sellel kanalil pole hetkel
ühtegi otseeetrit
Community:
Community: Kogukond
This channel currently does not have any posts: Sellel kanalil pole hetkel postitusi
Reveal Answers: Näita vastuseid
Hide Answers: Peida vastused

View File

@ -33,14 +33,14 @@ Global:
Videos: 'Videot'
Shorts: Lyhyet
Live: Livenä
# Search Bar
Community: Yhteisö
Counts:
Video Count: 1 video | {count} videota
Subscriber Count: 1 tilaaja | {count} tilaajaa
View Count: 1 näyttökerta | {count} näyttökertaa
Watching Count: 1 katselee | {count} katselee
Channel Count: 1 kanava | {count} kanavaa
# Search Bar
Search / Go to URL: 'Etsi / Mene osoitteeseen'
# In Filter Button
Search Filters:
@ -598,7 +598,6 @@ Channel:
Community:
This channel currently does not have any posts: Tällä kanavalla ei ole tällä hetkellä
mitään
Community: Yhteisö
Reveal Answers: Näytä vastaukset
Hide Answers: Piilota vastaukset
votes: '{votes} ääntä'

View File

@ -34,6 +34,7 @@ Global:
Videos: 'Vidéos'
Shorts: Shorts
Live: En direct
Community: Communauté
# Search Bar
Counts:
@ -642,7 +643,6 @@ Channel:
chaîne est limitée par l'âge et ne peut actuellement pas être visionnée dans FreeTube.
Channel Tabs: Onglets des chaînes
Community:
Community: Communauté
This channel currently does not have any posts: Cette chaîne n'a actuellement
aucune publication
votes: '{votes} votes'

View File

@ -34,6 +34,7 @@ Forward: 'Adiante'
# Anything shared among components / views should be put here
Global:
Videos: 'Vídeos'
Community: Comunidade
Version {versionNumber} is now available! Click for more details: 'A versión {versionNumber}
está dispoñible! Fai clic para veres máis detalles'
@ -619,7 +620,6 @@ Channel:
canle ten unha limitación de idade e actualmente non se pode ver en FreeTube.
Channel Tabs: Pestanas das canles
Community:
Community: Comunidade
This channel currently does not have any posts: Esta canle actualmente non ten
publicacións
Video:

View File

@ -35,6 +35,7 @@ Global:
Videos: 'סרטונים'
Shorts: Shorts
Live: חי
Community: קהילה
Counts:
Video Count: סרטון אחד | {count} סרטונים
@ -640,7 +641,6 @@ Channel:
מוגבל לפי גיל וכרגע אי אפשר לצפות בו ב־FreeTube.
Community:
This channel currently does not have any posts: אין רשומות בערוץ הזה כרגע
Community: קהילה
votes: '{votes} הצבעות'
Reveal Answers: חשיפת התשובות
Hide Answers: הסתרת התשובות

View File

@ -35,6 +35,7 @@ Global:
Videos: 'Videa'
Shorts: Kratka videa
Live: Uživo
Community: Zajednica
# Search Bar
Counts:
@ -659,7 +660,6 @@ Channel:
prijenosa uživo
Community:
This channel currently does not have any posts: Ovaj kanal trenutačno nema objava
Community: Zajednica
Reveal Answers: Prikaži odgovore
Hide Answers: Sakrij odgovore
votes: '{votes} glasanja'

View File

@ -36,6 +36,7 @@ Global:
Videos: 'Videók'
Shorts: Rövidfilmek
Live: Élő
Community: Közösség
Counts:
Video Count: 1 videó | {count} videó
@ -665,7 +666,6 @@ Channel:
Community:
This channel currently does not have any posts: Ezen a csatornán jelenleg nincsenek
bejegyzések
Community: Közösség
Reveal Answers: Válaszok feltárása
Hide Answers: Válaszok elrejtése
votes: '{votes} szavazat'

View File

@ -36,6 +36,7 @@ Global:
Videos: 'Myndskeið'
Shorts: Stuttmyndir
Live: Í beinni
Community: Samfélag
Counts:
Video Count: 1 myndskeið | {count} myndskeið
@ -599,7 +600,6 @@ Channel:
This channel does not currently have any live streams: Þessi rás er í augnablikinu
ekki með nein bein streymi
Community:
Community: Samfélag
This channel currently does not have any posts: Þessi rás er ekki með neinar færslur
Reveal Answers: Birta svör
Hide Answers: Fela svör

View File

@ -36,6 +36,7 @@ Global:
Videos: 'Video'
Shorts: Video brevi
Live: Dal vivo
Community: Comunità
# Search Bar
Counts:
@ -619,7 +620,6 @@ Channel:
Community:
This channel currently does not have any posts: Questo canale attualmente non
ha alcun post
Community: Comunità
votes: '{votes} voti'
Reveal Answers: Rivela le risposte
Hide Answers: Nascondi le risposte

View File

@ -33,6 +33,7 @@ Global:
Videos: '動画'
Shorts: ショート動画
Live: ライブ配信
Community: コミュニティ
# Search Bar
Counts:
@ -555,7 +556,6 @@ Channel:
Removed subscription from {count} other channel(s): ほかの {count} チャンネルから登録を削除しました
Community:
This channel currently does not have any posts: このチャンネルには現在投稿がありません
Community: コミュニティ
votes: '{votes} 投票'
Reveal Answers: 回答を表示
Hide Answers: 回答の非表示

View File

@ -35,6 +35,7 @@ Global:
Videos: 'Videoer'
Shorts: Kortvideoer
Live: Direkte
Community: Gemenskap
# Search Bar
Search / Go to URL: 'Søk/gå til nettadresse'
@ -558,7 +559,6 @@ Channel:
kanalen er aldersbegrenset og kan derfor ikke vises i FreeTube.
Channel Tabs: Kanalfaner
Community:
Community: Gemenskap
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

View File

@ -34,6 +34,7 @@ Global:
Videos: 'Filmy'
Shorts: Filmy Short
Live: Transmisje
Community: Społeczność
# Search Bar
Counts:
@ -626,7 +627,6 @@ Channel:
Community:
This channel currently does not have any posts: Ten kanał nie ma obecnie żadnych
publikacji
Community: Społeczność
votes: '{votes} głosów'
Reveal Answers: Pokaż odpowiedzi
Hide Answers: Schowaj odpowiedzi

View File

@ -34,6 +34,7 @@ Global:
Videos: 'Vídeos'
Shorts: Shorts
Live: Ao vivo
Community: Comunidade
# Search Bar
Counts:
@ -612,7 +613,6 @@ Channel:
This channel is age-restricted and currently cannot be viewed in FreeTube.: Este
canal tem restrição de idade e atualmente não pode ser visualizado no FreeTube.
Community:
Community: Comunidade
This channel currently does not have any posts: Neste momento, este canal não
tem publicações
votes: '{votes} Votos'

View File

@ -36,6 +36,7 @@ Global:
Videos: Vídeos
Shorts: Curtas
Live: Em directo
Community: Comunidade
Version {versionNumber} is now available! Click for more details: A versão {versionNumber}
já está disponível! Clique para mais detalhes
@ -594,7 +595,6 @@ Channel:
canal tem restrição de idade e atualmente não pode ser visualizado no Free Tube.
Channel Tabs: Separadores de canais
Community:
Community: Comunidade
This channel currently does not have any posts: Neste momento, este canal não
tem publicações
Live:

View File

@ -35,6 +35,7 @@ Global:
Videos: 'Vídeos'
Shorts: Curtas
Live: Em directo
Community: Comunidade
Counts:
Channel Count: 1 canal | {count} canais

View File

@ -33,9 +33,9 @@ Forward: 'Înainte'
# Anything shared among components / views should be put here
Global:
Videos: 'Videoclipuri'
Shorts: Shorts
Live: Live
Community: Comunitate
Counts:
Video Count: 1 videoclip | {count} videoclipuri
Subscriber Count: 1 abonat | {count} de abonați

View File

@ -33,6 +33,7 @@ Global:
Videos: 'Видео'
Shorts: Короткие видео
Live: Трансляции
Community: Сообщество
# Search Bar
Search / Go to URL: 'Поиск / Перейти по адресу'
@ -598,7 +599,6 @@ Channel:
This channel is age-restricted and currently cannot be viewed in FreeTube.: Этот
канал ограничен по возрасту и в настоящее время не может быть просмотрен во FreeTube.
Community:
Community: Сообщество
This channel currently does not have any posts: На этом канале в настоящее время
нет никаких записей
votes: 'Голосов: {votes}'

View File

@ -35,6 +35,7 @@ Global:
Videos: 'Videor'
Shorts: Shorts
Live: Live
Community: Gemenskap
Version {versionNumber} is now available! Click for more details: 'Versionen {versionNumber}
är nu tillgänglig! Klicka för mer detaljer'
@ -636,7 +637,6 @@ Channel:
kanalen är ålderbegränsad och kan inte ses i FreeTube.
Channel Tabs: Kanalflikar
Community:
Community: Gemenskap
This channel currently does not have any posts: Denna kanal har för närvarande
inga inlägg
votes: '{votes} röster'

View File

@ -35,6 +35,7 @@ Global:
Videos: 'Videolar'
Shorts: Kısa Videolar
Live: Canlı
Community: Topluluk
Counts:
Video Count: 1 video | {count} video
@ -662,7 +663,6 @@ Channel:
This channel does not allow searching: Bu kanal aramaya izin vermiyor
This channel does not exist: Bu kanal mevcut değil
Community:
Community: Topluluk
This channel currently does not have any posts: Bu kanalda şu anda herhangi bir
gönderi yok
votes: '{votes} oy'

View File

@ -35,6 +35,7 @@ Global:
Videos: 'Відео'
Shorts: Shorts
Live: Наживо
Community: Спільнота
Counts:
Video Count: 1 відео | {count} відео
@ -593,7 +594,6 @@ Channel:
канал має вікові обмеження і наразі не може бути переглянутий на FreeTube.
Channel Tabs: Вкладки каналів
Community:
Community: Спільнота
This channel currently does not have any posts: Зараз на цьому каналі немає публікацій
votes: 'Голосів: {votes}'
Reveal Answers: Розгорнути відповіді

View File

@ -33,6 +33,7 @@ Global:
Videos: '视频'
Shorts: 短视频
Live: 直播
Community: 社区
# Search Bar
Counts:
@ -555,7 +556,6 @@ Channel:
This channel does not exist: 此频道不存在
This channel does not allow searching: 此频道不允许搜索
Community:
Community: 社区
This channel currently does not have any posts: 此频道当前没有任何帖子
votes: '{votes} 票'
Reveal Answers: 揭晓答案

View File

@ -33,6 +33,7 @@ Global:
Videos: '影片'
Shorts: 短片
Live: 直播
Community: 社群
# Search Bar
Counts:
@ -565,7 +566,6 @@ Channel:
This channel does not exist: 此頻道不存在
This channel does not allow searching: 此頻道不允許搜尋
Community:
Community: 社群
This channel currently does not have any posts: 此頻道目前沒有任何貼文
votes: '{votes} 票'
Reveal Answers: 揭露答案