From 549927fe0306b01bdef3bf43c63b950cde83c30c Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:22:20 +0200 Subject: [PATCH 01/11] Local API: Implement SABR for VODs --- package.json | 1 + .../ft-shaka-video-player.js | 260 ++++--- .../ft-shaka-video-player.vue | 4 +- src/renderer/helpers/api/local.js | 39 +- src/renderer/helpers/player/EbmlParser.js | 276 +++++++ .../helpers/player/Mp4SegmentIndexParser.js | 178 +++++ .../helpers/player/SabrManifestParser.js | 703 ++++++++++++++++++ .../helpers/player/SabrSchemePlugin.js | 499 +++++++++++++ .../helpers/player/WebmSegmentIndexParser.js | 338 +++++++++ src/renderer/helpers/player/utils.js | 2 +- src/renderer/views/Watch/Watch.js | 148 +++- src/renderer/views/Watch/Watch.vue | 1 + yarn.lock | 7 + 13 files changed, 2321 insertions(+), 135 deletions(-) create mode 100644 src/renderer/helpers/player/EbmlParser.js create mode 100644 src/renderer/helpers/player/Mp4SegmentIndexParser.js create mode 100644 src/renderer/helpers/player/SabrManifestParser.js create mode 100644 src/renderer/helpers/player/SabrSchemePlugin.js create mode 100644 src/renderer/helpers/player/WebmSegmentIndexParser.js diff --git a/package.json b/package.json index d8dced20385ae..1a4579a09babb 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "autolinker": "^4.1.0", "bgutils-js": "^3.2.0", "electron-context-menu": "^4.0.5", + "googlevideo": "^3.0.0", "marked": "^15.0.7", "path-browserify": "^1.0.1", "portal-vue": "^2.1.7", 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 68230be2fe75c..b1a498d0a63f6 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 @@ -16,7 +16,6 @@ import { getSponsorBlockSegments, logShakaError, repairInvidiousManifest, - sortCaptions, translateSponsorBlockCategory } from '../../helpers/player/utils' import { @@ -24,6 +23,8 @@ import { showToast, writeFileWithPicker } from '../../helpers/utils' +import { MANIFEST_TYPE_SABR } from '../../helpers/player/SabrManifestParser' +import { setupSabrScheme } from '../../helpers/player/SabrSchemePlugin' /** @typedef {import('../../helpers/sponsorblock').SponsorBlockCategory} SponsorBlockCategory */ @@ -71,6 +72,10 @@ export default defineComponent({ type: String, required: true }, + sabrData: { + type: Object, + default: null + }, legacyFormats: { type: Array, default: () => ([]) @@ -188,31 +193,10 @@ export default defineComponent({ let startInFullscreen = props.startInFullscreen let startInPip = props.startInPip - /** - * @type {{ - * url: string, - * label: string, - * language: string, - * mimeType: string, - * isAutotranslated?: boolean - * }[]} - */ - let sortedCaptions - - // we don't need to sort if we only have one caption or don't have any - if (props.captions.length > 1) { - // theoretically we would resort when the language changes, but we can't remove captions that we already added to the player - sortedCaptions = sortCaptions(props.captions) - } else if (props.captions.length === 1) { - sortedCaptions = props.captions - } else { - sortedCaptions = [] - } - /** @type {number|null} */ let restoreCaptionIndex = null - if (store.getters.getEnableSubtitlesByDefault && sortedCaptions.length > 0) { + if (store.getters.getEnableSubtitlesByDefault && props.captions.length > 0) { restoreCaptionIndex = 0 } @@ -223,10 +207,6 @@ export default defineComponent({ height: 0, frameRate: 0 }, - playerDimensions: { - width: 0, - height: 0 - }, bitrate: '0', volume: '100', bandwidth: '0', @@ -243,6 +223,11 @@ export default defineComponent({ } }) + const playerDimensions = computed(() => ({ + width: playerWidth.value, + height: playerHeight.value + })) + // #region settings /** @type {import('vue').ComputedRef} */ @@ -791,7 +776,7 @@ export default defineComponent({ trackLabelFormat: hasMultipleAudioTracks.value ? TrackLabelFormat.LABEL : TrackLabelFormat.LANGUAGE, // Only set it to label if we added the captions ourselves, // some live streams come with subtitles in the DASH manifest, but without labels - textTrackLabelFormat: sortedCaptions.length > 0 ? TrackLabelFormat.LABEL : TrackLabelFormat.LANGUAGE, + textTrackLabelFormat: props.captions.length > 0 ? TrackLabelFormat.LABEL : TrackLabelFormat.LANGUAGE, displayInVrMode: useVrMode.value } @@ -1045,7 +1030,7 @@ export default defineComponent({ }) /** @type {ResizeObserver|null} */ - let resizeObserver = null + let containerResizeObserver = null /** @type {ResizeObserverCallback} */ function resized(entries) { @@ -1207,8 +1192,68 @@ export default defineComponent({ } } + const videoElementWidth = ref(0) + const videoElementHeight = ref(0) + + /** @type {ResizeObserver} */ + const videoResizeObserver = new ResizeObserver(() => { + if (video.value) { + const devicePixelRatio = window.devicePixelRatio > 1 ? window.devicePixelRatio : 1 + const video_ = video.value + + videoElementWidth.value = video_.clientWidth * devicePixelRatio + videoElementHeight.value = video_.clientHeight * devicePixelRatio + } + }) + + /** @type {PictureInPictureWindow | null} */ + let pipWindow = null + const pipWindowWidth = ref(null) + const pipWindowHeight = ref(null) + + /** + * @param {PictureInPictureEvent} event + */ + function handleEnterPictureInPicture(event) { + pipWindow = event.pictureInPictureWindow + handlePictureInPictureResize() + pipWindow.addEventListener('resize', handlePictureInPictureResize) + } + + function handleLeavePictureInPicture() { + pipWindow.removeEventListener('resize', handlePictureInPictureResize) + + pipWindow = null + pipWindowWidth.value = null + pipWindowHeight.value = null + } + + function handlePictureInPictureResize() { + const devicePixelRatio = window.devicePixelRatio > 1 ? window.devicePixelRatio : 1 + + pipWindowWidth.value = pipWindow.width * devicePixelRatio + pipWindowHeight.value = pipWindow.height * devicePixelRatio + } + + const playerWidth = computed(() => pipWindowWidth.value ?? videoElementWidth.value) + const playerHeight = computed(() => pipWindowHeight.value ?? videoElementHeight.value) + // #endregion video event handlers + // #region SABR + + /** @type {shaka.extern.Manifest | undefined} */ + let sabrManifest + + /** @type {() => void | undefined} */ + let cleanupSabrScheme + + if (props.sabrData) { + cleanupSabrScheme = /** @__NOINLINE__ */ setupSabrScheme(props.sabrData, () => player, () => sabrManifest, playerWidth, playerHeight) + } + + // #endregion SABR + // #region request/response filters /** @type {shaka.extern.RequestFilter} */ @@ -1218,7 +1263,7 @@ export default defineComponent({ // only when we aren't proxying through Invidious, // it doesn't like the range param and makes get requests to youtube anyway - if (url.hostname.endsWith('.googlevideo.com') && url.pathname === '/videoplayback') { + if (url.protocol !== 'sabr:' && url.hostname.endsWith('.googlevideo.com') && url.pathname === '/videoplayback') { request.method = 'POST' request.body = new Uint8Array([0x78, 0]) // protobuf: { 15: 0 } (no idea what it means but this is what YouTube uses) @@ -1232,13 +1277,15 @@ export default defineComponent({ } } - /** - * Handles Application Level Redirects - * Based on the example in the YouTube.js repository - * @type {shaka.extern.ResponseFilter} - */ + /** @type {shaka.extern.ResponseFilter} */ async function responseFilter(type, response, context) { if (type === RequestType.SEGMENT) { + const url = new URL(response.uri) + + if (url.protocol === 'sabr:') { + return + } + if (response.data && response.data.byteLength > 4 && new DataView(response.data).getUint32(0) === HTTP_IN_HEX) { // Interpret the response data as a URL string. @@ -1258,8 +1305,6 @@ export default defineComponent({ response.headers = redirectResponse.headers response.uri = redirectResponse.uri } else { - const url = new URL(response.uri) - // Fix positioning for auto-generated subtitles 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') { @@ -1434,12 +1479,6 @@ export default defineComponent({ updateLegacyQualityStats(activeLegacyFormat.value) } - const playerDimensions = video_.getBoundingClientRect() - stats.playerDimensions = { - width: Math.floor(playerDimensions.width), - height: Math.floor(playerDimensions.height) - } - if (!hasLoaded.value) { player.addEventListener('loaded', () => { if (showStats.value) { @@ -1495,14 +1534,13 @@ export default defineComponent({ stats.resolution.width = newTrack.width stats.resolution.height = newTrack.height } else { - // for videos with multiple audio tracks, youtube.js appends the track id to the itag, to make it unique stats.codecs.audioItag = newTrack.originalAudioId.split('-')[0] stats.codecs.audioCodec = newTrack.audioCodec if (props.format === 'dash') { stats.resolution.frameRate = newTrack.frameRate - stats.codecs.videoItag = newTrack.originalVideoId + stats.codecs.videoItag = newTrack.originalVideoId.split('-')[0] stats.codecs.videoCodec = newTrack.videoCodec stats.resolution.width = newTrack.width @@ -1535,12 +1573,6 @@ export default defineComponent({ } function updateStats() { - const playerDimensions = video.value.getBoundingClientRect() - stats.playerDimensions = { - width: Math.floor(playerDimensions.width), - height: Math.floor(playerDimensions.height) - } - const playerStats = player.getStats() if (props.format !== 'audio') { @@ -2468,6 +2500,8 @@ export default defineComponent({ return } + videoResizeObserver.observe(videoElement) + registerScreenshotButton() registerAudioTrackSelection() registerAutoplayToggle() @@ -2482,8 +2516,8 @@ export default defineComponent({ } else { useOverFlowMenu.value = container.value.getBoundingClientRect().width <= USE_OVERFLOW_MENU_WIDTH_THRESHOLD - resizeObserver = new ResizeObserver(resized) - resizeObserver.observe(container.value) + containerResizeObserver = new ResizeObserver(resized) + containerResizeObserver.observe(container.value) } controls.addEventListener('uiupdated', addUICustomizations) @@ -2574,40 +2608,59 @@ export default defineComponent({ // ideally we would set this in the `streaming` event handler, but for HLS this is only set to true after the loaded event fires. isLive.value = player.isLive() - const promises = [] - - for (const caption of sortedCaptions) { - 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() - - // position:0% for LTR text and position:100% for RTL text - text = text.replaceAll(/ align:start position:(?:10)?0%$/gm, '') - - const url = `data:${caption.mimeType};charset=utf-8,${encodeURIComponent(text)}` - - await player.addTextTrackAsync( - url, + if (props.format !== 'legacy' && props.manifestMimeType === MANIFEST_TYPE_SABR) { + sabrManifest = player.getManifest() + } + + // For SABR we include the thumbnails and subtitles in the manifest + if (props.format === 'legacy' || props.manifestMimeType !== MANIFEST_TYPE_SABR) { + const promises = [] + + for (const caption of props.captions) { + 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() + + // position:0% for LTR text and position:100% for RTL text + text = text.replaceAll(/ align:start position:(?:10)?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) { - if (error instanceof shaka.util.Error) { - handleError(error, 'addTextTrackAsync', caption) - } else { - console.error(error) - } - } - })()) + .catch(error => handleError(error, 'addTextTrackAsync', caption)) + ) + } } else { promises.push( player.addTextTrackAsync( @@ -2621,32 +2674,20 @@ export default defineComponent({ .catch(error => handleError(error, 'addTextTrackAsync', caption)) ) } - } else { + } + + if (!isLive.value && props.storyboardSrc) { 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)) + // Only log the error, as the thumbnails are a nice to have + // If an error occurs with them, it's not critical + player.addThumbnailsTrack(props.storyboardSrc, 'text/vtt') + .catch(error => logShakaError(error, 'addThumbnailsTrack', props.videoId, props.storyboardSrc)) ) } - } - if (!isLive.value && props.storyboardSrc) { - promises.push( - // Only log the error, as the thumbnails are a nice to have - // If an error occurs with them, it's not critical - player.addThumbnailsTrack(props.storyboardSrc, 'text/vtt') - .catch(error => logShakaError(error, 'addThumbnailsTrack', props.videoId, props.storyboardSrc)) - ) + await Promise.all(promises) } - await Promise.all(promises) - if (restoreCaptionIndex !== null) { const index = restoreCaptionIndex restoreCaptionIndex = null @@ -2833,9 +2874,13 @@ export default defineComponent({ document.removeEventListener('keydown', keyboardShortcutHandler) - if (resizeObserver) { - resizeObserver.disconnect() - resizeObserver = null + if (containerResizeObserver) { + containerResizeObserver.disconnect() + containerResizeObserver = null + } + + if (videoResizeObserver) { + videoResizeObserver.disconnect() } cleanUpCustomPlayerControls() @@ -2908,6 +2953,10 @@ export default defineComponent({ player = null } + if (cleanupSabrScheme) { + cleanupSabrScheme() + } + // 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) { @@ -2962,6 +3011,7 @@ export default defineComponent({ showStats, stats, + playerDimensions, autoplayVideos, sponsorBlockShowSkippedToast, @@ -2976,6 +3026,8 @@ export default defineComponent({ handleEnded, updateVolume, handleTimeupdate, + handleEnterPictureInPicture, + handleLeavePictureInPicture, valueChangeMessage, valueChangeIcon, 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 f1e8cc8d86e8f..f74b2287879e5 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 @@ -22,6 +22,8 @@ @canplay="handleCanPlay" @volumechange="updateVolume" @timeupdate="handleTimeupdate" + @enterpictureinpicture="handleEnterPictureInPicture" + @leavepictureinpicture="handleLeavePictureInPicture" />