FreeTube/src/renderer/views/Channel/Channel.js

1203 lines
38 KiB
JavaScript

import { defineComponent } from 'vue'
import { mapActions } from 'vuex'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtButton from '../../components/ft-button/ft-button.vue'
import FtInput from '../../components/ft-input/ft-input.vue'
import FtSelect from '../../components/ft-select/ft-select.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtAgeRestricted from '../../components/ft-age-restricted/ft-age-restricted.vue'
import FtShareButton from '../../components/ft-share-button/ft-share-button.vue'
import autolinker from 'autolinker'
import { MAIN_PROFILE_ID } from '../../../constants'
import { copyToClipboard, extractNumberFromString, formatNumber, isNullOrEmpty, showToast } from '../../helpers/utils'
import packageDetails from '../../../../package.json'
import {
invidiousAPICall,
invidiousGetChannelId,
invidiousGetChannelInfo,
invidiousGetCommunityPosts,
youtubeImageUrlToInvidious
} from '../../helpers/api/invidious'
import {
getLocalChannel,
getLocalChannelId,
parseLocalChannelVideos,
parseLocalCommunityPost,
parseLocalListPlaylist,
parseLocalListVideo,
parseLocalSubscriberCount
} from '../../helpers/api/local'
export default defineComponent({
name: 'Channel',
components: {
'ft-card': FtCard,
'ft-button': FtButton,
'ft-input': FtInput,
'ft-select': FtSelect,
'ft-flex-box': FtFlexBox,
'ft-channel-bubble': FtChannelBubble,
'ft-loader': FtLoader,
'ft-element-list': FtElementList,
'ft-age-restricted': FtAgeRestricted,
'ft-share-button': FtShareButton
},
data: function () {
return {
isLoading: false,
isElementListLoading: false,
currentTab: 'videos',
id: '',
channelInstance: null,
channelName: '',
bannerUrl: '',
thumbnailUrl: '',
subCount: 0,
searchPage: 2,
videoContinuationData: null,
playlistContinuationData: null,
searchContinuationData: null,
communityContinuationData: null,
description: '',
tags: [],
views: 0,
joined: 0,
location: null,
videoSortBy: 'newest',
playlistSortBy: 'newest',
lastSearchQuery: '',
relatedChannels: [],
latestVideos: [],
latestPlaylists: [],
latestCommunityPosts: [],
searchResults: [],
shownElementList: [],
apiUsed: '',
isFamilyFriendly: false,
errorMessage: '',
showSearchBar: true,
showShareMenu: true,
videoSelectValues: [
'newest',
'popular'
],
playlistSelectValues: [
'newest',
'last'
],
tabInfoValues: [
'videos',
'playlists',
'community',
'about'
]
}
},
computed: {
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
hideUnsubscribeButton: function() {
return this.$store.getters.getHideUnsubscribeButton
},
showFamilyFriendlyOnly: function() {
return this.$store.getters.getShowFamilyFriendlyOnly
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
sessionSearchHistory: function () {
return this.$store.getters.getSessionSearchHistory
},
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
},
profileList: function () {
return this.$store.getters.getProfileList
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
subscriptionInfo: function () {
return this.activeProfile.subscriptions.find((channel) => {
return channel.id === this.id
}) ?? null
},
isSubscribed: function () {
return this.subscriptionInfo !== null
},
subscribedText: function () {
if (this.isSubscribed) {
return this.$t('Channel.Unsubscribe').toUpperCase()
} else {
return this.$t('Channel.Subscribe').toUpperCase()
}
},
videoSelectNames: function () {
return [
this.$t('Channel.Videos.Sort Types.Newest'),
this.$t('Channel.Videos.Sort Types.Most Popular')
]
},
playlistSelectNames: function () {
return [
this.$t('Channel.Playlists.Sort Types.Newest'),
this.$t('Channel.Playlists.Sort Types.Last Video Added')
]
},
formattedSubCount: function () {
if (this.hideChannelSubscriptions) {
return null
}
return formatNumber(this.subCount)
},
formattedViews: function () {
return formatNumber(this.views)
},
formattedJoined: function () {
return new Intl.DateTimeFormat([this.currentLocale, 'en'], { dateStyle: 'long' }).format(this.joined)
},
showFetchMoreButton: function () {
switch (this.currentTab) {
case 'videos':
return !isNullOrEmpty(this.videoContinuationData)
case 'playlists':
return !isNullOrEmpty(this.playlistContinuationData)
case 'community':
return !isNullOrEmpty(this.communityContinuationData)
case 'search':
return !isNullOrEmpty(this.searchContinuationData)
}
return false
},
hideChannelSubscriptions: function () {
return this.$store.getters.getHideChannelSubscriptions
},
searchSettings: function () {
return this.$store.getters.getSearchSettings
},
hideSearchBar: function () {
return this.$store.getters.getHideSearchBar
},
hideSharingActions: function () {
return this.$store.getters.getHideSharingActions
}
},
watch: {
$route() {
// react to route changes...
this.isLoading = true
if (this.$route.query.url) {
this.resolveChannelUrl(this.$route.query.url, this.$route.params.currentTab)
return
}
this.id = this.$route.params.id
this.currentTab = this.$route.params.currentTab ?? 'videos'
this.searchPage = 2
this.relatedChannels = []
this.latestVideos = []
this.latestPlaylists = []
this.searchResults = []
this.shownElementList = []
this.apiUsed = ''
this.channelInstance = ''
this.videoContinuationData = null
this.playlistContinuationData = null
this.searchContinuationData = null
this.communityContinuationData = null
this.showSearchBar = true
if (this.id === '@@@') {
this.showShareMenu = false
this.setErrorMessage(this.$i18n.t('Channel.This channel does not exist'))
return
}
this.showShareMenu = true
this.errorMessage = ''
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getChannelInfoInvidious()
} else {
this.getChannelLocal()
}
},
videoSortBy () {
this.isElementListLoading = true
this.latestVideos = []
switch (this.apiUsed) {
case 'local':
this.getChannelVideosLocal()
break
case 'invidious':
this.channelInvidiousVideos(true)
break
default:
this.getChannelVideosLocal()
}
},
playlistSortBy () {
this.isElementListLoading = true
this.latestPlaylists = []
this.playlistContinuationData = null
switch (this.apiUsed) {
case 'local':
this.getChannelPlaylistsLocal()
break
case 'invidious':
this.getPlaylistsInvidious()
break
default:
this.getChannelPlaylistsLocal()
}
}
},
mounted: function () {
this.isLoading = true
if (this.$route.query.url) {
this.resolveChannelUrl(this.$route.query.url, this.$route.params.currentTab)
return
}
this.id = this.$route.params.id
this.currentTab = this.$route.params.currentTab ?? 'videos'
if (this.id === '@@@') {
this.showShareMenu = false
this.setErrorMessage(this.$i18n.t('Channel.This channel does not exist'))
return
}
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getChannelInfoInvidious()
} else {
this.getChannelLocal()
}
},
methods: {
resolveChannelUrl: async function (url, tab = undefined) {
let id
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
id = await invidiousGetChannelId(url)
} else {
id = await getLocalChannelId(url)
}
if (id === null) {
// the channel page shows an error about the channel not existing when the id is @@@
id = '@@@'
}
// use router.replace to replace the current history entry
// with the one with the resolved channel id
// that way if you navigate back or forward in the history to this entry
// we don't need to resolve the URL again as we already know it
if (tab) {
this.$router.replace({ path: `/channel/${id}/${tab}` })
} else {
this.$router.replace({ path: `/channel/${id}` })
}
},
goToChannel: function (id) {
this.$router.push({ path: `/channel/${id}` })
},
getChannelLocal: async function () {
this.apiUsed = 'local'
this.isLoading = true
const expectedId = this.id
try {
const channel = await getLocalChannel(this.id)
let channelName
let channelThumbnailUrl
if (channel.alert) {
this.setErrorMessage(channel.alert)
return
} else if (channel.memo.has('ChannelAgeGate')) {
/** @type {import('youtubei.js/dist/src/parser/classes/ChannelAgeGate').default} */
const ageGate = channel.memo.get('ChannelAgeGate')[0]
channelName = ageGate.channel_title
channelThumbnailUrl = ageGate.avatar[0].url
this.channelName = channelName
this.thumbnailUrl = channelThumbnailUrl
document.title = `${channelName} - ${packageDetails.productName}`
this.updateSubscriptionDetails({ channelThumbnailUrl, channelName, channelId: this.id })
this.setErrorMessage(this.$t('Channel["This channel is age resticted and currently cannot be viewed in FreeTube."]'), true)
return
}
this.errorMessage = ''
if (expectedId !== this.id) {
return
}
let channelId
let subscriberText = null
let tags = []
switch (channel.header.type) {
case 'C4TabbedHeader': {
// example: Linus Tech Tips
// https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
/**
* @type {import('youtubei.js/dist/src/parser/classes/C4TabbedHeader').default}
*/
const header = channel.header
channelId = header.author.id
channelName = header.author.name
channelThumbnailUrl = header.author.best_thumbnail.url
subscriberText = header.subscribers?.text
break
}
case 'CarouselHeader': {
// examples: Music and YouTube Gaming
// https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
/**
* @type {import('youtubei.js/dist/src/parser/classes/CarouselHeader').default}
*/
const header = channel.header
/**
* @type {import('youtubei.js/dist/src/parser/classes/TopicChannelDetails').default}
*/
const topicChannelDetails = header.contents.find(node => node.type === 'TopicChannelDetails')
channelName = topicChannelDetails.title.text
subscriberText = topicChannelDetails.subtitle.text
channelThumbnailUrl = topicChannelDetails.avatar[0].url
if (channel.metadata.external_id) {
channelId = channel.metadata.external_id
} else {
channelId = topicChannelDetails.subscribe_button.channel_id
}
break
}
case 'InteractiveTabbedHeader': {
// example: Minecraft - Topic
// https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg
/**
* @type {import('youtubei.js/dist/src/parser/classes/InteractiveTabbedHeader').default}
*/
const header = channel.header
channelName = header.title.text
channelId = this.id
channelThumbnailUrl = header.box_art.at(-1).url
const badges = header.badges.map(badge => badge.label).filter(tag => tag)
tags.push(...badges)
break
}
}
this.channelName = channelName
this.thumbnailUrl = channelThumbnailUrl
this.isFamilyFriendly = !!channel.metadata.is_family_safe
if (channel.metadata.tags) {
tags.push(...channel.metadata.tags)
}
// deduplicate tags
// a Set can only ever contain unique elements,
// so this is an easy way to get rid of duplicates
if (tags.length > 0) {
tags = Array.from(new Set(tags))
}
this.tags = tags
document.title = `${channelName} - ${packageDetails.productName}`
if (!this.hideChannelSubscriptions && subscriberText) {
const subCount = parseLocalSubscriberCount(subscriberText)
if (isNaN(subCount)) {
this.subCount = null
} else {
this.subCount = subCount
}
} else {
this.subCount = null
}
this.updateSubscriptionDetails({ channelThumbnailUrl, channelName, channelId })
if (channel.header.banner?.length > 0) {
this.bannerUrl = channel.header.banner[0].url
} else {
this.bannerUrl = null
}
this.relatedChannels = channel.channels.map(({ author }) => {
let thumbnailUrl = author.best_thumbnail.url
if (thumbnailUrl.startsWith('//')) {
thumbnailUrl = `https:${thumbnailUrl}`
}
return {
name: author.name,
id: author.id,
thumbnailUrl
}
})
this.channelInstance = channel
if (channel.has_about) {
this.getChannelAboutLocal()
} else {
this.description = ''
this.views = null
this.joined = 0
this.location = null
}
if (channel.has_videos) {
this.getChannelVideosLocal()
}
if (channel.has_playlists) {
this.getChannelPlaylistsLocal()
}
if (channel.has_community) {
this.getCommunityPostsLocal()
}
this.showSearchBar = channel.has_search
this.isLoading = false
} 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'))
this.getChannelInfoInvidious()
} else {
this.isLoading = false
}
}
},
getChannelAboutLocal: async function () {
try {
/**
* @type {import('youtubei.js/dist/src/parser/youtube/Channel').default}
*/
const channel = this.channelInstance
const about = await channel.getAbout()
this.description = about.description.text !== 'N/A' ? autolinker.link(about.description.text) : ''
const views = extractNumberFromString(about.views.text)
this.views = isNaN(views) ? null : views
this.joined = new Date(about.joined.text.replace('Joined').trim())
this.location = about.country.text !== 'N/A' ? about.country.text : null
} 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'))
this.getChannelInfoInvidious()
} else {
this.isLoading = false
}
}
},
getChannelVideosLocal: async function () {
this.isElementListLoading = true
const expectedId = this.id
try {
/**
* @type {import('youtubei.js/dist/src/parser/youtube/Channel').default}
*/
const channel = this.channelInstance
let videosTab = await channel.getVideos()
if (this.videoSortBy !== 'newest') {
const index = this.videoSelectValues.indexOf(this.videoSortBy)
videosTab = await videosTab.applyFilter(videosTab.filters[index])
}
if (expectedId !== this.id) {
return
}
this.latestVideos = parseLocalChannelVideos(videosTab.videos, channel.header.author)
this.videoContinuationData = videosTab.has_continuation ? videosTab : null
this.isElementListLoading = false
} 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'))
this.getChannelInfoInvidious()
} else {
this.isLoading = false
}
}
},
channelLocalNextPage: async function () {
try {
/**
* @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation|import('youtubei.js/dist/src/parser/youtube/Channel').FilteredChannelList}
*/
const continuation = await this.videoContinuationData.getContinuation()
this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.channelInstance.header.author))
this.videoContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
}
},
getChannelInfoInvidious: function () {
this.isLoading = true
this.apiUsed = 'invidious'
this.channelInstance = null
const expectedId = this.id
invidiousGetChannelInfo(this.id).then((response) => {
if (expectedId !== this.id) {
return
}
const channelName = response.author
const channelId = response.authorId
this.channelName = channelName
document.title = `${this.channelName} - ${packageDetails.productName}`
this.id = channelId
this.isFamilyFriendly = response.isFamilyFriendly
if (this.hideChannelSubscriptions) {
this.subCount = null
} else {
this.subCount = response.subCount
}
const thumbnail = response.authorThumbnails[3].url
this.thumbnailUrl = youtubeImageUrlToInvidious(thumbnail, this.currentInvidiousInstance)
this.updateSubscriptionDetails({ channelThumbnailUrl: thumbnail, channelName: channelName, channelId: channelId })
this.description = autolinker.link(response.description)
this.views = response.totalViews
this.joined = new Date(response.joined * 1000)
this.relatedChannels = response.relatedChannels.map((channel) => {
const thumbnailUrl = channel.authorThumbnails.at(-1).url
return {
name: channel.author,
id: channel.authorId,
thumbnailUrl: youtubeImageUrlToInvidious(thumbnailUrl, this.currentInvidiousInstance)
}
})
this.latestVideos = response.latestVideos
if (response.authorBanners instanceof Array && response.authorBanners.length > 0) {
this.bannerUrl = youtubeImageUrlToInvidious(response.authorBanners[0].url, this.currentInvidiousInstance)
} else {
this.bannerUrl = null
}
this.errorMessage = ''
// some channels only have a few tabs
// here are all possible values: home, videos, shorts, streams, playlists, community, channels, about
if (response.tabs.includes('videos')) {
this.channelInvidiousVideos()
}
if (response.tabs.includes('playlists')) {
this.getPlaylistsInvidious()
}
if (response.tabs.includes('community')) {
this.getCommunityPostsInvidious()
}
this.isLoading = false
}).catch((err) => {
this.setErrorMessage(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 Local API'))
this.getChannelLocal()
} else {
this.isLoading = false
}
})
},
channelInvidiousVideos: function (sortByChanged) {
const payload = {
resource: 'channels/videos',
id: this.id,
params: {
sort_by: this.videoSortBy,
}
}
if (sortByChanged) {
this.videoContinuationData = null
}
let more = false
if (this.videoContinuationData) {
payload.params.continuation = this.videoContinuationData
more = true
}
if (!more) {
this.isElementListLoading = true
}
invidiousAPICall(payload).then((response) => {
if (more) {
this.latestVideos = this.latestVideos.concat(response.videos)
} else {
this.latestVideos = response.videos
}
this.videoContinuationData = response.continuation || null
this.isElementListLoading = false
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
})
},
getChannelPlaylistsLocal: async function () {
const expectedId = this.id
try {
/**
* @type {import('youtubei.js/dist/src/parser/youtube/Channel').default}
*/
const channel = this.channelInstance
let playlistsTab = await channel.getPlaylists()
// some channels have more categories of playlists than just "Created Playlists" e.g. https://www.youtube.com/channel/UCez-2shYlHQY3LfILBuDYqQ
// for the moment we just want the "Created Playlists" category that has all playlists in it
if (playlistsTab.content_type_filters.length > 1) {
/**
* @type {import('youtubei.js/dist/src/parser/classes/ChannelSubMenu').default}
*/
const menu = playlistsTab.current_tab.content.sub_menu
const createdPlaylistsFilter = menu.content_type_sub_menu_items.find(contentType => {
const url = `https://youtube.com/${contentType.endpoint.metadata.url}`
return new URL(url).searchParams.get('view') === '1'
}).title
playlistsTab = await playlistsTab.applyContentTypeFilter(createdPlaylistsFilter)
}
if (this.playlistSortBy !== 'newest' && playlistsTab.sort_filters.length > 0) {
const index = this.playlistSelectValues.indexOf(this.playlistSortBy)
playlistsTab = await playlistsTab.applySort(playlistsTab.sort_filters[index])
}
if (expectedId !== this.id) {
return
}
this.latestPlaylists = playlistsTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
this.playlistContinuationData = playlistsTab.has_continuation ? playlistsTab : null
this.isElementListLoading = false
} 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'))
this.getPlaylistsInvidious()
} else {
this.isLoading = false
}
}
},
getChannelPlaylistsLocalMore: async function () {
try {
/**
* @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation}
*/
const continuation = await this.playlistContinuationData.getContinuation()
const parsedPlaylists = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author))
this.latestPlaylists = this.latestPlaylists.concat(parsedPlaylists)
this.playlistContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
}
},
getPlaylistsInvidious: function () {
this.isElementListLoading = true
const payload = {
resource: 'channels/playlists',
id: this.id,
params: {
sort_by: this.playlistSortBy
}
}
invidiousAPICall(payload).then((response) => {
this.playlistContinuationData = response.continuation || null
this.latestPlaylists = response.playlists
this.isElementListLoading = false
}).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 Local API'))
this.getChannelLocal()
} else {
this.isLoading = false
}
})
},
getPlaylistsInvidiousMore: function () {
if (this.playlistContinuationData === null) {
console.warn('There are no more playlists available for this channel')
return
}
const payload = {
resource: 'channels/playlists',
id: this.id,
params: {
sort_by: this.playlistSortBy
}
}
if (this.playlistContinuationData) {
payload.params.continuation = this.playlistContinuationData
}
invidiousAPICall(payload).then((response) => {
this.playlistContinuationData = response.continuation || null
this.latestPlaylists = this.latestPlaylists.concat(response.playlists)
this.isElementListLoading = false
}).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 Local API'))
this.getChannelLocal()
} else {
this.isLoading = false
}
})
},
getCommunityPostsLocal: async function () {
const expectedId = this.id
try {
/**
* @type {import('youtubei.js/dist/src/parser/youtube/Channel').default}
*/
const channel = this.channelInstance
const communityTab = await channel.getCommunity()
if (expectedId !== this.id) {
return
}
this.latestCommunityPosts = communityTab.posts.map(parseLocalCommunityPost)
this.communityContinuationData = communityTab.has_continuation ? communityTab : null
} 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'))
this.getCommunityPostsInvidious()
} else {
this.isLoading = false
}
}
},
getCommunityPostsLocalMore: async function () {
try {
/**
* @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation}
*/
const continuation = await this.communityContinuationData.getContinuation()
this.latestCommunityPosts = this.latestCommunityPosts.concat(continuation.posts.map(parseLocalCommunityPost))
this.communityContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
}
},
getCommunityPostsInvidious: function() {
invidiousGetCommunityPosts(this.id).then(posts => {
this.latestCommunityPosts = 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 Local API'))
this.getCommunityPostsLocal()
}
})
},
handleSubscription: function () {
const currentProfile = JSON.parse(JSON.stringify(this.activeProfile))
const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0]))
if (this.isSubscribed) {
currentProfile.subscriptions = currentProfile.subscriptions.filter((channel) => {
return channel.id !== this.id
})
this.updateProfile(currentProfile)
showToast(this.$t('Channel.Channel has been removed from your subscriptions'))
if (this.activeProfile._id === MAIN_PROFILE_ID) {
// Check if a subscription exists in a different profile.
// Remove from there as well.
let duplicateSubscriptions = 0
this.profileList.forEach((profile) => {
if (profile._id === MAIN_PROFILE_ID) {
return
}
const parsedProfile = JSON.parse(JSON.stringify(profile))
const index = parsedProfile.subscriptions.findIndex((channel) => {
return channel.id === this.id
})
if (index !== -1) {
duplicateSubscriptions++
parsedProfile.subscriptions = parsedProfile.subscriptions.filter((x) => {
return x.id !== this.id
})
this.updateProfile(parsedProfile)
}
})
if (duplicateSubscriptions > 0) {
const message = this.$t('Channel.Removed subscription from {count} other channel(s)', { count: duplicateSubscriptions })
showToast(message)
}
}
} else {
const subscription = {
id: this.id,
name: this.channelName,
thumbnail: this.thumbnailUrl
}
currentProfile.subscriptions.push(subscription)
this.updateProfile(currentProfile)
showToast(this.$t('Channel.Added channel to your subscriptions'))
if (this.activeProfile._id !== MAIN_PROFILE_ID) {
const index = primaryProfile.subscriptions.findIndex((channel) => {
return channel.id === this.id
})
if (index === -1) {
primaryProfile.subscriptions.push(subscription)
this.updateProfile(primaryProfile)
}
}
}
},
setErrorMessage: function (errorMessage, responseHasNameAndThumbnail = false) {
this.isLoading = false
this.errorMessage = errorMessage
if (!responseHasNameAndThumbnail) {
this.channelName = this.subscriptionInfo?.name
this.thumbnailUrl = this.subscriptionInfo?.thumbnail
}
this.bannerUrl = null
this.subCount = null
},
handleFetchMore: function () {
switch (this.currentTab) {
case 'videos':
switch (this.apiUsed) {
case 'local':
this.channelLocalNextPage()
break
case 'invidious':
this.channelInvidiousVideos()
break
}
break
case 'playlists':
switch (this.apiUsed) {
case 'local':
this.getChannelPlaylistsLocalMore()
break
case 'invidious':
this.getPlaylistsInvidiousMore()
break
}
break
case 'search':
switch (this.apiUsed) {
case 'local':
this.searchChannelLocal()
break
case 'invidious':
this.searchChannelInvidious()
break
}
break
case 'community':
switch (this.apiUsed) {
case 'local':
this.getCommunityPostsLocalMore()
break
case 'invidious':
// not supported by invidious yet...
// this.getCommunityPostsInvidiousMore()
break
}
break
default:
console.error(this.currentTab)
}
},
changeTab: function (tab, event) {
if (event instanceof KeyboardEvent) {
// use arrowkeys to navigate
event.preventDefault()
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const index = this.tabInfoValues.indexOf(tab)
// focus left or right tab with wrap around
tab = (event.key === 'ArrowLeft')
? this.tabInfoValues[(index > 0 ? index : this.tabInfoValues.length) - 1]
: this.tabInfoValues[(index + 1) % this.tabInfoValues.length]
const tabNode = document.getElementById(`${tab}Tab`)
event.target.setAttribute('tabindex', '-1')
tabNode.setAttribute('tabindex', 0)
tabNode.focus({ focusVisible: true })
return
}
}
// `currentTabNode` can be `null` on 2nd+ search
const currentTabNode = document.querySelector('.tabs > .tab[aria-selected="true"]')
// `newTabNode` can be `null` when `tab` === "search"
const newTabNode = document.getElementById(`${tab}Tab`)
document.querySelector('.tabs > .tab[tabindex="0"]')?.setAttribute('tabindex', '-1')
newTabNode?.setAttribute('tabindex', '0')
currentTabNode?.setAttribute('aria-selected', 'false')
newTabNode?.setAttribute('aria-selected', 'true')
this.currentTab = tab
newTabNode?.focus({ focusVisible: true })
},
newSearch: function (query) {
this.lastSearchQuery = query
this.searchContinuationData = null
this.isElementListLoading = true
this.searchPage = 1
this.searchResults = []
this.changeTab('search')
switch (this.apiUsed) {
case 'local':
this.searchChannelLocal()
break
case 'invidious':
this.searchChannelInvidious()
break
}
},
searchChannelLocal: async function () {
const isNewSearch = this.searchContinuationData === null
try {
let result
let contents
if (isNewSearch) {
if (!this.channelInstance.has_search) {
showToast(this.$t('Channel.This channel does not allow searching'), 5000)
this.showSearchBar = false
return
}
result = await this.channelInstance.search(this.lastSearchQuery)
contents = result.current_tab.content.contents
} else {
result = await this.searchContinuationData.getContinuation()
contents = result.contents.contents
}
const results = contents
.filter(node => node.type === 'ItemSection')
.flatMap(itemSection => itemSection.contents)
.filter(item => item.type === 'Video' || item.type === 'Playlist')
.map(item => {
if (item.type === 'Video') {
return parseLocalListVideo(item)
} else {
return parseLocalListPlaylist(item, this.channelInstance.header.author)
}
})
if (isNewSearch) {
this.searchResults = results
} else {
this.searchResults = this.searchResults.concat(results)
}
this.searchContinuationData = result.has_continuation ? result : null
this.isElementListLoading = false
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
if (isNewSearch) {
if (this.backendPreference === 'local' && this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
this.searchChannelInvidious()
} else {
this.isLoading = false
}
}
}
},
searchChannelInvidious: function () {
const payload = {
resource: 'channels/search',
id: this.id,
params: {
q: this.lastSearchQuery,
page: this.searchPage
}
}
invidiousAPICall(payload).then((response) => {
this.searchResults = this.searchResults.concat(response)
this.isElementListLoading = false
this.searchPage++
}).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 Local API'))
this.searchChannelLocal()
} else {
this.isLoading = false
}
})
},
...mapActions([
'updateProfile',
'updateSubscriptionDetails'
])
}
})