diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js
index 18301f155c1cd..960797e89cda9 100644
--- a/src/renderer/helpers/api/local.js
+++ b/src/renderer/helpers/api/local.js
@@ -87,10 +87,11 @@ export function cordovaFetch(input, init = {}) {
* @param {boolean} options.withPlayer set to true to get an Innertube instance that can decode the streaming URLs
* @param {string|undefined} options.location the geolocation to pass to YouTube get different content
* @param {boolean} options.safetyMode whether to hide mature content
- * @param {string} options.clientType use an alterate client
+ * @param {import('youtubei.js').ClientType} options.clientType use an alterate client
+ * @param {boolean} options.generateSessionLocally generate the session locally or let YouTube generate it (local is faster, remote is more accurate)
* @returns the Innertube instance
*/
-async function createInnertube(options = { withPlayer: false, location: undefined, safetyMode: false, clientType: undefined }) {
+async function createInnertube(options = { withPlayer: false, location: undefined, safetyMode: false, clientType: undefined, generateSessionLocally: true }) {
let cache
if (options.withPlayer) {
const userData = await getUserDataPath()
@@ -117,7 +118,7 @@ async function createInnertube(options = { withPlayer: false, location: undefine
}
},
cache,
- generate_session_locally: true
+ generate_session_locally: !!options.generateSessionLocally
})
}
@@ -207,14 +208,14 @@ export async function getLocalVideoInfo(id, attemptBypass = false) {
let player
if (attemptBypass) {
- const innertube = await createInnertube({ withPlayer: true, clientType: ClientType.TV_EMBEDDED })
+ const innertube = await createInnertube({ withPlayer: true, clientType: ClientType.TV_EMBEDDED, generateSessionLocally: false })
player = innertube.actions.session.player
// the second request that getInfo makes 404s with the bypass, so we use getBasicInfo instead
// that's fine as we have most of the information from the original getInfo request
info = await innertube.getBasicInfo(id, 'TV_EMBEDDED')
} else {
- const innertube = await createInnertube({ withPlayer: true })
+ const innertube = await createInnertube({ withPlayer: true, generateSessionLocally: false })
player = innertube.actions.session.player
info = await innertube.getInfo(id)
@@ -233,17 +234,24 @@ export async function getLocalComments(id, sortByNewest = false) {
return innertube.getComments(id, sortByNewest ? 'NEWEST_FIRST' : 'TOP_COMMENTS')
}
+// I know `type & type` is typescript syntax and not valid jsdoc but I couldn't get @extends or @augments to work
+
+/**
+ * @typedef {object} _LocalFormat
+ * @property {string} freeTubeUrl deciphered streaming URL, stored in a custom property so the DASH manifest generation doesn't break
+ *
+ * @typedef {Misc.Format & _LocalFormat} LocalFormat
+ */
+
/**
* @param {Misc.Format[]} formats
* @param {import('youtubei.js').Player} player
*/
function decipherFormats(formats, player) {
for (const format of formats) {
- format.url = format.decipher(player)
-
- // set these to undefined so that toDash doesn't try to decipher them again, throwing an error
- format.cipher = undefined
- format.signature_cipher = undefined
+ // toDash deciphers the format again, so if we overwrite the original URL,
+ // it breaks because the n param would get deciphered twice and then be incorrect
+ format.freeTubeUrl = format.decipher(player)
}
}
@@ -420,6 +428,8 @@ function parseShortDuration(accessibilityLabel, videoId) {
export function parseLocalListPlaylist(playlist, author = undefined) {
let channelName
let channelId = null
+ /** @type {import('youtubei.js').YTNodes.PlaylistVideoThumbnail} */
+ const thumbnailRenderer = playlist.thumbnail_renderer
if (playlist.author) {
if (playlist.author instanceof Misc.Text) {
@@ -441,7 +451,7 @@ export function parseLocalListPlaylist(playlist, author = undefined) {
type: 'playlist',
dataSource: 'local',
title: playlist.title.text,
- thumbnail: playlist.thumbnails[0].url,
+ thumbnail: thumbnailRenderer ? thumbnailRenderer.thumbnail[0].url : playlist.thumbnails[0].url,
channelName,
channelId,
playlistId: playlist.id,
@@ -468,23 +478,79 @@ function handleSearchResponse(response) {
return {
results,
- continuationData: response.has_continuation ? response : null
+ // check the length of the results, as there can be continuations for things that we've filtered out, which we don't want
+ continuationData: response.has_continuation && results.length > 0 ? response : null
}
}
/**
- * @param {import('youtubei.js').YTNodes.PlaylistVideo} video
+ * @param {import('youtubei.js').YTNodes.PlaylistVideo|import('youtubei.js').YTNodes.ReelItem} video
*/
export function parseLocalPlaylistVideo(video) {
- return {
- videoId: video.id,
- title: video.title.text,
- author: video.author.name,
- authorId: video.author.id,
- lengthSeconds: isNaN(video.duration.seconds) ? '' : video.duration.seconds,
- liveNow: video.is_live,
- isUpcoming: video.is_upcoming,
- premiereDate: video.upcoming
+ if (video.type === 'ReelItem') {
+ /** @type {import('youtubei.js').YTNodes.ReelItem} */
+ const short = video
+
+ // unfortunately the only place with the duration is the accesibility string
+ const duration = parseShortDuration(video.accessibility_label, short.id)
+
+ return {
+ type: 'video',
+ videoId: short.id,
+ title: short.title.text,
+ viewCount: parseLocalSubscriberCount(short.views.text),
+ lengthSeconds: isNaN(duration) ? '' : duration
+ }
+ } else {
+ /** @type {import('youtubei.js').YTNodes.PlaylistVideo} */
+ const video_ = video
+
+ let viewCount = null
+
+ // the accessiblity label contains the full view count
+ // the video info only contains the short view count
+ if (video_.accessibility_label) {
+ const match = video_.accessibility_label.match(/([\d,.]+|no) views?$/i)
+
+ if (match) {
+ const count = match[1]
+
+ // as it's rare that a video has no views,
+ // checking the length allows us to avoid running toLowerCase unless we have to
+ if (count.length === 2 && count.toLowerCase() === 'no') {
+ viewCount = 0
+ } else {
+ const views = extractNumberFromString(count)
+
+ if (!isNaN(views)) {
+ viewCount = views
+ }
+ }
+ }
+ }
+
+ let publishedText = null
+
+ // normal videos have 3 text runs with the last one containing the published date
+ // live videos have 2 text runs with the number of people watching
+ // upcoming either videos don't have any info text or the number of people waiting,
+ // but we have the premiere date for those, so we don't need the published date
+ if (video_.video_info.runs && video_.video_info.runs.length === 3) {
+ publishedText = video_.video_info.runs[2].text
+ }
+
+ return {
+ videoId: video_.id,
+ title: video_.title.text,
+ author: video_.author.name,
+ authorId: video_.author.id,
+ viewCount,
+ publishedText,
+ lengthSeconds: isNaN(video_.duration.seconds) ? '' : video_.duration.seconds,
+ liveNow: video_.is_live,
+ isUpcoming: video_.is_upcoming,
+ premiereDate: video_.upcoming
+ }
}
}
@@ -729,7 +795,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16, options = { looseChanne
}
/**
- * @param {Misc.Format} format
+ * @param {LocalFormat} format
*/
export function mapLocalFormat(format) {
return {
@@ -740,7 +806,7 @@ export function mapLocalFormat(format) {
mimeType: format.mime_type,
height: format.height,
width: format.width,
- url: format.url
+ url: format.freeTubeUrl
}
}
diff --git a/src/renderer/helpers/sponsorblock.js b/src/renderer/helpers/sponsorblock.js
index 1f1b4a67908a8..62ea70e9d7623 100644
--- a/src/renderer/helpers/sponsorblock.js
+++ b/src/renderer/helpers/sponsorblock.js
@@ -1,16 +1,17 @@
import store from '../store/index'
-
-export async function sponsorBlockSkipSegments(videoId, categories) {
+async function getVideoHash(videoId) {
const videoIdBuffer = new TextEncoder().encode(videoId)
const hashBuffer = await crypto.subtle.digest('SHA-256', videoIdBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
- const videoIdHashPrefix = hashArray
+ return hashArray
.map(byte => byte.toString(16).padStart(2, '0'))
.slice(0, 4)
.join('')
-
+}
+export async function sponsorBlockSkipSegments(videoId, categories) {
+ const videoIdHashPrefix = await getVideoHash(videoId)
const requestUrl = `${store.getters.getSponsorBlockUrl}/api/skipSegments/${videoIdHashPrefix}?categories=${JSON.stringify(categories)}`
try {
@@ -30,3 +31,23 @@ export async function sponsorBlockSkipSegments(videoId, categories) {
throw error
}
}
+
+export async function deArrowData(videoId) {
+ const videoIdHashPrefix = (await getVideoHash(videoId)).substring(0, 4)
+ const requestUrl = `${store.getters.getSponsorBlockUrl}/api/branding/${videoIdHashPrefix}`
+
+ try {
+ const response = await fetch(requestUrl)
+
+ // 404 means that there are no segments registered for the video
+ if (response.status === 404) {
+ return undefined
+ }
+
+ const json = await response.json()
+ return json[videoId] ?? undefined
+ } catch (error) {
+ console.error('failed to fetch DeArrow data', requestUrl, error)
+ throw error
+ }
+}
diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js
index c8fe1aee0e501..7406a2445c9bb 100644
--- a/src/renderer/helpers/utils.js
+++ b/src/renderer/helpers/utils.js
@@ -201,7 +201,7 @@ export function showToast(message, time = null, action = null) {
* @param {string} messageOnSuccess the message to be displayed as a toast when the copy succeeds (optional)
* @param {string} messageOnError the message to be displayed as a toast when the copy fails (optional)
*/
-export async function copyToClipboard(content, { messageOnSuccess = null, messageOnError = null }) {
+export async function copyToClipboard(content, { messageOnSuccess = null, messageOnError = null } = {}) {
let clipboardAPI = navigator.clipboard.writeText.bind(navigator.clipboard)
if (process.env.IS_CORDOVA) {
// Convert the callbacks to promise notation
@@ -429,7 +429,7 @@ export function createWebURL(path) {
// strip html tags but keep
, , , , ,
export function stripHTML(value) {
- return value.replaceAll(/(<(?!br|\/?[bis]|img>)([^>]+)>)/gi, '')
+ return value.replaceAll(/(<(?!br|\/?[abis]|img>)([^>]+)>)/gi, '')
}
/**
@@ -487,22 +487,22 @@ export function replaceFilenameForbiddenChars(filenameOriginal) {
switch (process.platform) {
case 'win32':
forbiddenChars = {
- '<': '<', // U+FF1C
- '>': '>', // U+FF1E
- ':': ':', // U+FF1A
- '"': '"', // U+FF02
- '/': '/', // U+FF0F
- '\\': '\'', // U+FF3C
- '|': '|', // U+FF5C
- '?': '?', // U+FF1F
- '*': '*' // U+FF0A
+ '<': '<', // U+FF1C
+ '>': '>', // U+FF1E
+ ':': ':', // U+FF1A
+ '"': '"', // U+FF02
+ '/': '/', // U+FF0F
+ '\\': '\', // U+FF3C
+ '|': '|', // U+FF5C
+ '?': '?', // U+FF1F
+ '*': '*' // U+FF0A
}
break
case 'darwin':
- forbiddenChars = { '/': '/', ':': ':' }
+ forbiddenChars = { '/': '/', ':': ':' }
break
case 'linux':
- forbiddenChars = { '/': '/' }
+ forbiddenChars = { '/': '/' }
break
default:
break
diff --git a/src/renderer/scss-partials/_ft-list-item.scss b/src/renderer/scss-partials/_ft-list-item.scss
index 9e55f32c9d513..c3bb46f9adb78 100644
--- a/src/renderer/scss-partials/_ft-list-item.scss
+++ b/src/renderer/scss-partials/_ft-list-item.scss
@@ -228,6 +228,7 @@ $watched-transition-duration: 0.5s;
font-size: 14px;
grid-area: infoLine;
margin-top: 5px;
+ overflow-wrap: anywhere;
@include is-sidebar-item {
font-size: 12px;
diff --git a/src/renderer/store/modules/invidious.js b/src/renderer/store/modules/invidious.js
index f6a4563664fa8..fd6fc272588a1 100644
--- a/src/renderer/store/modules/invidious.js
+++ b/src/renderer/store/modules/invidious.js
@@ -1,5 +1,6 @@
import fs from 'fs/promises'
import { pathExists } from '../../helpers/filesystem'
+import { createWebURL } from '../../helpers/utils'
const state = {
currentInvidiousInstance: '',
@@ -41,9 +42,10 @@ const actions = {
const fileName = 'invidious-instances.json'
/* eslint-disable-next-line n/no-path-concat */
const fileLocation = process.env.NODE_ENV === 'development' ? './static/' : `${__dirname}/static/`
- if (await pathExists(`${fileLocation}${fileName}`)) {
+ const filePath = `${fileLocation}${fileName}`
+ if (!process.env.IS_ELECTRON || await pathExists(filePath)) {
console.warn('reading static file for invidious instances')
- const fileData = await fs.readFile(`${fileLocation}${fileName}`)
+ const fileData = process.env.IS_ELECTRON ? await fs.readFile(filePath, 'utf8') : await (await fetch(createWebURL(filePath))).text()
instances = JSON.parse(fileData).filter(e => {
return process.env.IS_ELECTRON || e.cors
}).map(e => {
diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js
index 76cbf0b48b32b..e4993b9b003f8 100644
--- a/src/renderer/store/modules/settings.js
+++ b/src/renderer/store/modules/settings.js
@@ -286,6 +286,7 @@ const state = {
settingsPassword: '',
allowDashAv1Formats: false,
commentAutoLoadEnabled: false,
+ useDeArrowTitles: false,
showThumbnailInMediaControls: true
}
diff --git a/src/renderer/store/modules/subscriptions.js b/src/renderer/store/modules/subscriptions.js
index 37be7815cbcab..ef2a8bd42ead3 100644
--- a/src/renderer/store/modules/subscriptions.js
+++ b/src/renderer/store/modules/subscriptions.js
@@ -1,39 +1,41 @@
-import { MAIN_PROFILE_ID } from '../../../constants'
+const defaultCacheEntryValueForForOneChannel = {
+ videos: null,
+}
+
+function deepCopy(obj) {
+ return JSON.parse(JSON.stringify(obj))
+}
const state = {
- allSubscriptionsList: [],
- profileSubscriptions: {
- activeProfile: MAIN_PROFILE_ID,
- videoList: [],
- errorChannels: []
- }
+ subscriptionsCachePerChannel: {},
}
const getters = {
- getAllSubscriptionsList: () => {
- return state.allSubscriptionsList
+ getSubscriptionsCacheEntriesForOneChannel: (state) => (channelId) => {
+ return state.subscriptionsCachePerChannel[channelId]
},
- getProfileSubscriptions: () => {
- return state.profileSubscriptions
- }
}
const actions = {
- updateAllSubscriptionsList ({ commit }, subscriptions) {
- commit('setAllSubscriptionsList', subscriptions)
+ clearSubscriptionsCache: ({ commit }) => {
+ commit('clearSubscriptionsCachePerChannel')
+ },
+
+ updateSubscriptionsCacheForOneChannel: ({ commit }, payload) => {
+ commit('updateSubscriptionsCacheForOneChannel', payload)
},
- updateProfileSubscriptions ({ commit }, subscriptions) {
- commit('setProfileSubscriptions', subscriptions)
- }
}
const mutations = {
- setAllSubscriptionsList (state, allSubscriptionsList) {
- state.allSubscriptionsList = allSubscriptionsList
+ updateSubscriptionsCacheForOneChannel(state, { channelId, videos }) {
+ const existingObject = state.subscriptionsCachePerChannel[channelId]
+ const newObject = existingObject != null ? existingObject : deepCopy(defaultCacheEntryValueForForOneChannel)
+ if (videos != null) { newObject.videos = videos }
+ state.subscriptionsCachePerChannel[channelId] = newObject
+ },
+ clearSubscriptionsCachePerChannel(state) {
+ state.subscriptionsCachePerChannel = {}
},
- setProfileSubscriptions (state, profileSubscriptions) {
- state.profileSubscriptions = profileSubscriptions
- }
}
export default {
diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js
index aeeedd7a06ede..1f4edda927453 100644
--- a/src/renderer/store/modules/utils.js
+++ b/src/renderer/store/modules/utils.js
@@ -27,6 +27,7 @@ const state = {
movies: null
},
cachedPlaylist: null,
+ deArrowCache: {},
showProgressBar: false,
progressBarPercentage: 0,
regionNames: [],
@@ -57,6 +58,10 @@ const getters = {
return state.sessionSearchHistory
},
+ getDeArrowCache: (state) => (videoId) => {
+ return state.deArrowCache[videoId]
+ },
+
getPopularCache () {
return state.popularCache
},
@@ -245,15 +250,12 @@ const actions = {
// Exclude __dirname from path if not in electron
const fileLocation = `${process.env.IS_ELECTRON ? process.env.NODE_ENV === 'development' ? '.' : __dirname : ''}/static/geolocations/`
if (process.env.IS_ELECTRON) {
- localePathExists = await pathExists(`${fileLocation}${locale}`)
+ localePathExists = await pathExists(`${fileLocation}${locale}.json`)
} else {
localePathExists = process.env.GEOLOCATION_NAMES.includes(locale)
}
- const pathName = `${fileLocation}${localePathExists ? locale : 'en-US'}/countries.json`
- const fileData = process.env.IS_ELECTRON ? JSON.parse(await fs.readFile(pathName)) : await (await fetch(createWebURL(pathName))).json()
-
- const countries = fileData.map((entry) => { return { id: entry.id, name: entry.name, code: entry.alpha2 } })
- countries.sort((a, b) => { return a.id - b.id })
+ const pathName = `${fileLocation}${localePathExists ? locale : 'en-US'}.json`
+ const countries = process.env.IS_ELECTRON ? JSON.parse(await fs.readFile(pathName)) : await (await fetch(createWebURL(pathName))).json()
const regionNames = countries.map((entry) => { return entry.name })
const regionValues = countries.map((entry) => { return entry.code })
@@ -568,21 +570,14 @@ const actions = {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'looping playlists')
}
}
- if (cmdArgs.supportsYtdlProtocol) {
- args.push(`${cmdArgs.playlistUrl}ytdl://${payload.playlistId}`)
- } else {
- args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`)
- }
+
+ args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`)
} else {
if (payload.playlistId != null && payload.playlistId !== '' && !ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'opening playlists')
}
if (payload.videoId != null) {
- if (cmdArgs.supportsYtdlProtocol) {
- args.push(`${cmdArgs.videoUrl}ytdl://${payload.videoId}`)
- } else {
- args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`)
- }
+ args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`)
}
}
@@ -614,6 +609,18 @@ const mutations = {
state.sessionSearchHistory = history
},
+ setDeArrowCache (state, cache) {
+ state.deArrowCache = cache
+ },
+
+ addVideoToDeArrowCache (state, payload) {
+ const sameVideo = state.deArrowCache[payload.videoId]
+
+ if (!sameVideo) {
+ state.deArrowCache[payload.videoId] = payload
+ }
+ },
+
addToSessionSearchHistory (state, payload) {
const sameSearch = state.sessionSearchHistory.findIndex((search) => {
return search.query === payload.query && searchFiltersMatch(payload.searchSettings, search.searchSettings)
diff --git a/src/renderer/views/Channel/Channel.js b/src/renderer/views/Channel/Channel.js
index 2a8dc4c6326b6..74f957bdb83e0 100644
--- a/src/renderer/views/Channel/Channel.js
+++ b/src/renderer/views/Channel/Channel.js
@@ -92,14 +92,21 @@ export default defineComponent({
errorMessage: '',
showSearchBar: true,
showShareMenu: true,
- videoShortLiveSelectValues: [
+ videoLiveSelectValues: [
+ 'newest',
+ 'popular',
+ 'oldest'
+ ],
+ shortSelectValues: [
'newest',
'popular'
],
playlistSelectValues: [
'newest',
'last'
- ]
+ ],
+
+ autoRefreshOnSortByChangeEnabled: false,
}
},
computed: {
@@ -137,7 +144,15 @@ export default defineComponent({
return this.subscriptionInfo !== null
},
- videoShortLiveSelectNames: function () {
+ videoLiveSelectNames: function () {
+ return [
+ this.$t('Channel.Videos.Sort Types.Newest'),
+ this.$t('Channel.Videos.Sort Types.Most Popular'),
+ this.$t('Channel.Videos.Sort Types.Oldest')
+ ]
+ },
+
+ shortSelectNames: function () {
return [
this.$t('Channel.Videos.Sort Types.Newest'),
this.$t('Channel.Videos.Sort Types.Most Popular')
@@ -244,8 +259,10 @@ export default defineComponent({
return
}
+ // Disable auto refresh on sort value change during state reset
+ this.autoRefreshOnSortByChangeEnabled = false
+
this.id = this.$route.params.id
- let currentTab = this.$route.params.currentTab ?? 'videos'
this.searchPage = 2
this.relatedChannels = []
this.latestVideos = []
@@ -273,23 +290,7 @@ export default defineComponent({
this.showLiveSortBy = true
this.showPlaylistSortBy = true
- if (this.hideChannelShorts && currentTab === 'shorts') {
- currentTab = 'videos'
- }
-
- if (this.hideLiveStreams && currentTab === 'live') {
- currentTab = 'videos'
- }
-
- if (this.hideChannelPlaylists && currentTab === 'playlists') {
- currentTab = 'videos'
- }
-
- if (this.hideChannelCommunity && currentTab === 'community') {
- currentTab = 'videos'
- }
-
- this.currentTab = currentTab
+ this.currentTab = this.currentOrFirstTab(this.$route.params.currentTab)
if (this.id === '@@@') {
this.showShareMenu = false
@@ -300,14 +301,20 @@ export default defineComponent({
this.showShareMenu = true
this.errorMessage = ''
+ // Re-enable auto refresh on sort value change AFTER update done
if (!(process.env.IS_ELECTRON || process.env.IS_CORDOVA) || this.backendPreference === 'invidious') {
this.getChannelInfoInvidious()
+ this.autoRefreshOnSortByChangeEnabled = true
} else {
- this.getChannelLocal()
+ this.getChannelLocal().finally(() => {
+ this.autoRefreshOnSortByChangeEnabled = true
+ })
}
},
videoSortBy () {
+ if (!this.autoRefreshOnSortByChangeEnabled) { return }
+
this.isElementListLoading = true
this.latestVideos = []
switch (this.apiUsed) {
@@ -323,6 +330,8 @@ export default defineComponent({
},
shortSortBy() {
+ if (!this.autoRefreshOnSortByChangeEnabled) { return }
+
this.isElementListLoading = true
this.latestShorts = []
switch (this.apiUsed) {
@@ -338,6 +347,8 @@ export default defineComponent({
},
liveSortBy () {
+ if (!this.autoRefreshOnSortByChangeEnabled) { return }
+
this.isElementListLoading = true
this.latestLive = []
switch (this.apiUsed) {
@@ -353,6 +364,8 @@ export default defineComponent({
},
playlistSortBy () {
+ if (!this.autoRefreshOnSortByChangeEnabled) { return }
+
this.isElementListLoading = true
this.latestPlaylists = []
this.playlistContinuationData = null
@@ -378,25 +391,7 @@ export default defineComponent({
this.id = this.$route.params.id
- let currentTab = this.$route.params.currentTab ?? 'videos'
-
- if (this.hideChannelShorts && currentTab === 'shorts') {
- currentTab = 'videos'
- }
-
- if (this.hideLiveStreams && currentTab === 'live') {
- currentTab = 'videos'
- }
-
- if (this.hideChannelPlaylists && currentTab === 'playlists') {
- currentTab = 'videos'
- }
-
- if (this.hideChannelCommunity && currentTab === 'community') {
- currentTab = 'videos'
- }
-
- this.currentTab = currentTab
+ this.currentTab = this.currentOrFirstTab(this.$route.params.currentTab)
if (this.id === '@@@') {
this.showShareMenu = false
@@ -404,10 +399,14 @@ export default defineComponent({
return
}
+ // Enable auto refresh on sort value change AFTER initial update done
if (!(process.env.IS_ELECTRON || process.env.IS_CORDOVA) || this.backendPreference === 'invidious') {
this.getChannelInfoInvidious()
+ this.autoRefreshOnSortByChangeEnabled = true
} else {
- this.getChannelLocal()
+ this.getChannelLocal().finally(() => {
+ this.autoRefreshOnSortByChangeEnabled = true
+ })
}
},
methods: {
@@ -436,6 +435,14 @@ export default defineComponent({
}
},
+ currentOrFirstTab: function (currentTab) {
+ if (this.tabInfoValues.includes(currentTab)) {
+ return currentTab
+ }
+
+ return this.tabInfoValues[0]
+ },
+
getChannelLocal: async function () {
this.apiUsed = 'local'
this.isLoading = true
@@ -559,7 +566,7 @@ export default defineComponent({
document.title = `${channelName} - ${packageDetails.productName}`
- if (!this.hideChannelSubscriptions && subscriberText) {
+ if (subscriberText) {
const subCount = parseLocalSubscriberCount(subscriberText)
if (isNaN(subCount)) {
@@ -687,7 +694,7 @@ export default defineComponent({
this.showVideoSortBy = videosTab.filters.length > 1
if (this.showVideoSortBy && this.videoSortBy !== 'newest') {
- const index = this.videoShortLiveSelectValues.indexOf(this.videoSortBy)
+ const index = this.videoLiveSelectValues.indexOf(this.videoSortBy)
videosTab = await videosTab.applyFilter(videosTab.filters[index])
}
@@ -745,7 +752,7 @@ export default defineComponent({
this.showShortSortBy = shortsTab.filters.length > 1
if (this.showShortSortBy && this.shortSortBy !== 'newest') {
- const index = this.videoShortLiveSelectValues.indexOf(this.shortSortBy)
+ const index = this.shortSelectValues.indexOf(this.shortSortBy)
shortsTab = await shortsTab.applyFilter(shortsTab.filters[index])
}
@@ -803,7 +810,7 @@ export default defineComponent({
this.showLiveSortBy = liveTab.filters.length > 1
if (this.showLiveSortBy && this.liveSortBy !== 'newest') {
- const index = this.videoShortLiveSelectValues.indexOf(this.liveSortBy)
+ const index = this.videoLiveSelectValues.indexOf(this.liveSortBy)
liveTab = await liveTab.applyFilter(liveTab.filters[index])
}
@@ -864,11 +871,7 @@ export default defineComponent({
document.title = `${this.channelName} - ${packageDetails.productName}`
this.id = channelId
this.isFamilyFriendly = response.isFamilyFriendly
- if (this.hideChannelSubscriptions) {
- this.subCount = null
- } else {
- this.subCount = response.subCount
- }
+ this.subCount = response.subCount
const thumbnail = response.authorThumbnails[3].url
this.thumbnailUrl = youtubeImageUrlToInvidious(thumbnail, this.currentInvidiousInstance)
this.updateSubscriptionDetails({ channelThumbnailUrl: thumbnail, channelName: channelName, channelId: channelId })
@@ -1399,21 +1402,13 @@ export default defineComponent({
: 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 })
},
diff --git a/src/renderer/views/Channel/Channel.vue b/src/renderer/views/Channel/Channel.vue
index 23b8b3a81d8f8..9dc77fb3b6c6c 100644
--- a/src/renderer/views/Channel/Channel.vue
+++ b/src/renderer/views/Channel/Channel.vue
@@ -49,7 +49,7 @@
{{ formattedSubCount }}
@@ -90,9 +90,9 @@
class="tab"
:class="(currentTab==='videos')?'selectedTab':''"
role="tab"
- aria-selected="true"
+ :aria-selected="String(currentTab === 'videos')"
aria-controls="videoPanel"
- tabindex="0"
+ :tabindex="(currentTab === 'videos' || currentTab === 'search') ? 0 : -1"
@click="changeTab('videos')"
@keydown.left.right.enter.space="changeTab('videos', $event)"
>
@@ -104,9 +104,9 @@
class="tab"
:class="(currentTab==='shorts')?'selectedTab':''"
role="tab"
- aria-selected="true"
+ :aria-selected="String(currentTab === 'shorts')"
aria-controls="shortPanel"
- tabindex="0"
+ :tabindex="currentTab === 'shorts' ? 0 : -1"
@click="changeTab('shorts')"
@keydown.left.right.enter.space="changeTab('shorts', $event)"
>
@@ -118,9 +118,9 @@
class="tab"
:class="(currentTab==='live')?'selectedTab':''"
role="tab"
- aria-selected="true"
+ :aria-selected="String(currentTab === 'live')"
aria-controls="livePanel"
- tabindex="0"
+ :tabindex="currentTab === 'live' ? 0 : -1"
@click="changeTab('live')"
@keydown.left.right.enter.space="changeTab('live', $event)"
>
@@ -131,9 +131,9 @@
id="playlistsTab"
class="tab"
role="tab"
- aria-selected="false"
+ :aria-selected="String(currentTab === 'playlists')"
aria-controls="playlistPanel"
- tabindex="-1"
+ :tabindex="currentTab === 'playlists' ? 0 : -1"
:class="(currentTab==='playlists')?'selectedTab':''"
@click="changeTab('playlists')"
@keydown.left.right.enter.space="changeTab('playlists', $event)"
@@ -145,9 +145,9 @@
id="communityTab"
class="tab"
role="tab"
- aria-selected="false"
+ :aria-selected="String(currentTab === 'community')"
aria-controls="communityPanel"
- tabindex="-1"
+ :tabindex="currentTab === 'community' ? 0 : -1"
:class="(currentTab==='community')?'selectedTab':''"
@click="changeTab('community')"
@keydown.left.right.enter.space="changeTab('community', $event)"
@@ -158,9 +158,9 @@
id="aboutTab"
class="tab"
role="tab"
- aria-selected="false"
+ :aria-selected="String(currentTab === 'about')"
aria-controls="aboutPanel"
- tabindex="-1"
+ :tabindex="currentTab === 'about' ? 0 : -1"
:class="(currentTab==='about')?'selectedTab':''"
@click="changeTab('about')"
@keydown.left.right.enter.space="changeTab('about', $event)"
@@ -197,9 +197,9 @@
v-if="showVideoSortBy"
v-show="currentTab === 'videos' && latestVideos.length > 0"
class="sortSelect"
- :value="videoShortLiveSelectValues[0]"
- :select-names="videoShortLiveSelectNames"
- :select-values="videoShortLiveSelectValues"
+ :value="videoLiveSelectValues[0]"
+ :select-names="videoLiveSelectNames"
+ :select-values="videoLiveSelectValues"
:placeholder="$t('Search Filters.Sort By.Sort By')"
@change="videoSortBy = $event"
/>
@@ -207,9 +207,9 @@
v-if="!hideChannelShorts && showShortSortBy"
v-show="currentTab === 'shorts' && latestShorts.length > 0"
class="sortSelect"
- :value="videoShortLiveSelectValues[0]"
- :select-names="videoShortLiveSelectNames"
- :select-values="videoShortLiveSelectValues"
+ :value="shortSelectValues[0]"
+ :select-names="shortSelectNames"
+ :select-values="shortSelectValues"
:placeholder="$t('Search Filters.Sort By.Sort By')"
@change="shortSortBy = $event"
/>
@@ -217,9 +217,9 @@
v-if="!hideLiveStreams && showLiveSortBy"
v-show="currentTab === 'live' && latestLive.length > 0"
class="sortSelect"
- :value="videoShortLiveSelectValues[0]"
- :select-names="videoShortLiveSelectNames"
- :select-values="videoShortLiveSelectValues"
+ :value="videoLiveSelectValues[0]"
+ :select-names="videoLiveSelectNames"
+ :select-values="videoLiveSelectValues"
:placeholder="$t('Search Filters.Sort By.Sort By')"
@change="liveSortBy = $event"
/>
@@ -244,6 +244,7 @@
v-show="currentTab === 'videos'"
id="videoPanel"
:data="latestVideos"
+ :use-channels-hidden-preference="false"
role="tabpanel"
aria-labelledby="videosTab"
/>
@@ -258,6 +259,7 @@
v-if="!hideChannelShorts && currentTab === 'shorts'"
id="shortPanel"
:data="latestShorts"
+ :use-channels-hidden-preference="false"
role="tabpanel"
aria-labelledby="shortsTab"
/>
@@ -273,6 +275,7 @@
v-show="currentTab === 'live'"
id="livePanel"
:data="latestLive"
+ :use-channels-hidden-preference="false"
role="tabpanel"
aria-labelledby="liveTab"
/>
@@ -287,6 +290,7 @@
v-if="!hideChannelPlaylists && currentTab === 'playlists'"
id="playlistPanel"
:data="latestPlaylists"
+ :use-channels-hidden-preference="false"
role="tabpanel"
aria-labelledby="playlistsTab"
/>
@@ -301,6 +305,7 @@
v-if="!hideChannelCommunity && currentTab === 'community'"
id="communityPanel"
:data="latestCommunityPosts"
+ :use-channels-hidden-preference="false"
role="tabpanel"
aria-labelledby="communityTab"
display="list"
@@ -315,6 +320,7 @@
{
+ const cacheEntry = this.$store.getters.getSubscriptionsCacheEntriesForOneChannel(channel.id)
+ if (cacheEntry == null) { return }
- profileSubscriptions: function () {
- return this.$store.getters.getProfileSubscriptions
+ entries.push(cacheEntry)
+ })
+ return entries
},
+ videoCacheForAllActiveProfileChannelsPresent() {
+ if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false }
+ if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false }
- allSubscriptionsList: function () {
- return this.$store.getters.getAllSubscriptionsList
+ return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => {
+ return cacheEntry.videos != null
+ })
},
historyCache: function () {
@@ -92,12 +105,13 @@ export default defineComponent({
fetchSubscriptionsAutomatically: function() {
return this.$store.getters.getFetchSubscriptionsAutomatically
- }
+ },
},
watch: {
activeProfile: async function (_) {
- this.getProfileSubscriptions()
- }
+ this.isLoading = true
+ this.loadVideosFromCacheSometimes()
+ },
},
mounted: async function () {
document.addEventListener('keydown', this.keyboardShortcutHandler)
@@ -108,66 +122,63 @@ export default defineComponent({
this.dataLimit = dataLimit
}
- if (this.profileSubscriptions.videoList.length !== 0) {
- if (this.profileSubscriptions.activeProfile === this.activeProfile._id) {
- const subscriptionList = JSON.parse(JSON.stringify(this.profileSubscriptions))
- if (this.hideWatchedSubs) {
- this.videoList = await Promise.all(subscriptionList.videoList.filter((video) => {
- const historyIndex = this.historyCache.findIndex((x) => {
- return x.videoId === video.videoId
- })
-
- return historyIndex === -1
- }))
- } else {
- this.videoList = subscriptionList.videoList
- this.errorChannels = subscriptionList.errorChannels
- }
- } else {
- this.getProfileSubscriptions()
- }
-
- this.isLoading = false
- } else if (this.fetchSubscriptionsAutomatically) {
- setTimeout(async () => {
- this.getSubscriptions()
- }, 300)
- } else {
- this.isLoading = false
- }
+ this.loadVideosFromCacheSometimes()
},
beforeDestroy: function () {
document.removeEventListener('keydown', this.keyboardShortcutHandler)
},
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.getSubscriptionsCacheEntriesForOneChannel(channel.id)
+
+ videoList.push(...channelCacheEntry.videos)
+ })
+ this.updateVideoListAfterProcessing(videoList)
+ this.isLoading = false
+ },
+
goToChannel: function (id) {
this.$router.push({ path: `/channel/${id}` })
},
- getSubscriptions: function () {
+ 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 (this.activeSubscriptionList.length >= 125 && !useRss) {
+ 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.isLoading = true
this.updateShowProgressBar(true)
this.setProgressBarPercentage(0)
this.attemptedFetch = true
- let videoList = []
- let channelCount = 0
this.errorChannels = []
- this.activeSubscriptionList.forEach(async (channel) => {
+ const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => {
let videos = []
if ((!process.env.IS_ELECTRON && !process.env.IS_CORDOVA) || this.backendPreference === 'invidious') {
if (useRss) {
@@ -183,86 +194,71 @@ export default defineComponent({
}
}
- videoList = videoList.concat(videos)
channelCount++
- const percentageComplete = (channelCount / this.activeSubscriptionList.length) * 100
+ const percentageComplete = (channelCount / channelsToLoadFromRemote.length) * 100
this.setProgressBarPercentage(percentageComplete)
+ this.updateSubscriptionsCacheForOneChannel({
+ channelId: channel.id,
+ videos: videos,
+ })
+ return videos
+ }))).flatMap((o) => o)
+ videoList.push(...videoListFromRemote)
- if (channelCount === this.activeSubscriptionList.length) {
- videoList = await Promise.all(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.durationText !== 'PREMIERE'
- })
- }
- const profileSubscriptions = {
- activeProfile: this.activeProfile._id,
- videoList: videoList,
- errorChannels: this.errorChannels
- }
+ this.updateVideoListAfterProcessing(videoList)
+ this.isLoading = false
+ this.updateShowProgressBar(false)
+ },
- this.videoList = await Promise.all(videoList.filter((video) => {
- if (this.hideWatchedSubs) {
- const historyIndex = this.historyCache.findIndex((x) => {
- return x.videoId === video.videoId
- })
+ 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
+ )
+ })
+ }
- return historyIndex === -1
- } else {
- return true
- }
- }))
- this.updateProfileSubscriptions(profileSubscriptions)
- this.isLoading = false
- this.updateShowProgressBar(false)
+ this.videoList = videoList.filter((video) => {
+ if (this.hideWatchedSubs) {
+ const historyIndex = this.historyCache.findIndex((x) => {
+ return x.videoId === video.videoId
+ })
- if (this.activeProfile === MAIN_PROFILE_ID) {
- this.updateAllSubscriptionsList(profileSubscriptions.videoList)
- }
+ return historyIndex === -1
+ } else {
+ return true
}
})
},
- getProfileSubscriptions: async function () {
- if (this.allSubscriptionsList.length !== 0) {
- this.isLoading = true
- this.videoList = await Promise.all(this.allSubscriptionsList.filter((video) => {
- const channelIndex = this.activeSubscriptionList.findIndex((x) => {
- return x.id === video.authorId
- })
-
- if (this.hideWatchedSubs) {
- const historyIndex = this.historyCache.findIndex((x) => {
- return x.videoId === video.videoId
- })
-
- return channelIndex !== -1 && historyIndex === -1
- } else {
- return channelIndex !== -1
- }
- }))
- this.isLoading = false
- } else if (this.fetchSubscriptionsAutomatically) {
- this.getSubscriptions()
- } else if (this.activeProfile._id === this.profileSubscriptions.activeProfile) {
- this.videoList = this.profileSubscriptions.videoList
+ 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
}
},
@@ -477,16 +473,23 @@ export default defineComponent({
sessionStorage.setItem('subscriptionLimit', this.dataLimit)
},
- // This function should always be at the bottom of this file
+ /**
+ * 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.getSubscriptions()
+ this.loadVideosForSubscriptionsFromRemote()
}
break
}
@@ -494,8 +497,7 @@ export default defineComponent({
...mapActions([
'updateShowProgressBar',
- 'updateProfileSubscriptions',
- 'updateAllSubscriptionsList'
+ 'updateSubscriptionsCacheForOneChannel',
]),
...mapMutations([
diff --git a/src/renderer/views/Subscriptions/Subscriptions.vue b/src/renderer/views/Subscriptions/Subscriptions.vue
index 23529adeb37fd..1cbf8863209b1 100644
--- a/src/renderer/views/Subscriptions/Subscriptions.vue
+++ b/src/renderer/views/Subscriptions/Subscriptions.vue
@@ -50,6 +50,7 @@