diff --git a/.github/workflows/calibreapp-image-actions.yml b/.github/workflows/calibreapp-image-actions.yml index d336cad2328ab..1315f1fe5915b 100644 --- a/.github/workflows/calibreapp-image-actions.yml +++ b/.github/workflows/calibreapp-image-actions.yml @@ -20,7 +20,7 @@ jobs: compressOnly: true - name: Create New Pull Request If Needed if: steps.calibre.outputs.markdown != '' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v7 with: title: Compressed Images Nightly branch-suffix: timestamp diff --git a/_scripts/ProcessLocalesPlugin.js b/_scripts/ProcessLocalesPlugin.js index e3b2dd7a88cf9..51ff870c698f3 100644 --- a/_scripts/ProcessLocalesPlugin.js +++ b/_scripts/ProcessLocalesPlugin.js @@ -40,17 +40,17 @@ class ProcessLocalesPlugin { /** @type {(updatedLocales: [string, string][]) => void|null} */ this.notifyLocaleChange = null - if (this.hotReload) { - this.hotReloadScript = readFileSync(`${__dirname}/_hotReloadLocalesScript.js`, 'utf-8') - } - this.loadLocales() } /** @param {import('webpack').Compiler} compiler */ apply(compiler) { const { CachedSource, RawSource } = compiler.webpack.sources; - const { Compilation } = compiler.webpack + const { Compilation, DefinePlugin } = compiler.webpack + + new DefinePlugin({ + 'process.env.HOT_RELOAD_LOCALES': this.hotReload + }).apply(compiler) compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { const IS_DEV_SERVER = !!compiler.watching @@ -136,19 +136,6 @@ class ProcessLocalesPlugin { compilation.fileDependencies.addAll(this.filePaths) } }) - - compiler.hooks.emit.tap(PLUGIN_NAME, (compilation) => { - if (this.hotReload) { - // Find generated JavaScript output file (e.g. renderer.js or web.js) - // and inject the code snippet that listens for locale updates and replaces vue-i18n's locales - - /** @type {string} */ - const filename = [...[...compilation.chunks][0].files] - .find(file => file.endsWith('.js')) - - compilation.assets[filename]._source._children.push(`\n${this.hotReloadScript}`) - } - }) } loadLocales() { diff --git a/_scripts/_hotReloadLocalesScript.js b/_scripts/_hotReloadLocalesScript.js deleted file mode 100644 index 8cbfb6ef8812d..0000000000000 --- a/_scripts/_hotReloadLocalesScript.js +++ /dev/null @@ -1,18 +0,0 @@ -const websocket = new WebSocket('ws://localhost:9080/ws') - -websocket.onmessage = (event) => { - const message = JSON.parse(event.data) - - if (message.type === 'freetube-locale-update') { - const i18n = document.getElementById('app').__vue__.$i18n - - for (const [locale, data] of message.data) { - // Only update locale data if it was already loaded - if (i18n.availableLocales.includes(locale)) { - const localeData = JSON.parse(data) - - i18n.setLocaleMessage(locale, localeData) - } - } - } -} diff --git a/package.json b/package.json index cf93e6ce8a487..a4731a535b0e7 100644 --- a/package.json +++ b/package.json @@ -63,12 +63,12 @@ "autolinker": "^4.0.0", "electron-context-menu": "^4.0.4", "lodash.debounce": "^4.0.8", - "marked": "^14.1.0", + "marked": "^14.1.2", "path-browserify": "^1.0.1", "portal-vue": "^2.1.7", "process": "^0.11.10", - "shaka-player": "^4.10.12", - "swiper": "^11.1.12", + "shaka-player": "^4.11.1", + "swiper": "^11.1.14", "vue": "^2.7.16", "vue-i18n": "^8.28.2", "vue-observe-visibility": "^1.0.0", @@ -87,18 +87,18 @@ "copy-webpack-plugin": "^12.0.2", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", - "electron": "^32.0.1", - "electron-builder": "^24.13.3", + "electron": "^32.1.0", + "electron-builder": "^25.0.5", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-config-standard": "^17.1.0", - "eslint-plugin-import": "^2.29.1", + "eslint-plugin-import": "^2.30.0", "eslint-plugin-jsonc": "^2.16.0", "eslint-plugin-n": "^17.10.2", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-promise": "^7.1.0", "eslint-plugin-unicorn": "^55.0.0", - "eslint-plugin-vue": "^9.27.0", + "eslint-plugin-vue": "^9.28.0", "eslint-plugin-vuejs-accessibility": "^2.4.1", "eslint-plugin-yml": "^1.14.0", "html-webpack-plugin": "^5.6.0", @@ -107,14 +107,14 @@ "lefthook": "^1.7.15", "mini-css-extract-plugin": "^2.9.1", "npm-run-all2": "^6.2.2", - "postcss": "^8.4.44", + "postcss": "^8.4.47", "postcss-scss": "^4.0.9", "prettier": "^2.8.8", "rimraf": "^6.0.1", - "sass": "^1.77.8", + "sass": "^1.78.0", "sass-loader": "^16.0.1", "stylelint": "^16.9.0", - "stylelint-config-sass-guidelines": "^12.0.0", + "stylelint-config-sass-guidelines": "^12.1.0", "stylelint-config-standard": "^36.0.1", "stylelint-high-performance-animation": "^1.10.0", "stylelint-use-logical-spec": "^5.0.1", @@ -124,7 +124,7 @@ "vue-loader": "^15.10.0", "webpack": "^5.94.0", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^5.0.4", + "webpack-dev-server": "^5.1.0", "yaml-eslint-parser": "^1.2.3" } } diff --git a/src/constants.js b/src/constants.js index 490c8d67771a5..a45a71598d527 100644 --- a/src/constants.js +++ b/src/constants.js @@ -51,6 +51,7 @@ const DBActions = { }, HISTORY: { + OVERWRITE: 'db-action-history-overwrite', UPDATE_WATCH_PROGRESS: 'db-action-history-update-watch-progress', UPDATE_PLAYLIST: 'db-action-history-update-playlist', }, @@ -78,6 +79,7 @@ const SyncEvents = { }, HISTORY: { + OVERWRITE: 'sync-history-overwrite', UPDATE_WATCH_PROGRESS: 'sync-history-update-watch-progress', UPDATE_PLAYLIST: 'sync-history-update-playlist', }, diff --git a/src/datastores/handlers/base.js b/src/datastores/handlers/base.js index 4a7db5cbb8c3d..b3ec944b319ed 100644 --- a/src/datastores/handlers/base.js +++ b/src/datastores/handlers/base.js @@ -56,6 +56,12 @@ class History { return db.history.updateAsync({ videoId: record.videoId }, record, { upsert: true }) } + static async overwrite(records) { + await db.history.removeAsync({}, { multi: true }) + + await db.history.insertAsync(records) + } + static updateWatchProgress(videoId, watchProgress) { return db.history.updateAsync({ videoId }, { $set: { watchProgress } }, { upsert: true }) } diff --git a/src/datastores/handlers/electron.js b/src/datastores/handlers/electron.js index cc0b473a3b990..41d4872e45d8e 100644 --- a/src/datastores/handlers/electron.js +++ b/src/datastores/handlers/electron.js @@ -32,6 +32,13 @@ class History { ) } + static overwrite(records) { + return ipcRenderer.invoke( + IpcChannels.DB_HISTORY, + { action: DBActions.HISTORY.OVERWRITE, data: records } + ) + } + static updateWatchProgress(videoId, watchProgress) { return ipcRenderer.invoke( IpcChannels.DB_HISTORY, diff --git a/src/datastores/handlers/web.js b/src/datastores/handlers/web.js index 93ffa3d68c8ff..0fa321bcb4beb 100644 --- a/src/datastores/handlers/web.js +++ b/src/datastores/handlers/web.js @@ -29,6 +29,10 @@ class History { return baseHandlers.history.upsert(record) } + static overwrite(records) { + return baseHandlers.history.overwrite(records) + } + static updateWatchProgress(videoId, watchProgress) { return baseHandlers.history.updateWatchProgress(videoId, watchProgress) } diff --git a/src/main/index.js b/src/main/index.js index 4cd8cd680a5c0..0848a677c2f1d 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1075,6 +1075,15 @@ function runApp() { ) return null + case DBActions.HISTORY.OVERWRITE: + await baseHandlers.history.overwrite(data) + syncOtherWindows( + IpcChannels.SYNC_HISTORY, + event, + { event: SyncEvents.HISTORY.OVERWRITE, data } + ) + return null + case DBActions.HISTORY.UPDATE_WATCH_PROGRESS: await baseHandlers.history.updateWatchProgress(data.videoId, data.watchProgress) syncOtherWindows( diff --git a/src/renderer/App.js b/src/renderer/App.js index e2ecc75e4140b..03e46519504db 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -453,6 +453,17 @@ export default defineComponent({ break } + case 'post': { + const { postId, query } = result + + openInternalPath({ + path: `/post/${postId}`, + query, + doCreateNewWindow + }) + break + } + case 'channel': { const { channelId, subPath, url } = result diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 93d14723725e7..a0235a5854898 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -23,6 +23,7 @@ > diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index c528fcabd283e..7c950732ebb5e 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -50,6 +50,9 @@ export default defineComponent({ allPlaylists: function () { return this.$store.getters.getAllPlaylists }, + historyCacheById: function () { + return this.$store.getters.getHistoryCacheById + }, historyCacheSorted: function () { return this.$store.getters.getHistoryCacheSorted }, @@ -616,7 +619,7 @@ export default defineComponent({ }) }, - importFreeTubeHistory(textDecode) { + async importFreeTubeHistory(textDecode) { textDecode.pop() const requiredKeys = [ @@ -630,7 +633,6 @@ export default defineComponent({ 'title', 'type', 'videoId', - 'viewCount', 'watchProgress', ] @@ -638,12 +640,17 @@ export default defineComponent({ // `_id` absent if marked as watched manually '_id', 'lastViewedPlaylistId', + 'lastViewedPlaylistItemId', + 'lastViewedPlaylistType', + 'viewCount', ] const ignoredKeys = [ 'paid', ] + const historyItems = new Map(Object.entries(this.historyCacheById)) + textDecode.forEach((history) => { const historyData = JSON.parse(history) // We would technically already be done by the time the data is parsed, @@ -667,14 +674,16 @@ export default defineComponent({ showToast(this.$t('Settings.Data Settings.History object has insufficient data, skipping item')) console.error('Missing Keys: ', missingKeys, historyData) } else { - this.updateHistory(historyObject) + historyItems.set(historyObject.videoId, historyObject) } }) + await this.overwriteHistory(historyItems) + showToast(this.$t('Settings.Data Settings.All watched history has been successfully imported')) }, - importYouTubeHistory(historyData) { + async importYouTubeHistory(historyData) { const filterPredicate = item => item.products.includes('YouTube') && item.titleUrl != null && // removed video doesnt contain url... @@ -722,6 +731,8 @@ export default defineComponent({ 'activityControls', ].concat(Object.keys(keyMapping)) + const historyItems = new Map(Object.entries(this.historyCacheById)) + filteredHistoryData.forEach(element => { const historyObject = {} @@ -750,10 +761,12 @@ export default defineComponent({ historyObject.watchProgress = 1 historyObject.isLive = false - this.updateHistory(historyObject) + historyItems.set(historyObject.videoId, historyObject) } }) + await this.overwriteHistory(historyItems) + showToast(this.$t('Settings.Data Settings.All watched history has been successfully imported')) }, @@ -1069,10 +1082,10 @@ export default defineComponent({ ...mapActions([ 'updateProfile', 'updateShowProgressBar', - 'updateHistory', 'addPlaylist', 'addVideo', 'updatePlaylist', + 'overwriteHistory' ]), ...mapMutations([ diff --git a/src/renderer/components/ft-community-post/ft-community-post.js b/src/renderer/components/ft-community-post/ft-community-post.js index 51fa39bb1afed..cbf30691f14ff 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.js +++ b/src/renderer/components/ft-community-post/ft-community-post.js @@ -7,7 +7,7 @@ import autolinker from 'autolinker' import { A11y, Navigation, Pagination } from 'swiper/modules' -import { createWebURL, deepCopy, toLocalePublicationString } from '../../helpers/utils' +import { createWebURL, deepCopy, formatNumber, toLocalePublicationString } from '../../helpers/utils' import { youtubeImageUrlToInvidious } from '../../helpers/api/invidious' export default defineComponent({ @@ -29,7 +29,11 @@ export default defineComponent({ hideForbiddenTitles: { type: Boolean, default: true - } + }, + singlePost: { + type: Boolean, + default: false + }, }, data: function () { return { @@ -37,9 +41,11 @@ export default defineComponent({ postId: '', authorThumbnails: null, publishedText: '', - voteCount: '', + voteCount: 0, + formattedVoteCount: '', postContent: '', - commentCount: '', + commentCount: null, + formattedCommentCount: '', author: '', authorId: '', } @@ -56,6 +62,16 @@ export default defineComponent({ hideVideo() { return this.forbiddenTitles.some((text) => this.data.postContent.content.title?.toLowerCase().includes(text.toLowerCase())) + }, + + backendPreference: function () { + return this.$store.getters.getBackendPreference + }, + backendFallback: function () { + return this.$store.getters.getBackendFallback + }, + isInvidiousAllowed: function() { + return this.backendPreference === 'invidious' || this.backendFallback } }, created: function () { @@ -127,7 +143,9 @@ export default defineComponent({ isRSS: this.data.isRSS }) this.voteCount = this.data.voteCount + this.formattedVoteCount = formatNumber(this.voteCount) this.commentCount = this.data.commentCount + this.formattedCommentCount = formatNumber(this.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 diff --git a/src/renderer/components/ft-community-post/ft-community-post.scss b/src/renderer/components/ft-community-post/ft-community-post.scss index bb50bb5b4fb43..6f7b7ebce9e6a 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.scss +++ b/src/renderer/components/ft-community-post/ft-community-post.scss @@ -59,6 +59,12 @@ white-space: pre-wrap; } +.commentsLink { + color: var(--primary-text-color); + text-decoration: none; + font-weight: bold; +} + .bottomSection { color: var(--tertiary-text-color); display: block; diff --git a/src/renderer/components/ft-community-post/ft-community-post.vue b/src/renderer/components/ft-community-post/ft-community-post.vue index f65e379e47824..b7f864efde2da 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.vue +++ b/src/renderer/components/ft-community-post/ft-community-post.vue @@ -115,11 +115,42 @@
- {{ voteCount }} - + + + + + + + { + nextTick(() => { this.$refs.dropdown?.focus() }) } diff --git a/src/renderer/components/ft-input/ft-input.js b/src/renderer/components/ft-input/ft-input.js index 876e6d5bbcd56..4df5666757fc7 100644 --- a/src/renderer/components/ft-input/ft-input.js +++ b/src/renderer/components/ft-input/ft-input.js @@ -207,6 +207,7 @@ export default defineComponent({ case 'search': case 'channel': case 'hashtag': + case 'post': isYoutubeLink = true break diff --git a/src/renderer/components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.js b/src/renderer/components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.js index c1808e6a52ed9..0753f7ff1e608 100644 --- a/src/renderer/components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.js +++ b/src/renderer/components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.js @@ -289,7 +289,6 @@ export default defineComponent({ if (addedPlaylistIds.size === 1) { message = this.$tc('User Playlists.AddVideoPrompt.Toast.{videoCount} video(s) added to 1 playlist', this.toBeAddedToPlaylistVideoCount, { videoCount: this.toBeAddedToPlaylistVideoCount, - playlistCount: addedPlaylistIds.size, }) } else { message = this.$tc('User Playlists.AddVideoPrompt.Toast.{videoCount} video(s) added to {playlistCount} playlists', this.toBeAddedToPlaylistVideoCount, { diff --git a/src/renderer/components/ft-profile-selector/ft-profile-selector.js b/src/renderer/components/ft-profile-selector/ft-profile-selector.js index 3dc775de48391..3e8eeffeb8e93 100644 --- a/src/renderer/components/ft-profile-selector/ft-profile-selector.js +++ b/src/renderer/components/ft-profile-selector/ft-profile-selector.js @@ -1,4 +1,4 @@ -import { defineComponent } from 'vue' +import { defineComponent, nextTick } from 'vue' import { mapActions } from 'vuex' import FtCard from '../../components/ft-card/ft-card.vue' @@ -53,7 +53,7 @@ export default defineComponent({ if (this.profileListShown) { // wait until the profile list is visible // then focus it so we can hide it automatically when it loses focus - setTimeout(() => { + nextTick(() => { this.$refs.profileList?.$el?.focus() }) } diff --git a/src/renderer/components/ft-select/ft-select.js b/src/renderer/components/ft-select/ft-select.js index 60cdebcaf7cac..80cd726b73a25 100644 --- a/src/renderer/components/ft-select/ft-select.js +++ b/src/renderer/components/ft-select/ft-select.js @@ -47,6 +47,10 @@ export default defineComponent({ iconColor: { type: String, default: null + }, + isLocaleSelector: { + type: Boolean, + default: false } }, emits: ['change'], diff --git a/src/renderer/components/ft-select/ft-select.vue b/src/renderer/components/ft-select/ft-select.vue index 8b6acb20b2316..77f466625b8e3 100644 --- a/src/renderer/components/ft-select/ft-select.vue +++ b/src/renderer/components/ft-select/ft-select.vue @@ -15,6 +15,7 @@ v-for="(name, index) in selectNames" :key="index" :value="selectValues[index]" + :lang="isLocaleSelector && selectValues[index] !== 'system' ? selectValues[index].replace('_', '-') : null" > {{ name }} diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.css b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.css index 815f23d70f636..f6afe74816f02 100644 --- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.css +++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.css @@ -37,16 +37,6 @@ flex-direction: column-reverse; } -/* - With the legacy formats, shaka hands over the captions to the browser, - that causes issues like the captions being displayed under the player controls. - The browser positions captions based on the video element as it doesn't know about shaka's custom controls. - To solve that we use shaka's caption displayer (UITextDisplayer), so we need to hide the browser's ones - */ -.player::-webkit-media-text-track-container { - display: none; -} - .sixteenByNine { aspect-ratio: 16 / 9; } diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js index 563fb37da207e..3828574691ef1 100644 --- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js +++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js @@ -14,8 +14,8 @@ import { LegacyQualitySelection } from './player-components/LegacyQualitySelecti import { ScreenshotButton } from './player-components/ScreenshotButton' import { StatsButton } from './player-components/StatsButton' import { TheatreModeButton } from './player-components/TheatreModeButton' -import { shakaCueFromVTTCue } from '../../helpers/player/legacyFormatsVttCueParser' import { + findMostSimilarAudioBandwidth, getSponsorBlockSegments, logShakaError, qualityLabelToDimension, @@ -38,6 +38,7 @@ const HTTP_IN_HEX = 0x68747470 const USE_OVERFLOW_MENU_WIDTH_THRESHOLD = 600 const RequestType = shaka.net.NetworkingEngine.RequestType +const AdvancedRequestType = shaka.net.NetworkingEngine.AdvancedRequestType const TrackLabelFormat = shaka.ui.Overlay.TrackLabelFormat /** @type {Map} */ @@ -135,7 +136,6 @@ export default defineComponent({ const hasLoaded = ref(false) const hasMultipleAudioTracks = ref(false) - let hasMultipleAudioChannelCounts = false const isLive = ref(false) const useOverFlowMenu = ref(false) @@ -523,6 +523,17 @@ export default defineComponent({ // #region player config + const seekingIsPossible = computed(() => { + if (props.manifestMimeType !== 'application/x-mpegurl') { + return true + } + + const match = props.manifestSrc.match(/\/(?:manifest|playlist)_duration\/(\d+)\//) + + // Check how many seconds we are allowed to seek, 30 is too short, 3600 is an hour which is great + return match != null && parseInt(match[1] || '0') > 30 + }) + /** * @param {'dash'|'audio'|'legacy'} format * @param {boolean} useAutoQuality @@ -544,7 +555,8 @@ export default defineComponent({ segmentRelativeVttTiming: true, dash: { manifestPreprocessorTXml: manifestPreprocessorTXml - } + }, + availabilityWindowOverride: seekingIsPossible.value ? NaN : 0 }, abr: { enabled: useAutoQuality, @@ -793,7 +805,7 @@ export default defineComponent({ function configureUI(firstTime = false) { if (firstTime) { const firstTimeConfig = { - addSeekBar: true, + addSeekBar: seekingIsPossible.value, customContextMenu: true, contextMenuElements: ['ft_stats'], enableTooltips: true, @@ -947,102 +959,6 @@ export default defineComponent({ // #endregion UI config - // #region legacy text displayer - - /** @type {shaka.text.UITextDisplayer|null} */ - let legacyTextDisplayer = null - - /** @type {shaka.util.EventManager|null} */ - let legacyTextEventManager = null - - /** - * For the legacy formats the captions are handled by the browser instead of shaka. - * As we want to maintain the same appearance and functionality across all 3 formats (audio, dash and legacy), - * this function sets up and configures a custom `shaka.text.UITextDisplayer` instance, - * it also sets up event handlers to add and remove cues, when the browser reports that the active ones changed. - * The browser's own caption display is hidden through CSS. - * - * @param {HTMLVideoElement} video - * @param {HTMLElement} container - */ - async function setUpLegacyTextDisplay(video, container) { - await cleanUpLegacyTextDisplay() - - const textDisplayerConfig = player.getConfiguration().textDisplayer - - legacyTextDisplayer = new shaka.text.UITextDisplayer(video, container, textDisplayerConfig) - - // using an event manager lets us easily release the event handlers in one go, when we tear down the legacy text display - legacyTextEventManager = new shaka.util.EventManager() - - legacyTextEventManager.listen(player, 'texttrackvisibility', () => { - legacyTextDisplayer.setTextVisibility(player.isTextTrackVisible()) - }) - - /** @type {TextTrack} */ - let currentTrack - let currentTrackIsAutoGenerated = false - - legacyTextEventManager.listen(player, 'textchanged', () => { - const { start, end } = player.seekRange() - legacyTextDisplayer.remove(start, end) - - legacyTextEventManager.unlisten(currentTrack, 'cuechange') - - // TextTracksList doesn't support forEach or for-of - for (let i = 0; i < video.textTracks.length; i++) { - const textTrack = video.textTracks[i] - - if (textTrack.mode === 'showing') { - currentTrack = textTrack - currentTrackIsAutoGenerated = textTrack.label.endsWith('(auto-generated)') - break - } - } - - // TODO: performance improvement, don't listen to cue changes while the captions are hidden - legacyTextEventManager.listen(currentTrack, 'cuechange', () => { - // As live streams don't support legacy formats and the duration of VOD videos doesn't changes, - // we can assume that the seek range will always be the same, so we don't have to read it out here again - legacyTextDisplayer.remove(start, end) - if (currentTrack.activeCues) { - const activeCues = Array.from(currentTrack.activeCues).map((cue) => shakaCueFromVTTCue(cue, currentTrackIsAutoGenerated)) - legacyTextDisplayer.append(activeCues) - } - }) - - // add current ones now, so that they show straight away, not just when the next event fires - // e.g. when the video is paused - if (currentTrack.activeCues) { - const activeCues = Array.from(currentTrack.activeCues).map((cue) => shakaCueFromVTTCue(cue, currentTrackIsAutoGenerated)) - legacyTextDisplayer.append(activeCues) - } - }) - } - - /** - * Tears down the legacy captions text displayer and removes the event listeners - */ - async function cleanUpLegacyTextDisplay() { - if (legacyTextEventManager) { - // store in a temporary variable, so we can clear the variable immediately, even if the release takes longer - const eventManager = legacyTextEventManager - legacyTextEventManager = null - - eventManager.release() - } - - if (legacyTextDisplayer) { - // store in a temporary variable, so we can clear the variable immediately, even if the destroy takes longer - const textDisplayer = legacyTextDisplayer - legacyTextDisplayer = null - - await textDisplayer.destroy() - } - } - - // #endregion legacy text displayer - // #region player locales // shaka-player ships with some locales prebundled and already loaded @@ -1164,10 +1080,6 @@ export default defineComponent({ if (useSponsorBlock.value && sponsorBlockSegments.length > 0 && canSeek()) { skipSponsorBlockSegments(currentTime) } - - if ('mediaSession' in navigator) { - updateMediaSessionPositionState(currentTime) - } } // #endregion video event handlers @@ -1232,77 +1144,78 @@ export default defineComponent({ response.data = new TextEncoder().encode(cleaned).buffer } } - } - } + } else if (type === RequestType.MANIFEST && context.type === AdvancedRequestType.MEDIA_PLAYLIST) { + const url = new URL(response.uri) - // #endregion request/response filters - - // #region media session + let modifiedText - /** @type {MediaSessionActionHandler} */ - function mediaSessionActionHandler(details) { - const video_ = video.value + // Fixes proxied HLS manifests, as Invidious replaces the path parameters with query parameters, + // so shaka-player isn't able to infer the mime type from the `/file/seg.ts` part like it does for non-proxied HLS manifests. + // Shaka-player does attempt to detect it with HEAD request but the `Content-Type` header is `application/octet-stream`, + // which still doesn't tell shaka-player how to handle the stream because that's the equivalent of saying "binary data". + if (url.searchParams.has('local')) { + const stringBody = new TextDecoder().decode(response.data) - switch (details.action) { - case 'play': - video_.play() - break - case 'pause': - video_.pause() - break - case 'seekbackward': - video_.currentTime -= (details.seekOffset || defaultSkipInterval.value) - break - case 'seekforward': - video_.currentTime += (details.seekOffset || defaultSkipInterval.value) - break - case 'seekto': { - const { start: seekRangeStart } = player.seekRange() + modifiedText = stringBody.replaceAll(/https?:\/\/.+$/gm, hlsProxiedUrlReplacer) + } - if (details.fastSeek) { - video_.fastSeek(seekRangeStart + details.seekTime) - } else { - video_.currentTime = seekRangeStart + details.seekTime + // The audio-only streams are actually raw AAC, so correct the file extension from `.ts` to `.aac` + if (/\/itag\/23[34]\//.test(url.pathname) || url.searchParams.get('itag') === '233' || url.searchParams.get('itag') === '234') { + if (!modifiedText) { + modifiedText = new TextDecoder().decode(response.data) } - break + + modifiedText = modifiedText.replaceAll('/file/seg.ts', '/file/seg.aac') + } + + if (modifiedText) { + response.data = new TextEncoder().encode(modifiedText).buffer } } } /** - * @param {number|Event} currentTime + * @param {string} match */ - function updateMediaSessionPositionState(currentTime) { - if (hasLoaded.value && 'mediaSession' in navigator) { - const seekRange = player.seekRange() - - if (typeof currentTime !== 'number') { - currentTime = video.value.currentTime + function hlsProxiedUrlReplacer(match) { + const url = new URL(match) + + let fileValue + for (const [key, value] of url.searchParams) { + if (key === 'file') { + fileValue = value + continue + } else if (key === 'hls_chunk_host') { + // Add the host parameter so some Invidious instances stop complaining about the missing host parameter + // Replace .c.youtube.com with .googlevideo.com as the built-in Invidious video proxy only accepts host parameters with googlevideo.com + url.pathname += `/host/${encodeURIComponent(value.replace('.c.youtube.com', '.googlevideo.com'))}` } - const duration = seekRange.end - seekRange.start + url.pathname += `/${key}/${encodeURIComponent(value)}` + } - const playbackRate = video.value.playbackRate + // This has to be right at the end so that shaka-player can read the file extension + url.pathname += `/file/${encodeURIComponent(fileValue)}` - navigator.mediaSession.setPositionState({ - duration, - position: Math.min(Math.max(0, currentTime - seekRange.start), duration), - playbackRate: playbackRate > 0 ? playbackRate : undefined - }) - } + url.search = '' + return url.toString() } - // #endregion media session + // #endregion request/response filters // #region set quality /** * @param {number} quality + * @param {number | undefined} audioBandwidth + * @param {string | undefined} label */ - function setDashQuality(quality) { + function setDashQuality(quality, audioBandwidth, label) { let variants = player.getVariantTracks() - if (hasMultipleAudioTracks.value) { + if (label) { + variants = variants.filter(variant => variant.label === label) + } else if (hasMultipleAudioTracks.value) { // default audio track variants = variants.filter(variant => variant.audioRoles.includes('main')) } @@ -1320,9 +1233,21 @@ export default defineComponent({ } matches.sort((a, b) => isPortrait ? b.width - a.width : b.height - a.height) - variants = matches - player.selectVariantTrack(variants[0]) + let chosenVariant + + if (typeof audioBandwidth === 'number') { + const width = matches[0].width + const height = matches[0].height + + matches = matches.filter(variant => variant.width === width && variant.height === height) + + chosenVariant = findMostSimilarAudioBandwidth(matches, audioBandwidth) + } else { + chosenVariant = matches[0] + } + + player.selectVariantTrack(chosenVariant) } /** @@ -1363,7 +1288,6 @@ export default defineComponent({ } hasMultipleAudioTracks.value = false - hasMultipleAudioChannelCounts = false events.dispatchEvent(new CustomEvent('setLegacyFormat', { detail: { @@ -1435,7 +1359,7 @@ export default defineComponent({ stats.bitrate = (newTrack.bandwidth / 1000).toFixed(2) // Combined audio and video HLS streams - if (newTrack.videoCodec.includes(',')) { + if (newTrack.videoCodec?.includes(',')) { stats.codecs.audioItag = '' stats.codecs.videoItag = '' @@ -1689,6 +1613,8 @@ export default defineComponent({ // #region custom player controls + const { ContextMenu: shakaContextMenu, Controls: shakaControls, OverflowMenu: shakaOverflowMenu } = shaka.ui + function registerAudioTrackSelection() { /** @implements {shaka.extern.IUIElement.Factory} */ class AudioTrackSelectionFactory { @@ -1697,8 +1623,8 @@ export default defineComponent({ } } - shaka.ui.Controls.registerElement('ft_audio_tracks', new AudioTrackSelectionFactory()) - shaka.ui.OverflowMenu.registerElement('ft_audio_tracks', new AudioTrackSelectionFactory()) + shakaControls.registerElement('ft_audio_tracks', new AudioTrackSelectionFactory()) + shakaOverflowMenu.registerElement('ft_audio_tracks', new AudioTrackSelectionFactory()) } function registerTheatreModeButton() { @@ -1715,8 +1641,8 @@ export default defineComponent({ } } - shaka.ui.Controls.registerElement('ft_theatre_mode', new TheatreModeButtonFactory()) - shaka.ui.OverflowMenu.registerElement('ft_theatre_mode', new TheatreModeButtonFactory()) + shakaControls.registerElement('ft_theatre_mode', new TheatreModeButtonFactory()) + shakaOverflowMenu.registerElement('ft_theatre_mode', new TheatreModeButtonFactory()) } function registerFullWindowButton() { @@ -1743,8 +1669,8 @@ export default defineComponent({ } } - shaka.ui.Controls.registerElement('ft_full_window', new FullWindowButtonFactory()) - shaka.ui.OverflowMenu.registerElement('ft_full_window', new FullWindowButtonFactory()) + shakaControls.registerElement('ft_full_window', new FullWindowButtonFactory()) + shakaOverflowMenu.registerElement('ft_full_window', new FullWindowButtonFactory()) } function registerLegacyQualitySelection() { @@ -1778,8 +1704,8 @@ export default defineComponent({ } } - shaka.ui.Controls.registerElement('ft_legacy_quality', new LegacyQualitySelectionFactory()) - shaka.ui.OverflowMenu.registerElement('ft_legacy_quality', new LegacyQualitySelectionFactory()) + shakaControls.registerElement('ft_legacy_quality', new LegacyQualitySelectionFactory()) + shakaOverflowMenu.registerElement('ft_legacy_quality', new LegacyQualitySelectionFactory()) } function registerStatsButton() { @@ -1800,7 +1726,7 @@ export default defineComponent({ } } - shaka.ui.ContextMenu.registerElement('ft_stats', new StatsButtonFactory()) + shakaContextMenu.registerElement('ft_stats', new StatsButtonFactory()) } function registerScreenshotButton() { @@ -1817,8 +1743,34 @@ export default defineComponent({ } } - shaka.ui.Controls.registerElement('ft_screenshot', new ScreenshotButtonFactory()) - shaka.ui.OverflowMenu.registerElement('ft_screenshot', new ScreenshotButtonFactory()) + shakaControls.registerElement('ft_screenshot', new ScreenshotButtonFactory()) + shakaOverflowMenu.registerElement('ft_screenshot', new ScreenshotButtonFactory()) + } + + /** + * As shaka-player doesn't let you unregister custom control factories, + * overwrite them with `null` instead so the referenced objects + * (e.g. {@linkcode events}, {@linkcode fullWindowEnabled}) can get gargabe collected + */ + function cleanUpCustomPlayerControls() { + shakaControls.registerElement('ft_audio_tracks', null) + shakaOverflowMenu.registerElement('ft_audio_tracks', null) + + shakaControls.registerElement('ft_theatre_mode', null) + shakaOverflowMenu.registerElement('ft_theatre_mode', null) + + shakaControls.registerElement('ft_full_window', null) + shakaOverflowMenu.registerElement('ft_full_window', null) + + shakaControls.registerElement('ft_legacy_quality', null) + shakaOverflowMenu.registerElement('ft_legacy_quality', null) + + shakaContextMenu.registerElement('ft_stats', null) + + if (process.env.IS_ELECTRON) { + shakaControls.registerElement('ft_screenshot', null) + shakaOverflowMenu.registerElement('ft_screenshot', null) + } } // #endregion custom player controls @@ -2408,30 +2360,11 @@ export default defineComponent({ controls.addEventListener('uiupdated', addUICustomizations) configureUI(true) - if (props.format === 'legacy' && sortedCaptions.length > 0) { - await setUpLegacyTextDisplay(videoElement, container.value) - - // check if the component is already getting destroyed - // which is possible because this function runs asynchronously - if (!ui || !player) { - return - } - } - document.removeEventListener('keydown', keyboardShortcutHandler) document.addEventListener('keydown', keyboardShortcutHandler) player.addEventListener('loading', () => { hasLoaded.value = false - - if ('mediaSession' in navigator) { - navigator.mediaSession.setActionHandler('play', null) - navigator.mediaSession.setActionHandler('pause', null) - navigator.mediaSession.setActionHandler('seekto', null) - navigator.mediaSession.setActionHandler('seekbackward', null) - navigator.mediaSession.setActionHandler('seekforward', null) - navigator.mediaSession.setPositionState() - } }) player.addEventListener('loaded', handleLoaded) @@ -2451,7 +2384,6 @@ export default defineComponent({ } hasMultipleAudioTracks.value = player.getAudioLanguagesAndRoles().length > 1 - hasMultipleAudioChannelCounts = new Set(player.getVariantTracks().map(track => track.channelsCount)).size > 1 if (props.format === 'dash') { const firstVariant = player.getVariantTracks()[0] @@ -2531,17 +2463,62 @@ export default defineComponent({ const promises = [] for (const caption of sortedCaptions) { - promises.push( - player.addTextTrackAsync( - caption.url, - caption.language, - 'captions', - caption.mimeType, - undefined, // codec, only needed if the captions are inside a container (e.g. mp4) - caption.label + if (props.format === 'legacy') { + const url = new URL(caption.url) + + if (url.hostname.endsWith('.youtube.com') && url.pathname === '/api/timedtext' && + url.searchParams.get('caps') === 'asr' && url.searchParams.get('kind') === 'asr' && url.searchParams.get('fmt') === 'vtt') { + promises.push((async () => { + try { + const response = await fetch(caption.url) + let text = await response.text() + + text = text.replaceAll(/ align:start position:0%$/gm, '') + + const url = `data:${caption.mimeType};charset=utf-8,${encodeURIComponent(text)}` + + await player.addTextTrackAsync( + url, + caption.language, + 'captions', + caption.mimeType, + undefined, // codec, only needed if the captions are inside a container (e.g. mp4) + caption.label + ) + } catch (error) { + if (error instanceof shaka.util.Error) { + handleError(error, 'addTextTrackAsync', caption) + } else { + console.error(error) + } + } + })()) + } else { + promises.push( + player.addTextTrackAsync( + caption.url, + caption.language, + 'captions', + caption.mimeType, + undefined, // codec, only needed if the captions are inside a container (e.g. mp4) + caption.label + ) + .catch(error => handleError(error, 'addTextTrackAsync', caption)) + ) + } + } else { + promises.push( + player.addTextTrackAsync( + caption.url, + caption.language, + 'captions', + caption.mimeType, + undefined, // codec, only needed if the captions are inside a container (e.g. mp4) + caption.label + ) + .catch(error => handleError(error, 'addTextTrackAsync', caption)) ) - .catch(error => handleError(error, 'addTextTrackAsync', caption)) - ) + } } if (!isLive.value && props.storyboardSrc) { @@ -2564,30 +2541,6 @@ export default defineComponent({ if (textTrack) { player.selectTextTrack(textTrack) - if (props.format === 'legacy') { - // ensure that only the track we want enabled is enabled - // for some reason, after we set the visibility to true - // a second track gets enabled, not sure why, - // but as far as i can tell it might be Electron itself doing it - // weirdly it doesn't happen for shaka's caption selector but we seem to be doing the same stuff - // for the moment this hack works - // - // maybe this issue: https://github.com/shaka-project/shaka-player/issues/3474 - - const textTracks = video.value.textTracks - - textTracks.addEventListener('change', () => { - for (let i = 0; i < textTracks.length; i++) { - const textTrack = textTracks[i] - if (textTrack.kind === 'captions' || textTrack.kind === 'subtitles') { - textTrack.mode = i === index ? 'showing' : 'disabled' - } - } - }, { - once: true - }) - } - await player.setTextTrackVisibility(true) } } @@ -2595,21 +2548,6 @@ export default defineComponent({ if (props.chapters.length > 0) { createChapterMarkers() } - - if ('mediaSession' in navigator) { - navigator.mediaSession.setActionHandler('play', mediaSessionActionHandler) - navigator.mediaSession.setActionHandler('pause', mediaSessionActionHandler) - - if (canSeek()) { - navigator.mediaSession.setActionHandler('seekto', mediaSessionActionHandler) - navigator.mediaSession.setActionHandler('seekbackward', mediaSessionActionHandler) - navigator.mediaSession.setActionHandler('seekforward', mediaSessionActionHandler) - } else { - navigator.mediaSession.setActionHandler('seekto', null) - navigator.mediaSession.setActionHandler('seekbackward', null) - navigator.mediaSession.setActionHandler('seekforward', null) - } - } } watch( @@ -2638,7 +2576,7 @@ export default defineComponent({ const wasPaused = video_.paused - const useAutoQuality = oldFormat === 'legacy' ? defaultQuality.value === 'auto' : player.getConfiguration().abr.enabled + let useAutoQuality = oldFormat === 'legacy' ? defaultQuality.value === 'auto' : player.getConfiguration().abr.enabled if (!wasPaused) { video_.pause() @@ -2658,17 +2596,9 @@ export default defineComponent({ restoreCaptionIndex = null } - if (oldFormat === 'legacy') { - await cleanUpLegacyTextDisplay() - } - - if (newFormat === 'legacy' && sortedCaptions.length > 0) { - await setUpLegacyTextDisplay(video_, container.value) - } - if (newFormat === 'audio' || newFormat === 'dash') { - /** @type {{language: string, role: string, channelsCount: number}|undefined} */ - let audioTrack + let label + let audioBandwidth let dimension if (oldFormat === 'legacy' && newFormat === 'dash') { @@ -2677,18 +2607,25 @@ export default defineComponent({ if (!useAutoQuality) { dimension = qualityLabelToDimension(legacyFormat.qualityLabel) } - } else if (oldFormat !== 'legacy' && (hasMultipleAudioTracks.value || hasMultipleAudioChannelCounts)) { + } else if (oldFormat !== 'legacy') { const track = player.getVariantTracks().find(track => track.active) - audioTrack = { - language: track.language, - role: track.audioRoles[0], - channelsCount: track.channelsCount + if (typeof track.audioBandwidth === 'number') { + audioBandwidth = track.audioBandwidth + } + + if (track.label) { + label = track.label } } - if (oldFormat === 'audio' && newFormat === 'dash' && !useAutoQuality && defaultQuality.value !== 'auto') { - dimension = defaultQuality.value + if (oldFormat === 'audio' && newFormat === 'dash' && !useAutoQuality) { + if (defaultQuality.value !== 'auto') { + dimension = defaultQuality.value + } else { + // Use auto as we don't know what resolution to pick + useAutoQuality = true + } } player.configure(getPlayerConfig(newFormat, useAutoQuality)) @@ -2696,12 +2633,32 @@ export default defineComponent({ try { await player.load(props.manifestSrc, playbackPosition, props.manifestMimeType) - if (dimension) { - setDashQuality(dimension) - } + if (useAutoQuality) { + if (label) { + player.selectVariantsByLabel(label) + } + } else { + if (dimension) { + setDashQuality(dimension, audioBandwidth, label) + } else { + let variants = player.getVariantTracks() + + if (label) { + variants = variants.filter(variant => variant.label === label) + } - if (audioTrack) { - player.selectAudioLanguage(audioTrack.language, audioTrack.role, audioTrack.channelsCount) + let chosenVariant + + if (typeof audioBandwidth === 'number') { + chosenVariant = findMostSimilarAudioBandwidth(variants, audioBandwidth) + } else { + chosenVariant = variants.reduce((previous, current) => { + return previous === null || current.bandwidth > previous.bandwidth ? current : previous + }, null) + } + + player.selectVariantTrack(chosenVariant) + } } } catch (error) { handleError(error, 'loading dash/audio manifest for format switch', `${oldFormat} -> ${newFormat}`) @@ -2732,26 +2689,13 @@ export default defineComponent({ resizeObserver = null } - cleanUpLegacyTextDisplay() - - if (ui) { - // destroying the ui also destroys the player - ui.destroy() - ui = null - player = null - } + cleanUpCustomPlayerControls() stopPowerSaveBlocker() window.removeEventListener('beforeunload', stopPowerSaveBlocker) if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = 'none' - navigator.mediaSession.setPositionState() - navigator.mediaSession.setActionHandler('play', null) - navigator.mediaSession.setActionHandler('pause', null) - navigator.mediaSession.setActionHandler('seekforward', null) - navigator.mediaSession.setActionHandler('seekbackward', null) - navigator.mediaSession.setActionHandler('seekto', null) } skippedSponsorBlockSegments.value.forEach(segment => clearTimeout(segment.timeoutId)) @@ -2783,13 +2727,41 @@ export default defineComponent({ video.value.currentTime = time } + /** + * Vue's lifecycle hooks are synchonous, so if we destroy the player in {@linkcode onBeforeUnmount}, + * it won't be finished in time, as the player destruction is asynchronous. + * To workaround that we destroy the player first and wait for it to finish before we unmount this component. + */ + async function destroyPlayer() { + if (ui) { + // destroying the ui also destroys the player + await ui.destroy() + ui = null + player = null + } else if (player) { + await player.destroy() + player = null + } + + // shaka-player doesn't clear these itself, which prevents shaka.ui.Overlay from being garbage collected + // Should really be fixed in shaka-player but it's easier just to do it ourselves + if (container.value) { + container.value.ui = null + } + + if (video.value) { + video.value.ui = null + } + } + expose({ hasLoaded, isPaused, pause, getCurrentTime, - setCurrentTime + setCurrentTime, + destroyPlayer }) // #endregion functions used by the watch page @@ -2817,8 +2789,6 @@ export default defineComponent({ handleEnded, updateVolume, handleTimeupdate, - - updateMediaSessionPositionState } } }) diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.vue b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.vue index 3477b6ad37f4a..4b9519c772718 100644 --- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.vue +++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.vue @@ -21,8 +21,6 @@ @ended="handleEnded" @volumechange="updateVolume" @timeupdate="handleTimeupdate" - @ratechange="updateMediaSessionPositionState" - @seeked="updateMediaSessionPositionState" /> { this.updateAudioTracks_() }) @@ -40,9 +40,14 @@ export class AudioTrackSelection extends shaka.ui.SettingsMenu { this.updateAudioTracks_() } - /** @private */ - updateAudioTracks_() { - const tracks = this.player.getVariantTracks() + /** + * @private + * @param {shaka.extern.TrackList=} tracks + */ + updateAudioTracks_(tracks) { + if (!tracks) { + tracks = this.player.getVariantTracks() + } const selectedTrack = tracks.find(track => track.active) @@ -68,7 +73,7 @@ export class AudioTrackSelection extends shaka.ui.SettingsMenu { const button = document.createElement('button') button.addEventListener('click', () => { - this.onAudioTrackSelected_(track) + this.onAudioTrackSelected_(track.label) }) const span = document.createElement('span') @@ -95,7 +100,7 @@ export class AudioTrackSelection extends shaka.ui.SettingsMenu { this.button.setAttribute('shaka-status', this.currentSelection.innerText) - if (knownLabels.size > 0) { + if (knownLabels.size > 1) { this.button.classList.remove('shaka-hidden') } else { this.button.classList.add('shaka-hidden') @@ -116,11 +121,31 @@ export class AudioTrackSelection extends shaka.ui.SettingsMenu { } /** - * @param {shaka.extern.Track} track + * @param {string} label * @private */ - onAudioTrackSelected_(track) { - this.player.selectVariantsByLabel(track.label) + onAudioTrackSelected_(label) { + if (this.player.getConfiguration().abr.enabled) { + this.player.selectVariantsByLabel(label) + } else { + const variants = this.player.getVariantTracks() + const previousVariant = variants.find(variant => variant.active) + + let matchingVariants = variants.filter(variant => variant.label === label) + + if (!this.player.isAudioOnly()) { + matchingVariants = matchingVariants.filter(variant => { + return variant.width === previousVariant.width && + variant.height === previousVariant.height && + variant.frameRate === previousVariant.frameRate && + variant.hdr === previousVariant.hdr + }) + } + + const closestVariant = findMostSimilarAudioBandwidth(matchingVariants, previousVariant.audioBandwidth) + + this.player.selectVariantTrack(closestVariant, true) + } } /** @private */ diff --git a/src/renderer/components/ft-toast/ft-toast.js b/src/renderer/components/ft-toast/ft-toast.js index c85fbf55f65cc..15697ca25061d 100644 --- a/src/renderer/components/ft-toast/ft-toast.js +++ b/src/renderer/components/ft-toast/ft-toast.js @@ -1,4 +1,4 @@ -import { defineComponent } from 'vue' +import { defineComponent, nextTick } from 'vue' import FtToastEvents from './ft-toast-events.js' let id = 0 @@ -38,7 +38,7 @@ export default defineComponent({ id: id++ } toast.timeout = setTimeout(this.close, time || 3000, toast) - setTimeout(() => { toast.isOpen = true }) + nextTick(() => { toast.isOpen = true }) if (this.toasts.length > 4) { this.remove(0) } diff --git a/src/renderer/components/general-settings/general-settings.js b/src/renderer/components/general-settings/general-settings.js index 64b53ff387ca6..1ddbf2c1d4607 100644 --- a/src/renderer/components/general-settings/general-settings.js +++ b/src/renderer/components/general-settings/general-settings.js @@ -242,10 +242,6 @@ export default defineComponent({ handlePreferredApiBackend: function (backend) { this.updateBackendPreference(backend) - - if (backend === 'local') { - this.updateForceLocalBackendForLegacy(false) - } }, handleThumbnailPreferenceChange: function (value) { @@ -270,7 +266,6 @@ export default defineComponent({ 'updateRegion', 'updateListType', 'updateThumbnailPreference', - 'updateForceLocalBackendForLegacy', 'updateCurrentLocale', 'updateExternalLinkHandling', 'updateGeneralAutoLoadMorePaginatedItemsEnabled', diff --git a/src/renderer/components/general-settings/general-settings.vue b/src/renderer/components/general-settings/general-settings.vue index 536aa22461f73..2a77422f45959 100644 --- a/src/renderer/components/general-settings/general-settings.vue +++ b/src/renderer/components/general-settings/general-settings.vue @@ -81,6 +81,7 @@ :select-names="localeNames" :select-values="localeOptions" :icon="['fas', 'language']" + is-locale-selector @change="updateCurrentLocale" />
- { @@ -162,8 +174,12 @@ export default defineComponent({ getCommentData: function () { this.isLoading = true - if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious') { - this.getCommentDataInvidious() + if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious' || this.isPostComments) { + if (!this.isPostComments) { + this.getCommentDataInvidious() + } else { + this.getPostCommentsInvidious() + } } else { this.getCommentDataLocal() } @@ -173,8 +189,12 @@ export default defineComponent({ if (this.commentData.length === 0 || this.nextPageToken === null || typeof this.nextPageToken === 'undefined') { showToast(this.$t('Comments.There are no more comments for this video')) } else { - if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious') { - this.getCommentDataInvidious() + if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious' || this.isPostComments) { + if (!this.isPostComments) { + this.getCommentDataInvidious() + } else { + this.getPostCommentsInvidious() + } } else { this.getCommentDataLocal(true) } @@ -190,17 +210,14 @@ export default defineComponent({ }, getCommentReplies: function (index) { - if (process.env.SUPPORTS_LOCAL_API) { - switch (this.commentData[index].dataType) { - case 'local': - this.getCommentRepliesLocal(index) - break - case 'invidious': - this.getCommentRepliesInvidious(index) - break + if (!process.env.SUPPORTS_LOCAL_API || this.commentData[index].dataType === 'invidious' || this.isPostComments) { + if (!this.isPostComments) { + this.getCommentRepliesInvidious(index) + } else { + this.getPostCommentRepliesInvidious(index) } } else { - this.getCommentRepliesInvidious(index) + this.getCommentRepliesLocal(index) } }, @@ -375,5 +392,68 @@ export default defineComponent({ this.isLoading = false }) }, + + getPostCommentsInvidious: function() { + const nextPageToken = this.nextPageToken + + const fetchComments = nextPageToken == null + ? getInvidiousCommunityPostComments({ postId: this.id, authorId: this.postAuthorId }) + : getInvidiousCommunityPostCommentReplies({ postId: this.id, replyToken: this.nextPageToken, authorId: this.postAuthorId }) + + fetchComments.then(({ response, commentData, continuation }) => { + commentData = commentData.map(({ replyToken, ...comment }) => { + if (comment.hasReplyToken) { + this.replyTokens.set(comment.id, replyToken) + } else { + this.replyTokens.delete(comment.id) + } + + return comment + }) + + this.commentData = this.commentData.concat(commentData) + this.nextPageToken = response?.continuation ?? continuation + this.isLoading = false + this.showComments = true + }).catch((err) => { + console.error(err) + const errorMessage = this.$t('Invidious API Error (Click to copy)') + showToast(`${errorMessage}: ${err}`, 10000, () => { + copyToClipboard(err) + }) + this.isLoading = false + }) + }, + + getPostCommentRepliesInvidious: function(index) { + showToast(this.$t('Comments.Getting comment replies, please wait')) + + const comment = this.commentData[index] + const replyToken = this.replyTokens.get(comment.id) + const id = this.id + + getInvidiousCommunityPostCommentReplies({ postId: id, replyToken: replyToken, authorId: this.postAuthorId }) + .then(({ commentData, continuation }) => { + comment.replies = comment.replies.concat(commentData) + comment.showReplies = true + + if (continuation) { + this.replyTokens.set(comment.id, continuation) + comment.hasReplyToken = true + } else { + this.replyTokens.delete(comment.id) + comment.hasReplyToken = false + } + + this.isLoading = false + }).catch((error) => { + console.error(error) + const errorMessage = this.$t('Invidious API Error (Click to copy)') + showToast(`${errorMessage}: ${error}`, 10000, () => { + copyToClipboard(error) + }) + this.isLoading = false + }) + } } }) diff --git a/src/renderer/components/watch-video-comments/watch-video-comments.vue b/src/renderer/components/watch-video-comments/watch-video-comments.vue index ee5798c631b73..d39df77213606 100644 --- a/src/renderer/components/watch-video-comments/watch-video-comments.vue +++ b/src/renderer/components/watch-video-comments/watch-video-comments.vue @@ -41,7 +41,7 @@ {{ $t("Comments.Click to View Comments") }} -

+

+ {{ $t("Comments.There are no comments available for this post") }} +

+

{{ $t("Comments.There are no comments available for this video") }}

diff --git a/src/renderer/components/watch-video-info/watch-video-info.js b/src/renderer/components/watch-video-info/watch-video-info.js index cab55a969e06c..ed57aa9f23d86 100644 --- a/src/renderer/components/watch-video-info/watch-video-info.js +++ b/src/renderer/components/watch-video-info/watch-video-info.js @@ -1,4 +1,4 @@ -import { defineComponent } from 'vue' +import { defineComponent, nextTick } from 'vue' import { mapActions } from 'vuex' import FtCard from '../ft-card/ft-card.vue' import FtIconButton from '../ft-icon-button/ft-icon-button.vue' @@ -274,7 +274,7 @@ export default defineComponent({ if (dropdownShown && window.innerWidth >= 901) { // adds a slight delay so we know that the dropdown has shown up // and won't mess up our scrolling - setTimeout(() => { + nextTick(() => { this.$emit('scroll-to-info-area') }) } diff --git a/src/renderer/components/watch-video-live-chat/watch-video-live-chat.js b/src/renderer/components/watch-video-live-chat/watch-video-live-chat.js index 6a6daaefab93f..973eaddc551a9 100644 --- a/src/renderer/components/watch-video-live-chat/watch-video-live-chat.js +++ b/src/renderer/components/watch-video-live-chat/watch-video-live-chat.js @@ -1,4 +1,4 @@ -import { defineComponent } from 'vue' +import { defineComponent, nextTick } from 'vue' import FtLoader from '../ft-loader/ft-loader.vue' import FtCard from '../ft-card/ft-card.vue' import FtButton from '../ft-button/ft-button.vue' @@ -169,7 +169,7 @@ export default defineComponent({ this.isLoading = false - setTimeout(() => { + nextTick(() => { this.$refs.liveChatComments?.scrollTo({ top: this.$refs.liveChatComments.scrollHeight, behavior: 'instant' @@ -278,7 +278,7 @@ export default defineComponent({ this.comments.push(comment) if (!this.isLoading && this.stayAtBottom) { - setTimeout(() => { + nextTick(() => { this.$refs.liveChatComments?.scrollTo({ top: this.$refs.liveChatComments.scrollHeight, behavior: this.scrollingBehaviour diff --git a/src/renderer/components/watch-video-playlist/watch-video-playlist.js b/src/renderer/components/watch-video-playlist/watch-video-playlist.js index e9fad636e8999..ec0ccd27cfc1c 100644 --- a/src/renderer/components/watch-video-playlist/watch-video-playlist.js +++ b/src/renderer/components/watch-video-playlist/watch-video-playlist.js @@ -289,9 +289,9 @@ export default defineComponent({ // Create a new array to avoid changing array in data store state // it could be user playlist or cache playlist this.playlistItems = this.playlistItems.toReversed() - setTimeout(() => { + nextTick(() => { this.isLoading = false - }, 1) + }) }, togglePauseOnCurrentVideo: function () { diff --git a/src/renderer/helpers/api/invidious.js b/src/renderer/helpers/api/invidious.js index 17259c7f8158e..f05c4af95e920 100644 --- a/src/renderer/helpers/api/invidious.js +++ b/src/renderer/helpers/api/invidious.js @@ -60,6 +60,16 @@ export function invidiousAPICall({ resource, id = '', params = {}, doLogError = }) } +async function resolveUrl(url) { + return await invidiousAPICall({ + resource: 'resolveurl', + params: { + url + }, + doLogError: false + }) +} + /** * Gets the channel ID for a channel URL * used to get the ID for channel usernames and handles @@ -67,13 +77,7 @@ export function invidiousAPICall({ resource, id = '', params = {}, doLogError = */ export async function invidiousGetChannelId(url) { try { - const response = await invidiousAPICall({ - resource: 'resolveurl', - params: { - url - }, - doLogError: false - }) + const response = await resolveUrl(url) if (response.pageType === 'WEB_PAGE_TYPE_CHANNEL') { return response.ucid @@ -198,6 +202,60 @@ export async function invidiousGetCommunityPosts(channelId, continuation = null) return { posts: response.comments, continuation: response.continuation ?? null } } +export async function getInvidiousCommunityPost(postId, authorId = null) { + const payload = { + resource: 'post', + id: postId, + } + + if (authorId == null) { + authorId = await invidiousGetChannelId('https://www.youtube.com/post/' + postId) + } + + payload.params = { + ucid: authorId + } + + const response = await invidiousAPICall(payload) + + const post = parseInvidiousCommunityData(response.comments[0]) + post.authorId = authorId + post.commentCount = null + + return post +} + +export async function getInvidiousCommunityPostComments({ postId, authorId }) { + const payload = { + resource: 'post', + id: postId, + subResource: 'comments', + params: { + ucid: authorId + } + } + + const response = await invidiousAPICall(payload) + const commentData = parseInvidiousCommentData(response) + + return { response, commentData } +} + +export async function getInvidiousCommunityPostCommentReplies({ postId, replyToken, authorId }) { + const payload = { + resource: 'post', + id: postId, + subResource: 'comments', + params: { + ucid: authorId, + continuation: replyToken + } + } + + const response = await invidiousAPICall(payload) + return { commentData: parseInvidiousCommentData(response), continuation: response.continuation ?? null } +} + function parseInvidiousCommunityData(data) { return { // use #/ to support channel YT links. diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 12ddbf6cb3fbf..c65b900a428d2 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -8,6 +8,7 @@ import { calculatePublishedDate, escapeHTML, extractNumberFromString, + getChannelPlaylistId, randomArrayItem, toLocalePublicationString } from '../utils' @@ -283,16 +284,13 @@ export async function getLocalVideoInfo(id) { info.storyboards = iosInfo.storyboards } else if (iosInfo.streaming_data) { info.streaming_data.adaptive_formats = iosInfo.streaming_data.adaptive_formats + info.streaming_data.hls_manifest_url = iosInfo.streaming_data.hls_manifest_url + // Use the legacy formats from the original web response as the iOS client doesn't have any legacy formats for (const format of info.streaming_data.adaptive_formats) { format.freeTubeUrl = format.url } - - // don't overwrite for live streams - if (!info.streaming_data.hls_manifest_url) { - info.streaming_data.hls_manifest_url = iosInfo.streaming_data.hls_manifest_url - } } if (info.streaming_data) { @@ -397,6 +395,21 @@ export async function getLocalChannelVideos(id) { // so we need to check that we got the right tab if (videosTab.current_tab?.endpoint.metadata.url?.endsWith('/videos')) { videos = parseLocalChannelVideos(videosTab.videos, channelId, name) + } else if (name.endsWith('- Topic') && !!videosTab.metadata.music_artist_name) { + try { + const playlist = await innertube.getPlaylist(getChannelPlaylistId(channelId, 'videos', 'newest')) + + videos = playlist.items.map(parseLocalPlaylistVideo) + } catch (error) { + // If the channel doesn't exist, the API call to channel page above would have already failed, + // so if we get an error that the playlist doesn't exist here, it just means that this artist topic channel + // doesn't have any videos. + if (error.message === 'The playlist does not exist.') { + videos = [] + } else { + throw error + } + } } else { videos = [] } @@ -922,6 +935,7 @@ export function parseLocalPlaylistVideo(video) { ) return { + type: 'video', videoId: video_.id, title: video_.title.text, author: video_.author.name, @@ -1416,7 +1430,7 @@ function parseLocalCommunityPost(post) { postId: post.id, authorThumbnails: post.author.thumbnails, publishedText: post.published.text, - voteCount: post.vote_count, + voteCount: parseLocalSubscriberCount(post.vote_count.text), postContent: parseLocalAttachment(post.attachment), commentCount: replyCount, author: post.author.name, diff --git a/src/renderer/helpers/player/legacyFormatsVttCueParser.js b/src/renderer/helpers/player/legacyFormatsVttCueParser.js deleted file mode 100644 index ec17ff9018c8a..0000000000000 --- a/src/renderer/helpers/player/legacyFormatsVttCueParser.js +++ /dev/null @@ -1,297 +0,0 @@ -import shaka from 'shaka-player' - -const ShakaCue = shaka.text.Cue - -/** - * Creates a shaka Cue from a browser native VTTCue - * including parsing the karake style text, styling information and unescaping the text. - * - * Please note this is designed specifically to parse YouTube's VTT files - * and makes some assumptions that won't be applicable to other VTT files. - * E.g. that the class names always refer to colours, as the actual CSS that the class name points to, - * isn't accessible through the VTTCues. It's the only way (as far as I can tell) to support coloured text, - * without parsing the VTT file to extract the style section. - * - * The only tag this currently doesn't parse, is the voice (``) one, as I wasn't able to find a video with them. - * @param {VTTCue} vttCue - * @param {boolean} ignoreTextAlignAndPosition auto-generated text tracks are displayed in the bottom left corner if we don't ignore these properties - */ -export function shakaCueFromVTTCue(vttCue, ignoreTextAlignAndPosition) { - // https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API#cue_payload_text_tags - // https://w3c.github.io/webvtt - - // strip the voice tags as we don't support them yet - /** @type {string} */ - const text = vttCue.text.replaceAll(/<(?:v(?:[\t .][^>]+)?|\/v)>/g, '') - - // if the text doesn't contain any tags, we can bypass all the parsing and directly return a Cue - if (!text.includes('<')) { - const shakaCue = new ShakaCue(vttCue.startTime, vttCue.endTime, replaceCueTextEscapeSequences(text)) - - copyFromVttCueToShakaCue(vttCue, shakaCue, ignoreTextAlignAndPosition) - - return shakaCue - } - - const nestedCues = [] - - const TIME_TAG_REGEX = /(?:(?\d{2,}):)?(?\d{2}):(?\d{2}\.\d{3})/ - - let currentStartTime = vttCue.startTime - let currentText = '' - /** @type {string|null} */ - let currentColor = null - let bold = false - let italic = false - let underline = false - - let ruby = false - let rubyRt = false - - let inTag = false - let tagText = '' - - const createCueWithCurrentConfig = () => { - /** @type {'rt'|'ruby'|null} */ - let rubyTag = null - - if (rubyRt) { - rubyTag = 'rt' - } else if (ruby) { - rubyTag = 'ruby' - } - - const cue = createFormattedShakaCue(currentStartTime, vttCue.endTime, currentText, bold, italic, underline, currentColor, rubyTag) - - currentText = '' - - return cue - } - - for (let i = 0; i < text.length; i++) { - const character = text.charAt(i) - - if (inTag) { - if (character === '>') { - if (currentText.length > 0) { - nestedCues.push(createCueWithCurrentConfig()) - } - - switch (tagText) { - case 'b': - bold = true - break - case '/b': - bold = false - break - case 'i': - italic = true - break - case '/i': - italic = false - break - case 'u': - underline = true - break - case '/u': - underline = false - break - case 'ruby': - ruby = true - break - case '/ruby': - ruby = false - break - case 'rt': - rubyRt = true - break - case '/rt': - rubyRt = false - break - case '/c': - currentColor = null - break - default: - if (tagText.charAt(0) === 'c') { - // examples - // - // - // - currentColor = tagText.substring(2) - } else { - const match = tagText.match(TIME_TAG_REGEX) - - if (match) { - let startSeconds = parseFloat(match.groups.seconds) - startSeconds += parseInt(match.groups.minutes) * 60 - - if (match.groups.hours) { - startSeconds += parseInt(match.groups.hours) * 60 * 60 - } - - currentStartTime = startSeconds - } - } - - break - } - - inTag = false - tagText = '' - } else { - tagText += character - } - } else if (character === '<') { - inTag = true - - if (currentText.length > 0) { - nestedCues.push(createCueWithCurrentConfig()) - } - - // create cue with current settings - } else if (character === '\n') { - const cue = createCueWithCurrentConfig() - - const lineBreakCue = new ShakaCue(currentStartTime, vttCue.endTime, '') - lineBreakCue.lineBreak = true - - nestedCues.push(cue, lineBreakCue) - } else { - currentText += character - } - } - - if (currentText.length > 0) { - nestedCues.push(createCueWithCurrentConfig()) - } - - const shakaCue = new ShakaCue(vttCue.startTime, vttCue.endTime, '') - - copyFromVttCueToShakaCue(vttCue, shakaCue, ignoreTextAlignAndPosition) - - shakaCue.nestedCues = nestedCues - - return shakaCue -} - -/** - * @param {number} startTime - * @param {number} endTime - * @param {string} text - * @param {boolean} bold - * @param {boolean} italic - * @param {boolean} underline - * @param {string|null} color - * @param {'ruby'|'rt'|null} rubyTag - */ -function createFormattedShakaCue(startTime, endTime, text, bold, italic, underline, color, rubyTag) { - const cue = new ShakaCue(startTime, endTime, replaceCueTextEscapeSequences(text)) - - if (bold) { - cue.fontWeight = ShakaCue.fontWeight.BOLD - } - - if (italic) { - cue.fontStyle = ShakaCue.fontStyle.ITALIC - } - - if (underline) { - cue.textDecoration = [ShakaCue.textDecoration.UNDERLINE] - } - - if (color !== null && color.length > 0) { - // even though we can't directly access the style section in the vtt file - // YouTube uses predictable class names for their colour classes: - // either the name of a colour e.g. "c.yellow" or the hex values e.g. "c.colorEEEEEE" - // (I checked the style section in one of their VTT files to verify that) - - if (color.startsWith('color')) { - cue.color = color.replace('color', '#') - } else { - cue.color = color - } - } - - if (rubyTag !== null) { - cue.rubyTag = rubyTag - } - - return cue -} - -/** - * @param {VTTCue} vttCue - * @param {shaka.text.Cue} shakaCue - * @param {boolean} ignoreTextAlignAndPosition - */ -function copyFromVttCueToShakaCue(vttCue, shakaCue, ignoreTextAlignAndPosition) { - shakaCue.lineAlign = vttCue.lineAlign ?? ShakaCue.lineAlign.START - shakaCue.positionAlign = vttCue.positionAlign ?? ShakaCue.positionAlign.AUTO - shakaCue.size = vttCue.size - - switch (vttCue.vertical) { - case '': - shakaCue.writingMode = ShakaCue.writingMode.HORIZONTAL_TOP_TO_BOTTOM - break - case 'lr': - shakaCue.writingMode = ShakaCue.writingMode.VERTICAL_LEFT_TO_RIGHT - break - case 'rl': - shakaCue.writingMode = ShakaCue.writingMode.VERTICAL_RIGHT_TO_LEFT - break - } - - shakaCue.lineInterpretation = vttCue.snapToLines ? ShakaCue.lineInterpretation.LINE_NUMBER : ShakaCue.lineInterpretation.PERCENTAGE - - shakaCue.line = vttCue.line === 'auto' ? null : vttCue.line - - if (!ignoreTextAlignAndPosition) { - shakaCue.textAlign = vttCue.align - shakaCue.position = vttCue.position === 'auto' ? null : vttCue.position - } - - // only available in Firefox at the moment, but we might as well copy it, when it's there - if (vttCue.region) { - const shakaRegion = shakaCue.region - - shakaRegion.id = vttCue.region.id - shakaRegion.viewportAnchorX = vttCue.region.viewportAnchorX - shakaRegion.viewportAnchorY = vttCue.region.viewportAnchorY - shakaRegion.regionAnchorX = vttCue.region.regionAnchorX - shakaRegion.regionAnchorY = vttCue.region.regionAnchorY - shakaRegion.width = vttCue.region.width - shakaRegion.height = vttCue.region.lines - shakaRegion.heightUnits = shaka.text.CueRegion.units.LINES - shakaRegion.scroll = vttCue.region.scroll - } -} - -/** - * @param {string} text - * @returns {string} - */ -function replaceCueTextEscapeSequences(text) { - return text.replaceAll(/&(amp|lt|gt|lrm|rlm|nbsp);/g, escapeSequenceReplacer) -} - -/** - * @param {string} _match - * @param {string} sequence - * @returns {string} - */ -function escapeSequenceReplacer(_match, sequence) { - switch (sequence) { - case 'amp': - return '&' - case 'lt': - return '<' - case 'gt': - return '>' - case 'lrm': - return '\u200E' - case 'rlm': - return '\u200F' - case 'nbsp': - return '\u00A0' - } -} diff --git a/src/renderer/helpers/player/utils.js b/src/renderer/helpers/player/utils.js index 9aeb4509486ef..8085d87cb6db4 100644 --- a/src/renderer/helpers/player/utils.js +++ b/src/renderer/helpers/player/utils.js @@ -104,7 +104,7 @@ export function translateSponsorBlockCategory(category) { case 'outro': return i18n.t('Video.Sponsor Block category.outro') case 'recap': - return this.$t('Video.Sponsor Block category.recap') + return i18n.t('Video.Sponsor Block category.recap') case 'selfpromo': return i18n.t('Video.Sponsor Block category.self-promotion') case 'interaction': @@ -317,3 +317,23 @@ export function repairInvidiousManifest(periods) { } } } + +/** + * @param {shaka.extern.TrackList} variants + * @param {number} bandwidthToMatch + */ +export function findMostSimilarAudioBandwidth(variants, bandwidthToMatch) { + let closestAudioBandwithDifference = Infinity + let closestVariant + + for (const variant of variants) { + const difference = Math.abs(variant.audioBandwidth - bandwidthToMatch) + + if (difference < closestAudioBandwithDifference) { + closestAudioBandwithDifference = difference + closestVariant = variant + } + } + + return closestVariant +} diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js index cc02b4c60d9c2..08203d0b6ca24 100644 --- a/src/renderer/helpers/utils.js +++ b/src/renderer/helpers/utils.js @@ -852,3 +852,44 @@ export function base64EncodeUtf8(text) { const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join('') return btoa(binString) } + +/** + * @overload + * @param {string} channelId + * @param {'videos' | 'live' | 'shorts'} type + * @param {'newest' | 'popular'} sortBy + * @returns {string} + * + * @overload + * @param {string} channelId + * @param {'all'} type + * @returns {string} +* + * @param {string} channelId + * @param {'all' | 'videos' | 'live' | 'shorts'} type + * @param {'newest' | 'popular'} sortBy + */ +export function getChannelPlaylistId(channelId, type, sortBy) { + switch (type) { + case 'videos': + if (sortBy === 'popular') { + return channelId.replace(/^UC/, 'UULP') + } else { + return channelId.replace(/^UC/, 'UULF') + } + case 'live': + if (sortBy === 'popular') { + return channelId.replace(/^UC/, 'UULV') + } else { + return channelId.replace(/^UC/, 'UUPV') + } + case 'shorts': + if (sortBy === 'popular') { + return channelId.replace(/^UC/, 'UUPS') + } else { + return channelId.replace(/^UC/, 'UUSH') + } + case 'all': + return channelId.replace(/^UC/, 'UU') + } +} diff --git a/src/renderer/i18n/index.js b/src/renderer/i18n/index.js index 79cb207788798..3feed824a7cce 100644 --- a/src/renderer/i18n/index.js +++ b/src/renderer/i18n/index.js @@ -50,4 +50,24 @@ export async function loadLocale(locale) { i18n.setLocaleMessage(locale, data) } +// Set by _scripts/ProcessLocalesPlugin.js +if (process.env.HOT_RELOAD_LOCALES) { + const websocket = new WebSocket('ws://localhost:9080/ws') + + websocket.onmessage = (event) => { + const message = JSON.parse(event.data) + + if (message.type === 'freetube-locale-update') { + for (const [locale, data] of message.data) { + // Only update locale data if it was already loaded + if (i18n.availableLocales.includes(locale)) { + const localeData = JSON.parse(data) + + i18n.setLocaleMessage(locale, localeData) + } + } + } + } +} + export default i18n diff --git a/src/renderer/router/index.js b/src/renderer/router/index.js index 2185a7d4878ce..f0a0a2ec4cb60 100644 --- a/src/renderer/router/index.js +++ b/src/renderer/router/index.js @@ -14,6 +14,7 @@ import Playlist from '../views/Playlist/Playlist.vue' import Channel from '../views/Channel/Channel.vue' import Watch from '../views/Watch/Watch.vue' import Hashtag from '../views/Hashtag/Hashtag.vue' +import Post from '../views/Post/Post.vue' Vue.use(Router) @@ -133,6 +134,13 @@ const router = new Router({ title: 'Hashtag' }, component: Hashtag + }, + { + path: '/post/:id', + meta: { + title: 'Post', + }, + component: Post } ], scrollBehavior(to, from, savedPosition) { diff --git a/src/renderer/store/modules/history.js b/src/renderer/store/modules/history.js index 59d7fa1fc3213..b99b63a1c2b4a 100644 --- a/src/renderer/store/modules/history.js +++ b/src/renderer/store/modules/history.js @@ -45,6 +45,27 @@ const actions = { } }, + /** + * @param {any} param0 + * @param {Map} historyItems + */ + async overwriteHistory({ commit }, historyItems) { + try { + const sortedRecords = Array.from(historyItems.values()) + + // sort before sending saving to the database and passing to other windows + // so that the other windows can use it as is, without having to sort the array themselves + sortedRecords.sort((a, b) => b.timeWatched - a.timeWatched) + + await DBHistoryHandlers.overwrite(sortedRecords) + + commit('setHistoryCacheSorted', sortedRecords) + commit('setHistoryCacheById', Object.fromEntries(historyItems)) + } catch (errMessage) { + console.error(errMessage) + } + }, + async removeFromHistory({ commit }, videoId) { try { await DBHistoryHandlers.delete(videoId) diff --git a/src/renderer/store/modules/profiles.js b/src/renderer/store/modules/profiles.js index 70ca26b8aaa18..9e55e61e3f953 100644 --- a/src/renderer/store/modules/profiles.js +++ b/src/renderer/store/modules/profiles.js @@ -113,7 +113,11 @@ const actions = { } if (channelThumbnailUrl) { - const thumbnail = channelThumbnailUrl.replace(/=s\d*/, '=s176') // change thumbnail size if different + const thumbnail = channelThumbnailUrl + // change thumbnail size if different + .replace(/=s\d*/, '=s176') + // If this is an Invidious URL, convert it to a YouTube one + .replace(/^https?:\/\/[^/]+\/ggpht/, 'https://yt3.googleusercontent.com') if (channel.thumbnail !== thumbnail) { channel.thumbnail = thumbnail @@ -129,7 +133,12 @@ const actions = { }, async updateSubscriptionDetails({ dispatch, state }, { channelThumbnailUrl, channelName, channelId }) { - const thumbnail = channelThumbnailUrl?.replace(/=s\d*/, '=s176') ?? null // change thumbnail size if different + const thumbnail = channelThumbnailUrl + // change thumbnail size if different + ?.replace(/=s\d*/, '=s176') + // If this is an Invidious URL, convert it to a YouTube one + .replace(/^https?:\/\/[^/]+\/ggpht/, 'https://yt3.googleusercontent.com') ?? + null const profileList = state.profileList for (const profile of profileList) { const currentProfileCopy = deepCopy(profile) @@ -173,6 +182,11 @@ const actions = { }, async addChannelToProfiles({ commit }, { channel, profileIds }) { + // If this is an Invidious URL, convert it to a YouTube one + if (!channel.thumbnail.startsWith('https://yt3.googleusercontent.com/')) { + channel.thumbnail = channel.thumbnail.replace(/^https?:\/\/[^/]+\/ggpht/, 'https://yt3.googleusercontent.com') + } + try { await DBProfileHandlers.addChannelToProfiles(channel, profileIds) commit('addChannelToProfiles', { channel, profileIds }) diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index 928b0eb3ccb26..d272f07d317dd 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -193,7 +193,6 @@ const state = { externalPlayerIgnoreDefaultArgs: false, externalPlayerCustomArgs: '', expandSideBar: false, - forceLocalBackendForLegacy: false, hideActiveSubscriptions: false, hideChannelCommunity: false, hideChannelPlaylists: false, @@ -247,7 +246,7 @@ const state = { sponsorBlockShowSkippedToast: true, sponsorBlockUrl: 'https://sponsor.ajay.app', sponsorBlockSponsor: { - color: 'Blue', + color: 'Green', skip: 'autoSkip' }, sponsorBlockSelfPromo: { @@ -255,19 +254,19 @@ const state = { skip: 'showInSeekBar' }, sponsorBlockInteraction: { - color: 'Green', + color: 'Pink', skip: 'showInSeekBar' }, sponsorBlockIntro: { - color: 'Orange', + color: 'Cyan', skip: 'doNothing' }, sponsorBlockOutro: { - color: 'Orange', + color: 'Blue', skip: 'doNothing' }, sponsorBlockRecap: { - color: 'Orange', + color: 'Indigo', skip: 'doNothing' }, sponsorBlockMusicOffTopic: { @@ -275,7 +274,7 @@ const state = { skip: 'doNothing' }, sponsorBlockFiller: { - color: 'Orange', + color: 'Purple', skip: 'doNothing' }, thumbnailPreference: '', @@ -477,6 +476,18 @@ const customActions = { commit('upsertToHistoryCache', data) break + case SyncEvents.HISTORY.OVERWRITE: { + const byId = {} + data.forEach(video => { + byId[video.videoId] = video + }) + + // It comes pre-sorted, so we don't have to sort it here + commit('setHistoryCacheSorted', data) + commit('setHistoryCacheById', byId) + break + } + case SyncEvents.HISTORY.UPDATE_WATCH_PROGRESS: commit('updateRecordWatchProgressInHistoryCache', data) break diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js index ce9e710e4863d..f257c98018cc4 100644 --- a/src/renderer/store/modules/utils.js +++ b/src/renderer/store/modules/utils.js @@ -472,11 +472,13 @@ const actions = { const hashtagPattern = /^\/hashtag\/(?[^#&/?]+)$/ + const postPattern = /^\/post\/(?.+)/ const typePatterns = new Map([ ['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/], ['search', /^\/results|search\/?$/], ['hashtag', hashtagPattern], - ['channel', channelPattern] + ['channel', channelPattern], + ['post', postPattern] ]) for (const [type, pattern] of typePatterns) { @@ -553,6 +555,17 @@ const actions = { hashtag } } + + case 'post': { + const match = url.pathname.match(postPattern) + const postId = match.groups.postId + const query = { authorId: url.searchParams.get('ucid') } + return { + urlType: 'post', + postId, + query + } + } /* Using RegExp named capture groups from ES2018 To avoid access to specific captured value broken @@ -610,6 +623,16 @@ const actions = { subPath = 'about' break case 'community': + if (url.searchParams.has('lb')) { + // if it has the lb search parameter then it is linking a specific community post + const postId = url.searchParams.get('lb') + const query = { authorId: channelId } + return { + urlType: 'post', + postId, + query + } + } subPath = 'community' break default: diff --git a/src/renderer/views/About/About.js b/src/renderer/views/About/About.js index 54b1c34fc8833..d47e65713089e 100644 --- a/src/renderer/views/About/About.js +++ b/src/renderer/views/About/About.js @@ -21,7 +21,7 @@ export default defineComponent({ { icon: ['fab', 'github'], title: this.$t('About.Source code'), - content: `GitHub: FreeTubeApp/FreeTube
${this.$t('About.Licensed under the')} ${this.$t('About.AGPLv3')}` + content: `GitHub: FreeTubeApp/FreeTube
${this.$t('About.Licensed under the')} ${this.$t('About.AGPLv3')}` }, { icon: ['fas', 'file-download'], diff --git a/src/renderer/views/Channel/Channel.js b/src/renderer/views/Channel/Channel.js index 952d879976cb7..752cab41f063e 100644 --- a/src/renderer/views/Channel/Channel.js +++ b/src/renderer/views/Channel/Channel.js @@ -16,6 +16,7 @@ import { copyToClipboard, extractNumberFromString, showToast, + getChannelPlaylistId, getIconForSortPreference } from '../../helpers/utils' import { isNullOrEmpty } from '../../helpers/strings' @@ -39,7 +40,9 @@ import { parseLocalListPlaylist, parseLocalListVideo, parseLocalSubscriberCount, - getLocalArtistTopicChannelReleasesContinuation + getLocalArtistTopicChannelReleasesContinuation, + getLocalPlaylist, + parseLocalPlaylistVideo } from '../../helpers/api/local' import { isNavigationFailure, NavigationFailureType } from 'vue-router' @@ -109,11 +112,6 @@ export default defineComponent({ errorMessage: '', showSearchBar: true, showShareMenu: true, - videoLiveShortSelectValues: [ - 'newest', - 'popular', - 'oldest' - ], playlistSelectValues: [ 'newest', 'last' @@ -180,7 +178,29 @@ export default defineComponent({ return profileList[0].subscriptions.some((channel) => channel.id === this.id) }, + videoLiveShortSelectValues: function () { + if (this.isArtistTopicChannel) { + return [ + 'newest', + 'popular', + ] + } + + return [ + 'newest', + 'popular', + 'oldest' + ] + }, + videoLiveShortSelectNames: function () { + if (this.isArtistTopicChannel) { + return [ + this.$t('Channel.Videos.Sort Types.Newest'), + this.$t('Channel.Videos.Sort Types.Most Popular'), + ] + } + return [ this.$t('Channel.Videos.Sort Types.Newest'), this.$t('Channel.Videos.Sort Types.Most Popular'), @@ -334,7 +354,7 @@ export default defineComponent({ if (this.id === '@@@') { this.showShareMenu = false - this.setErrorMessage(this.$i18n.t('Channel.This channel does not exist')) + this.setErrorMessage(this.$t('Channel.This channel does not exist')) return } @@ -434,7 +454,7 @@ export default defineComponent({ if (this.id === '@@@') { this.showShareMenu = false - this.setErrorMessage(this.$i18n.t('Channel.This channel does not exist')) + this.setErrorMessage(this.$t('Channel.This channel does not exist')) return } @@ -630,7 +650,7 @@ export default defineComponent({ } const tabs = ['about'] - if (channel.has_videos) { + if (channel.has_videos || this.isArtistTopicChannel) { tabs.push('videos') this.getChannelVideosLocal() } @@ -704,7 +724,7 @@ export default defineComponent({ } }, - getChannelAboutLocal: async function (channel) { + getChannelAboutLocal: async function () { try { /** * @type {import('youtubei.js').YT.Channel} @@ -762,26 +782,43 @@ export default defineComponent({ const expectedId = this.id try { - /** - * @type {import('youtubei.js').YT.Channel} - */ - const channel = this.channelInstance - let videosTab = await channel.getVideos() + if (this.isArtistTopicChannel) { + // Artist topic channels don't have a videos tab. + // Interestingly the auto-generated uploads playlists do exist for those channels, + // so we'll use them instead. - this.showVideoSortBy = videosTab.filters.length > 1 + const playlistId = getChannelPlaylistId(this.id, 'videos', this.videoSortBy) + const playlist = await getLocalPlaylist(playlistId) - if (this.showVideoSortBy && this.videoSortBy !== 'newest') { - const index = this.videoLiveShortSelectValues.indexOf(this.videoSortBy) - videosTab = await videosTab.applyFilter(videosTab.filters[index]) - } + if (expectedId !== this.id) { + return + } - if (expectedId !== this.id) { - return - } + this.latestVideos = playlist.items.map(parseLocalPlaylistVideo) + this.videoContinuationData = playlist.has_continuation ? playlist : null + this.isElementListLoading = false + } else { + /** + * @type {import('youtubei.js').YT.Channel} + */ + const channel = this.channelInstance + let videosTab = await channel.getVideos() - this.latestVideos = parseLocalChannelVideos(videosTab.videos, this.id, this.channelName) - this.videoContinuationData = videosTab.has_continuation ? videosTab : null - this.isElementListLoading = false + this.showVideoSortBy = videosTab.filters.length > 1 + + if (this.showVideoSortBy && this.videoSortBy !== 'newest') { + const index = this.videoLiveShortSelectValues.indexOf(this.videoSortBy) + videosTab = await videosTab.applyFilter(videosTab.filters[index]) + } + + if (expectedId !== this.id) { + return + } + + this.latestVideos = parseLocalChannelVideos(videosTab.videos, this.id, this.channelName) + this.videoContinuationData = videosTab.has_continuation ? videosTab : null + this.isElementListLoading = false + } if (this.isSubscribedInAnyProfile && this.latestVideos.length > 0 && this.videoSortBy === 'newest') { this.updateSubscriptionVideosCacheByChannel({ @@ -793,6 +830,11 @@ export default defineComponent({ }) } } catch (err) { + if (this.isArtistTopicChannel && err.message === 'The playlist does not exist.') { + // If this artist topic channel doesn't have any videos, ignore the error. + return + } + console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${err}`, 10000, () => { @@ -809,13 +851,21 @@ export default defineComponent({ channelLocalNextPage: async function () { try { - /** - * @type {import('youtubei.js').YT.ChannelListContinuation|import('youtubei.js').YT.FilteredChannelList} - */ - const continuation = await this.videoContinuationData.getContinuation() + if (this.isArtistTopicChannel) { + /** @type {import('youtubei.js').YT.Playlist} */ + const continuation = await this.videoContinuationData.getContinuation() + + this.latestVideos = this.latestVideos.concat(continuation.items.map(parseLocalPlaylistVideo)) + this.videoContinuationData = continuation.has_continuation ? continuation : null + } else { + /** + * @type {import('youtubei.js').YT.ChannelListContinuation|import('youtubei.js').YT.FilteredChannelList} + */ + const continuation = await this.videoContinuationData.getContinuation() - this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.id, this.channelName)) - this.videoContinuationData = continuation.has_continuation ? continuation : null + this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.id, this.channelName)) + this.videoContinuationData = continuation.has_continuation ? continuation : null + } } catch (err) { console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') diff --git a/src/renderer/views/Playlist/Playlist.js b/src/renderer/views/Playlist/Playlist.js index aaf1cd290c0c5..81d9f0f687edd 100644 --- a/src/renderer/views/Playlist/Playlist.js +++ b/src/renderer/views/Playlist/Playlist.js @@ -1,4 +1,4 @@ -import { defineComponent } from 'vue' +import { defineComponent, nextTick } from 'vue' import { mapActions, mapMutations } from 'vuex' import debounce from 'lodash.debounce' import FtLoader from '../../components/ft-loader/ft-loader.vue' @@ -428,7 +428,7 @@ export default defineComponent({ // Stop users from spamming the load more button, by replacing it with a loading symbol until the newly added items are renderered this.isLoadingMore = true - setTimeout(() => { + nextTick(() => { if (this.userPlaylistVisibleLimit + 100 < this.videoCount) { this.userPlaylistVisibleLimit += 100 } else { diff --git a/src/renderer/views/Post/Post.js b/src/renderer/views/Post/Post.js new file mode 100644 index 0000000000000..d13bb906bbf95 --- /dev/null +++ b/src/renderer/views/Post/Post.js @@ -0,0 +1,62 @@ +import { defineComponent } from 'vue' +import FtCard from '../../components/ft-card/ft-card.vue' +import FtCommunityPost from '../../components/ft-community-post/ft-community-post.vue' +import FtLoader from '../../components/ft-loader/ft-loader.vue' +import WatchVideoComments from '../../components/watch-video-comments/watch-video-comments.vue' + +import { getInvidiousCommunityPost } from '../../helpers/api/invidious' + +export default defineComponent({ + name: 'Post', + components: { + FtCard, + FtCommunityPost, + FtLoader, + WatchVideoComments + }, + data: function () { + return { + id: '', + authorId: '', + post: null, + comments: null, + isLoading: true, + } + }, + computed: { + backendPreference: function () { + return this.$store.getters.getBackendPreference + }, + backendFallback: function () { + return this.$store.getters.getBackendFallback + }, + isInvidiousAllowed: function() { + return this.backendPreference === 'invidious' || this.backendFallback + } + }, + watch: { + async $route() { + // react to route changes... + this.isLoading = true + if (this.isInvidiousAllowed) { + this.id = this.$route.params.id + this.authorId = this.$route.query.authorId + await this.loadDataInvidiousAsync() + } + } + }, + mounted: async function () { + if (this.isInvidiousAllowed) { + this.id = this.$route.params.id + this.authorId = this.$route.query.authorId + await this.loadDataInvidiousAsync() + } + }, + methods: { + loadDataInvidiousAsync: async function() { + this.post = await getInvidiousCommunityPost(this.id, this.authorId) + this.authorId = this.post.authorId + this.isLoading = false + } + } +}) diff --git a/src/renderer/views/Post/Post.vue b/src/renderer/views/Post/Post.vue new file mode 100644 index 0000000000000..22b316ebaeaa7 --- /dev/null +++ b/src/renderer/views/Post/Post.vue @@ -0,0 +1,31 @@ + + +