diff --git a/materialious/src/lib/api/model.ts b/materialious/src/lib/api/model.ts index f28267e9..55feb8dc 100644 --- a/materialious/src/lib/api/model.ts +++ b/materialious/src/lib/api/model.ts @@ -80,6 +80,13 @@ export interface Captions { url: string; } +export interface Ytjs { + innertube: Innertube; + video: YT.VideoInfo; + clientPlaybackNonce: string; + rawApiResponse: ApiResponse; +} + export interface VideoPlay extends Video { keywords: string[]; likeCount: number; @@ -100,12 +107,7 @@ export interface VideoPlay extends Video { authorThumbnails: Image[]; captions: Captions[]; storyboards?: StoryBoard[]; - ytjs?: { - innertube: Innertube; - video: YT.VideoInfo; - clientPlaybackNonce: string; - rawApiResponse: ApiResponse; - }; + ytjs?: Ytjs; fallbackPatch?: 'youtubejs' | 'piped'; } diff --git a/materialious/src/lib/components/Player.svelte b/materialious/src/lib/components/Player.svelte index 9ebdb4ae..218d662b 100644 --- a/materialious/src/lib/components/Player.svelte +++ b/materialious/src/lib/components/Player.svelte @@ -9,7 +9,7 @@ import { StatusBar, Style } from '@capacitor/status-bar'; import { NavigationBar } from '@hugotomazi/capacitor-navigation-bar'; import { type Page } from '@sveltejs/kit'; - import { base64ToU8, Protos } from 'googlevideo'; + import { HttpFetchPlugin } from '$lib/sabr/shakaHttpPlugin'; import ui from 'beercss'; import ISO6391 from 'iso-639-1'; import Mousetrap from 'mousetrap'; @@ -30,7 +30,7 @@ playerDefaultQualityStore, playerProxyVideosStore, playerSavePlaybackPositionStore, - poTokenCacheStore, + playerYouTubeJsFallback, sponsorBlockCategoriesStore, sponsorBlockDisplayToastStore, sponsorBlockStore, @@ -39,14 +39,8 @@ synciousStore } from '../store'; import { getDynamicTheme, setStatusBarColor } from '../theme'; - import { HttpFetchPlugin, type SabrStreamingContext } from '$lib/sabr/shakaHttpPlugin'; - import { Constants, type Misc } from 'youtubei.js'; - import { - fromFormat, - fromFormatInitializationMetadata, - fromMediaHeader, - getUniqueFormatId - } from '$lib/sabr/formatKeyUtils'; + import { injectSABR } from '$lib/sabr'; + import { patchYoutubeJs } from '$lib/patches/youtubejs'; interface Props { data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null }; @@ -68,59 +62,40 @@ let originalOrigination: ScreenOrientationResult | undefined; let watchProgressTimeout: NodeJS.Timeout; let userWantsFullscreen = false; + let playerElementResizeObserver: ResizeObserver | undefined; + let showVideoRetry = $state(false); let player: shaka.Player; let shakaUi: shaka.ui.Overlay; - // Shaka player required variables for SABR based off LuanRT's example - const sessionId = Array.from(Array(16), () => Math.floor(Math.random() * 36).toString(36)).join( - '' - ); - let videoPlaybackUstreamerConfig: string | undefined; - let serverAbrStreamingUrl: URL | undefined = undefined; - let drmParams: string | undefined; - let lastRequestMs = 0; - let lastSeekMs = 0; - let lastManualFormatSelectionMs = 0; - let lastActionMs = 0; - let playerElementResizeObserver: ResizeObserver | undefined; - let clientViewportHeight = playerElement?.clientHeight || 0; - let clientViewportWidth = playerElement?.clientWidth || 0; - let lastPlaybackCookie: Protos.PlaybackCookie | undefined; - const initializedFormats = new Map< - string, - { - lastSegmentMetadata?: { - formatId: Protos.FormatId; - startTimeMs: number; - startSequenceNumber: number; - endSequenceNumber: number; - durationMs: number; - }; - formatInitializationMetadata: Protos.FormatInitializationMetadata; - } - >(); - let formatList: Misc.Format[] = []; - - shaka.net.NetworkingEngine.registerScheme( - 'http', - HttpFetchPlugin.parse, - shaka.net.NetworkingEngine.PluginPriority.PREFERRED - ); - shaka.net.NetworkingEngine.registerScheme( - 'https', - HttpFetchPlugin.parse, - shaka.net.NetworkingEngine.PluginPriority.PREFERRED - ); - // Shaka player SABR var end - const STORAGE_KEY_VOLUME = 'shaka-preferred-volume'; + function restoreDefaultLanguage() { + if ($playerDefaultLanguage) { + const audioLanguages = player.getAudioLanguages(); + const langCode = ISO6391.getCode($playerDefaultLanguage); + + for (const audioLanguage of audioLanguages) { + if (audioLanguage.startsWith(langCode)) { + player.selectAudioLanguage(audioLanguage); + break; + } + } + } + } + function saveVolumePreference() { if (!playerElement) return; localStorage.setItem(STORAGE_KEY_VOLUME, playerElement.volume.toString()); } + function restoreVolumePreference() { + const savedVolume = localStorage.getItem(STORAGE_KEY_VOLUME); + if (savedVolume && playerElement) { + playerElement.volume = parseFloat(savedVolume); + } + } + function restoreQualityPreference() { const numericValue = parseInt($playerDefaultQualityStore, 10); @@ -157,6 +132,97 @@ } } + async function androidHandleRotate() { + if (Capacitor.getPlatform() === 'android' && data.video.adaptiveFormats.length > 0) { + const videoFormats = data.video.adaptiveFormats.filter((format) => + format.type.startsWith('video/') + ); + + originalOrigination = await ScreenOrientation.orientation(); + + document.addEventListener('fullscreenchange', async () => { + const isFullScreen = !!document.fullscreenElement; + + if (isFullScreen) { + // Ensure bar color is black while in fullscreen + await StatusBar.setBackgroundColor({ color: '#000000' }); + await NavigationBar.setColor({ + color: '#000000' + }); + await StatusBar.setStyle({ style: Style.Dark }); + } else { + await setStatusBarColor(); + } + + if (!get(playerAndroidLockOrientation)) return; + + if (isFullScreen && videoFormats[0].resolution) { + const widthHeight = videoFormats[0].resolution.split('x'); + + if (widthHeight.length !== 2) return; + + if (Number(widthHeight[0]) > Number(widthHeight[1])) { + await StatusBar.setOverlaysWebView({ overlay: true }); + await StatusBar.hide(); + await NavigationBar.hide(); + await ScreenOrientation.lock({ orientation: 'landscape' }); + } else { + await ScreenOrientation.lock({ orientation: 'portrait' }); + } + } else { + await StatusBar.setOverlaysWebView({ overlay: false }); + await StatusBar.show(); + await NavigationBar.show(); + + await ScreenOrientation.lock({ + orientation: (originalOrigination as ScreenOrientationResult).type + }); + } + }); + } + } + + async function setupSponsorSkip() { + if (!$sponsorBlockUrlStore || !$sponsorBlockCategoriesStore || !$sponsorBlockStore) return; + + if ( + $sponsorBlockCategoriesStore.length > 0 && + $sponsorBlockUrlStore && + $sponsorBlockUrlStore !== '' + ) { + const sponsorBlock = new SponsorBlock('', { baseURL: $sponsorBlockUrlStore }); + + try { + segments = await sponsorBlock.getSegments( + data.video.videoId, + $sponsorBlockCategoriesStore as Category[] + ); + + playerElement?.addEventListener('timeupdate', () => { + segments.forEach((segment) => { + if (!playerElement) return; + + if ( + playerElement.currentTime >= segment.startTime && + playerElement.currentTime <= segment.endTime + ) { + if (Math.round(playerElement.currentTime) >= Math.round(playerElement.duration)) { + return; + } + playerElement.currentTime = segment.endTime + 1; + if (!get(sponsorBlockDisplayToastStore)) { + snackBarAlert = `${get(_)('skipping')} ${segment.category}`; + ui('#snackbar-alert'); + } + } + }); + }); + } catch (error) { + console.error('Sponsorskip errored with:', error); + } + } + } + function loadTimeFromUrl(page: Page): boolean { if (player) { const timeGivenUrl = page.url.searchParams.get('time'); @@ -172,478 +238,12 @@ page.subscribe((pageUpdate) => loadTimeFromUrl(pageUpdate)); - const proxyVideos = get(playerProxyVideosStore); - - onMount(async () => { - shaka.polyfill.installAll(); - if (!shaka.Player.isBrowserSupported()) { - return; - } - - HttpFetchPlugin.cacheManager.clearCache(); - - player = new shaka.Player(); - player.configure({ - abr: { - enabled: true, - restrictions: { - maxWidth: 1920, - maxHeight: 1080 - } - }, - streaming: { - bufferingGoal: 120, - rebufferingGoal: 0.01, - bufferBehind: 300, - retryParameters: { - maxAttempts: 30, - baseDelay: 1500, - backoffFactor: 2.5, - fuzzFactor: 0.7, - timeout: 120000 - }, - stallThreshold: 2, - stallSkip: 0.5 - } - }); - playerElement = document.getElementById('player') as HTMLMediaElement; - - playerElementResizeObserver = new ResizeObserver(() => { - if (playerElement) { - clientViewportHeight = playerElement.clientHeight; - clientViewportWidth = playerElement.clientWidth; - } - }); - - playerElementResizeObserver.observe(playerElement); - - // Change instantly to stop video from being loud for a second - const savedVolume = localStorage.getItem(STORAGE_KEY_VOLUME); - if (savedVolume) { - playerElement.volume = parseFloat(savedVolume); - } - - shakaUi = new shaka.ui.Overlay( - player, - document.getElementById('shaka-container') as HTMLElement, - playerElement - ); - - shakaUi.configure({ - controlPanelElements: [ - 'play_pause', - Capacitor.getPlatform() === 'android' ? '' : 'volume', - 'spacer', - 'chapter', - 'time_and_duration', - 'captions', - 'overflow_menu', - 'fullscreen' - ], - overflowMenuButtons: [ - 'cast', - 'airplay', - 'captions', - 'quality', - 'playback_rate', - 'loop', - 'language', - 'statistics' - ], - playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3], - enableTooltips: false, - seekBarColors: { - played: (await getDynamicTheme())['--primary'] - } - }); - - if (data.video.fallbackPatch === 'youtubejs' && data.video.ytjs) { - if ( - data.video.ytjs.rawApiResponse.data.streamingData && - (data.video.ytjs.rawApiResponse.data.streamingData as any).drmParams - ) { - drmParams = (data.video.ytjs.rawApiResponse.data.streamingData as any).drmParams; - player.configure({ - drm: { - servers: { - 'com.widevine.alpha': - 'https://www.youtube.com/youtubei/v1/player/get_drm_license?alt=json' - } - } - }); - } - - videoPlaybackUstreamerConfig = - data.video.ytjs.video.page[0].player_config?.media_common_config - .media_ustreamer_request_config?.video_playback_ustreamer_config; - - if (data.video.ytjs.video.streaming_data) { - formatList = data.video.ytjs.video.streaming_data.adaptive_formats; - } - - if (data.video.ytjs.video.streaming_data?.server_abr_streaming_url) - serverAbrStreamingUrl = new URL( - data.video.ytjs.innertube.session.player!.decipher( - data.video.ytjs.video.streaming_data.server_abr_streaming_url - ) - ); - - playerElement.addEventListener('seeked', () => (lastSeekMs = Date.now())); - - player.addEventListener('variantchanged', (event) => { - // Technically, all variant changes here are manual, given we don't handle ABR updates from the server. - if (event.type !== 'variant') { - lastManualFormatSelectionMs = Date.now(); - } - - lastActionMs = Date.now(); - }); - - player.addEventListener('error', (event) => { - const error = (event as CustomEvent).detail as shaka.util.Error; - console.error('Player error:', error); - }); - - const networkingEngine = player.getNetworkingEngine(); - - if (!networkingEngine) return; - - // Required to stop buttons from being still selected when fullscreening - document.addEventListener('fullscreenchange', async () => { - userWantsFullscreen = document.fullscreenElement !== null; - const buttons = document.querySelectorAll('.shaka-controls-button-panel button'); - buttons.forEach((button) => { - // Reset the button's focus and active states - (button as HTMLElement).blur(); // Remove focus from the button - button.removeAttribute('aria-pressed'); // Reset any ARIA attributes that might indicate selection - }); - }); - - // Based off the following - // https://github.com/FreeTubeApp/FreeTube/blob/d270c9e251a433f1e4246a3f6a37acef707d22aa/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js#L1206 - // https://github.com/LuanRT/BgUtils/blob/6b121166be1ccb0b952dee1bdac488808365ae6b/examples/browser/web/src/main.ts#L293 - // https://github.com/LuanRT/yt-sabr-shaka-demo/blob/main/src/components/VideoPlayer.vue - networkingEngine.registerRequestFilter(async (type, request, context) => { - if (!player) return; - - const originalUrl = new URL(request.uris[0]); - let url = originalUrl.hostname === 'sabr' ? serverAbrStreamingUrl : originalUrl; - const headers = request.headers; - - if (!url) return; - - if ( - type === shaka.net.NetworkingEngine.RequestType.SEGMENT && - url.pathname.includes('videoplayback') - ) { - if (!data.video.liveNow) { - const currentFormat = formatList.find( - (format) => - fromFormat(format) === (new URL(request.uris[0]).searchParams.get('___key') || '') - ); - - if (!videoPlaybackUstreamerConfig) throw new Error('Ustreamer config not found.'); - - if (!currentFormat) throw new Error('No format found for SABR request.'); - - if (!playerElement) throw new Error('No video element found.'); - - const activeVariant = player - .getVariantTracks() - .find( - (track) => - getUniqueFormatId(currentFormat) === - (currentFormat.has_video ? track.originalVideoId : track.originalAudioId) - ); - - let videoFormat: Misc.Format | undefined; - let audioFormat: Misc.Format | undefined; - let videoFormatId: Protos.FormatId | undefined; - let audioFormatId: Protos.FormatId | undefined; - - if (activeVariant) { - for (const fmt of formatList) { - const uniqueFormatId = getUniqueFormatId(fmt); - if (uniqueFormatId === activeVariant.originalVideoId) { - videoFormat = fmt; - } else if (uniqueFormatId === activeVariant.originalAudioId) { - audioFormat = fmt; - } - } - } - - if (videoFormat) { - videoFormatId = { - itag: videoFormat.itag, - lastModified: parseInt(videoFormat.last_modified_ms), - xtags: videoFormat.xtags - }; - } - - if (audioFormat) { - audioFormatId = { - itag: audioFormat.itag, - lastModified: parseInt(audioFormat.last_modified_ms), - xtags: audioFormat.xtags - }; - } - - const isInit = context ? !context.segment : true; - - const videoPlaybackAbrRequest: Protos.VideoPlaybackAbrRequest = { - clientAbrState: { - playbackRate: player.getPlaybackRate(), - playerTimeMs: Math.round( - (context?.segment?.getStartTime() ?? playerElement.currentTime) * 1000 - ), - elapsedWallTimeMs: Date.now() - lastRequestMs, - timeSinceLastSeek: lastSeekMs === 0 ? 0 : Date.now() - lastSeekMs, - timeSinceLastActionMs: lastActionMs === 0 ? 0 : Date.now() - lastActionMs, - timeSinceLastManualFormatSelectionMs: - lastManualFormatSelectionMs === 0 ? 0 : Date.now() - lastManualFormatSelectionMs, - clientViewportIsFlexible: false, - bandwidthEstimate: Math.round(player.getStats().estimatedBandwidth), - drcEnabled: currentFormat.is_drc === true, - enabledTrackTypesBitfield: currentFormat.has_audio ? 1 : 2, - clientViewportHeight, - clientViewportWidth - }, - bufferedRanges: [], - selectedFormatIds: [], - selectedAudioFormatIds: [audioFormatId || {}], - selectedVideoFormatIds: [videoFormatId || {}], - videoPlaybackUstreamerConfig: base64ToU8(videoPlaybackUstreamerConfig), - streamerContext: { - poToken: base64ToU8(get(poTokenCacheStore) ?? ''), - playbackCookie: lastPlaybackCookie - ? Protos.PlaybackCookie.encode(lastPlaybackCookie).finish() - : undefined, - clientInfo: { - clientName: parseInt(Constants.CLIENT_NAME_IDS.WEB), - clientVersion: data.video.ytjs?.innertube.session.context.client.clientVersion, - osName: 'Windows', - osVersion: '10.0' - }, - field5: [], - field6: [] - }, - field1000: [] - }; - - // Normalize the resolution. - if (currentFormat.width && currentFormat.height) { - let resolution = currentFormat.height; - - const aspectRatio = currentFormat.height / currentFormat.width; - - if (aspectRatio > 16 / 9) { - resolution = Math.round((currentFormat.width * 9) / 16); - } - - if (resolution && videoPlaybackAbrRequest.clientAbrState) { - videoPlaybackAbrRequest.clientAbrState.stickyResolution = resolution; - videoPlaybackAbrRequest.clientAbrState.lastManualSelectedResolution = resolution; - } - } - - if (!isInit) { - // Add the currently/previously active formats to the list of buffered ranges and selected formats - // so that the server doesn't send its init data again. - const initializedFormatsArray = Array.from(initializedFormats.values()); - - for (const initializedFormat of initializedFormatsArray) { - if (initializedFormat.lastSegmentMetadata) { - videoPlaybackAbrRequest.bufferedRanges.push({ - formatId: initializedFormat.lastSegmentMetadata.formatId, - startSegmentIndex: initializedFormat.lastSegmentMetadata.startSequenceNumber, - durationMs: initializedFormat.lastSegmentMetadata.durationMs, - startTimeMs: 0, - endSegmentIndex: initializedFormat.lastSegmentMetadata.endSequenceNumber - }); - } - } - - if (audioFormatId) videoPlaybackAbrRequest.selectedFormatIds.push(audioFormatId); - - if (videoFormatId) videoPlaybackAbrRequest.selectedFormatIds.push(videoFormatId); - } - - request.body = Protos.VideoPlaybackAbrRequest.encode(videoPlaybackAbrRequest).finish(); - - const byteRange = headers.Range - ? { - start: Number(headers.Range.split('=')[1].split('-')[0]), - end: Number(headers.Range.split('=')[1].split('-')[1]) - } - : null; - - const sabrStreamingContext = { - byteRange, - format: currentFormat, - isInit, - isUMP: true, - isSABR: true, - playerTimeMs: videoPlaybackAbrRequest.clientAbrState?.playerTimeMs - }; - - // @NOTE: Not a real header. See the http plugin code for more info. - request.headers['X-Streaming-Context'] = btoa(JSON.stringify(sabrStreamingContext)); - delete headers.Range; - } else { - url.pathname += `/ump/1/srfvp/1/pot/${get(poTokenCacheStore)}`; - - request.headers['X-Streaming-Context'] = btoa( - JSON.stringify({ - isUMP: true, - isSABR: false - }) - ); - } - - request.method = 'POST'; - } else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) { - const wrapped = {} as Record; - wrapped.context = data.video.ytjs?.innertube.session.context; - wrapped.cpn = data.video.ytjs?.clientPlaybackNonce; - wrapped.drmParams = decodeURIComponent(drmParams || ''); - wrapped.drmSystem = 'DRM_SYSTEM_WIDEVINE'; - wrapped.drmVideoFeature = 'DRM_VIDEO_FEATURE_SDR'; - wrapped.licenseRequest = shaka.util.Uint8ArrayUtils.toBase64( - request.body as ArrayBuffer | ArrayBufferView - ); - wrapped.sessionId = sessionId; - wrapped.videoId = data.video.videoId; - request.body = shaka.util.StringUtils.toUTF8(JSON.stringify(wrapped)); - } - - request.uris[0] = url.toString(); - }); - - networkingEngine.registerResponseFilter(async (type, response, context) => { - if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) { - const url = new URL(response.uri); - 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' - ) { - const stringBody = new TextDecoder().decode(response.data); - // position:0% for LTR text and position:100% for RTL text - const cleaned = stringBody.replaceAll(/ align:start position:(?:10)?0%$/gm, ''); - - /* @ts-ignore */ - response.data = new TextEncoder().encode(cleaned).buffer; - } else { - const sabrStreamingContext = response.headers['X-Streaming-Context']; - - if (sabrStreamingContext) { - const { streamInfo, format, byteRange } = JSON.parse( - atob(sabrStreamingContext) - ) as SabrStreamingContext; - - if (streamInfo) { - const sabrRedirect = streamInfo.redirect; - const playbackCookie = streamInfo.playbackCookie; - const streamProtectionStatus = streamInfo.streamProtectionStatus; - const formatInitMetadata = streamInfo.formatInitMetadata || []; - const mainSegmentMediaHeader = streamInfo.mediaHeader; - - // If we have a redirect, follow it. - if (sabrRedirect?.url && !response.data.byteLength) { - let redirectUrl = new URL(sabrRedirect.url); - - serverAbrStreamingUrl = redirectUrl; - redirectUrl = new URL(`https://sabr?___key=${fromFormat(format) || ''}`); - - const retryParameters = player!.getConfiguration().streaming.retryParameters; - - const redirectRequest = shaka.net.NetworkingEngine.makeRequest( - [redirectUrl.toString()], - retryParameters - ); - - // Keep range so we can slice the response (only if it's the init segment). - if (typeof byteRange !== 'undefined') { - redirectRequest.headers['Range'] = `bytes=${byteRange.start}-${byteRange.end}`; - } - - const requestOperation = player! - .getNetworkingEngine() - ?.request(type, redirectRequest, context); - const redirectResponse = await requestOperation!.promise; - - // Modify the original response to contain the results of the redirect - // response. - Object.assign(response, redirectResponse); - return; - } - - if (playbackCookie) lastPlaybackCookie = streamInfo.playbackCookie; - - if (streamProtectionStatus && streamProtectionStatus.status === 3) { - console.warn('[UMP] Attestation required.'); - } - - for (const metadata of formatInitMetadata) { - const key = fromFormatInitializationMetadata(metadata); - if (!initializedFormats.has(key)) { - initializedFormats.set(key, { - formatInitializationMetadata: metadata - }); - console.log(`[SABR] Initialized format ${key}`); - } - } - - if (mainSegmentMediaHeader) { - const formatKey = fromMediaHeader(mainSegmentMediaHeader); - const initializedFormat = initializedFormats.get(formatKey); - - if (initializedFormat) { - initializedFormat.lastSegmentMetadata = { - formatId: mainSegmentMediaHeader.formatId!, - startTimeMs: mainSegmentMediaHeader.startMs || 0, - startSequenceNumber: mainSegmentMediaHeader.sequenceNumber || 1, - endSequenceNumber: mainSegmentMediaHeader.sequenceNumber || 1, - durationMs: mainSegmentMediaHeader.durationMs || 0 - }; - } - } - } - } - } - } else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) { - const wrappedString = shaka.util.StringUtils.fromUTF8(response.data); - const wrapped = JSON.parse(wrappedString); - const rawLicenseBase64 = wrapped.license; - response.data = shaka.util.Uint8ArrayUtils.fromBase64(rawLicenseBase64); - } - }); - } - - await player.attach(playerElement); - - console.log(data.video.dashUrl); + async function loadVideo() { + showVideoRetry = false; + // Will inject requirements for SABR if SABR is required. + playerElementResizeObserver = injectSABR(player, playerElement as HTMLMediaElement, data.video); if (!data.video.liveNow) { - let dashUrl: string = ''; - - // Due to CORs issues with redirects, hosted instances of Materialious - // dirctly provide the companion instance - // while clients can just use the reirect provided by Invidious' API - if (import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE && Capacitor.getPlatform() === 'web') { - dashUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}/api/manifest/dash/id/${data.video.videoId}`; - } else { - dashUrl = data.video.dashUrl; - } - - if (!data.video.fallbackPatch && (!Capacitor.isNativePlatform() || proxyVideos)) { - dashUrl += '?local=true'; - } - - await player.load(dashUrl); - if (data.video.captions) { data.video.captions.forEach(async (caption) => { player.addTextTrackAsync( @@ -684,93 +284,26 @@ // Auto save watch progress every minute. watchProgressTimeout = setInterval(() => savePlayerPos(), 60000); + setupSponsorSkip(); - if (get(sponsorBlockStore) && get(sponsorBlockCategoriesStore)) { - const currentCategories = get(sponsorBlockCategoriesStore); + let dashUrl: string = ''; - const sponsorBlockUrl = get(sponsorBlockUrlStore); - - if (currentCategories.length > 0 && sponsorBlockUrl && sponsorBlockUrl !== '') { - const sponsorBlock = new SponsorBlock('', { baseURL: sponsorBlockUrl }); - - try { - segments = await sponsorBlock.getSegments( - data.video.videoId, - get(sponsorBlockCategoriesStore) as Category[] - ); - - playerElement.addEventListener('timeupdate', () => { - segments.forEach((segment) => { - if (!playerElement) return; - - if ( - playerElement.currentTime >= segment.startTime && - playerElement.currentTime <= segment.endTime - ) { - if (Math.round(playerElement.currentTime) >= Math.round(playerElement.duration)) { - return; - } - playerElement.currentTime = segment.endTime + 1; - if (!get(sponsorBlockDisplayToastStore)) { - snackBarAlert = `${get(_)('skipping')} ${segment.category}`; - ui('#snackbar-alert'); - } - } - }); - }); - } catch {} - } + // Due to CORs issues with redirects, hosted instances of Materialious + // dirctly provide the companion instance + // while clients can just use the reirect provided by Invidious' API + if (import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE && Capacitor.getPlatform() === 'web') { + dashUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}/api/manifest/dash/id/${data.video.videoId}`; + } else { + dashUrl = data.video.dashUrl; } + if (!data.video.fallbackPatch && (!Capacitor.isNativePlatform() || $playerProxyVideosStore)) { + dashUrl += '?local=true'; + } + + await player.load(dashUrl); + await loadPlayerPos(); - - if (Capacitor.getPlatform() === 'android' && data.video.adaptiveFormats.length > 0) { - const videoFormats = data.video.adaptiveFormats.filter((format) => - format.type.startsWith('video/') - ); - - originalOrigination = await ScreenOrientation.orientation(); - - document.addEventListener('fullscreenchange', async () => { - const isFullScreen = !!document.fullscreenElement; - - if (isFullScreen) { - // Ensure bar color is black while in fullscreen - await StatusBar.setBackgroundColor({ color: '#000000' }); - await NavigationBar.setColor({ - color: '#000000' - }); - await StatusBar.setStyle({ style: Style.Dark }); - } else { - await setStatusBarColor(); - } - - if (!get(playerAndroidLockOrientation)) return; - - if (isFullScreen && videoFormats[0].resolution) { - const widthHeight = videoFormats[0].resolution.split('x'); - - if (widthHeight.length !== 2) return; - - if (Number(widthHeight[0]) > Number(widthHeight[1])) { - await StatusBar.setOverlaysWebView({ overlay: true }); - await StatusBar.hide(); - await NavigationBar.hide(); - await ScreenOrientation.lock({ orientation: 'landscape' }); - } else { - await ScreenOrientation.lock({ orientation: 'portrait' }); - } - } else { - await StatusBar.setOverlaysWebView({ overlay: false }); - await StatusBar.show(); - await NavigationBar.show(); - - await ScreenOrientation.lock({ - orientation: (originalOrigination as ScreenOrientationResult).type - }); - } - }); - } } else { if (data.video.fallbackPatch === 'youtubejs') { await player.load(data.video.dashUrl); @@ -783,23 +316,111 @@ snackBarAlert = get(_)('player.youtubeJsFallBack'); ui('#snackbar-alert'); } + } + + async function reloadVideo() { + showVideoRetry = false; + data.video = await patchYoutubeJs(data.video.videoId); + await loadVideo(); + } + + onMount(async () => { + shaka.polyfill.installAll(); + if (!shaka.Player.isBrowserSupported()) { + return; + } + + HttpFetchPlugin.cacheManager.clearCache(); + + player = new shaka.Player(); + + // YouTube's defaults + player.configure({ + abr: { + enabled: true, + restrictions: { + maxWidth: 1920, + maxHeight: 1080 + } + }, + streaming: { + bufferingGoal: 120, + rebufferingGoal: 0.01, + bufferBehind: 300, + retryParameters: { + maxAttempts: 30, + baseDelay: 1500, + backoffFactor: 2.5, + fuzzFactor: 0.7, + timeout: 120000 + }, + stallThreshold: 2, + stallSkip: 0.5 + } + }); + playerElement = document.getElementById('player') as HTMLMediaElement; + + // Change instantly to stop video from being loud for a second + restoreVolumePreference(); + + shakaUi = new shaka.ui.Overlay( + player, + document.getElementById('shaka-container') as HTMLElement, + playerElement + ); + + shakaUi.configure({ + controlPanelElements: [ + 'play_pause', + Capacitor.getPlatform() === 'android' ? '' : 'volume', + 'spacer', + 'chapter', + 'time_and_duration', + 'captions', + 'overflow_menu', + 'fullscreen' + ], + overflowMenuButtons: [ + 'cast', + 'airplay', + 'captions', + 'quality', + 'playback_rate', + 'loop', + 'language', + 'statistics' + ], + playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3], + enableTooltips: false, + seekBarColors: { + played: (await getDynamicTheme())['--primary'] + } + }); + + player.addEventListener('error', async (event) => { + const error = (event as CustomEvent).detail as shaka.util.Error; + console.error('Player error:', error); + }); + + // Required to stop buttons from being still selected when fullscreening + document.addEventListener('fullscreenchange', async () => { + userWantsFullscreen = document.fullscreenElement !== null; + const buttons = document.querySelectorAll('.shaka-controls-button-panel button'); + buttons.forEach((button) => { + // Reset the button's focus and active states + (button as HTMLElement).blur(); // Remove focus from the button + button.removeAttribute('aria-pressed'); // Reset any ARIA attributes that might indicate selection + }); + }); + + await player.attach(playerElement); playerElement.addEventListener('volumechange', saveVolumePreference); restoreQualityPreference(); + restoreDefaultLanguage(); - const defaultLanguage = get(playerDefaultLanguage); - if (defaultLanguage) { - const audioLanguages = player.getAudioLanguages(); - const langCode = ISO6391.getCode(defaultLanguage); - - for (const audioLanguage of audioLanguages) { - if (audioLanguage.startsWith(langCode)) { - player.selectAudioLanguage(audioLanguage); - break; - } - } - } + await androidHandleRotate(); const overflowMenuButton = document.querySelector('.shaka-overflow-menu-button'); if (overflowMenuButton) { @@ -898,6 +519,17 @@ shakaUi.getControls()?.toggleFullScreen(); } }); + + try { + await loadVideo(); + } catch (error: unknown) { + if (!Capacitor.isNativePlatform() || data.video.fallbackPatch === 'youtubejs') return; + showVideoRetry = true; + + if ((error as shaka.extern.Error).code === 1001 && $playerYouTubeJsFallback) { + await reloadVideo(); + } + } }); async function loadPlayerPos() { @@ -908,12 +540,12 @@ let toSetTime = 0; - if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) { + if ($synciousStore && $synciousInstanceStore && $authStore) { try { toSetTime = (await getVideoProgress(data.video.videoId))[0].time; } catch {} } else { - if (get(playerSavePlaybackPositionStore)) { + if ($playerSavePlaybackPositionStore) { try { const playerPos = localStorage.getItem(`v_${data.video.videoId}`); if (playerPos && Number(playerPos) > toSetTime) { @@ -929,12 +561,9 @@ function savePlayerPos() { if (data.video.hlsUrl) return; - if ( - get(playerSavePlaybackPositionStore) && - player && - playerElement && - playerElement.currentTime - ) { + const synciousEnabled = $synciousStore && $synciousInstanceStore && $authStore; + + if ($playerSavePlaybackPositionStore && player && playerElement && playerElement.currentTime) { if ( playerElement.currentTime < playerElement.duration - 10 && playerElement.currentTime > 10 @@ -943,7 +572,7 @@ localStorage.setItem(`v_${data.video.videoId}`, playerElement.currentTime.toString()); } catch {} - if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) { + if (synciousEnabled) { saveVideoProgress(data.video.videoId, playerElement.currentTime); } } else { @@ -951,7 +580,7 @@ localStorage.removeItem(`v_${data.video.videoId}`); } catch {} - if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) { + if (synciousEnabled) { deleteVideoProgress(data.video.videoId); } } @@ -968,12 +597,15 @@ }); } } + if (watchProgressTimeout) { clearTimeout(watchProgressTimeout); } + try { savePlayerPos(); } catch (error) {} + playerPosSet = false; HttpFetchPlugin.cacheManager.clearCache(); @@ -999,20 +631,69 @@
+{#if showVideoRetry} +
+ {#if $playerYouTubeJsFallback} +

{$_('player.youtubeJsLoading')}

+ + {:else} +

{$_('player.retryText')}

+ +
+ + {/if} +
+{/if} + {#if !isEmbed}
{snackBarAlert}
{/if} + + diff --git a/materialious/src/lib/i18n/locales/en.json b/materialious/src/lib/i18n/locales/en.json index 5e71c49d..056c8f30 100644 --- a/materialious/src/lib/i18n/locales/en.json +++ b/materialious/src/lib/i18n/locales/en.json @@ -92,7 +92,11 @@ "unableToLoadComments": "Unable to load comments", "addToPlaylist": "Add to playlist", "youtubeJsFallBack": "Local video fallback being used", - "defaultLanguage": "Default Language" + "defaultLanguage": "Default Language", + "youtubeJsLoading": "Video failed to load, attempting to reload with local video fallback.", + "retryText": "Video failed to load, how would you like to continue?", + "enableYoutubejsTemp": "Use local video fallback once", + "enableYoutubejsPerm": "Always allow local video fallback" }, "layout": { "interface": "Interface", diff --git a/materialious/src/lib/sabr/index.ts b/materialious/src/lib/sabr/index.ts new file mode 100644 index 00000000..dea01736 --- /dev/null +++ b/materialious/src/lib/sabr/index.ts @@ -0,0 +1,418 @@ +import shaka from 'shaka-player/dist/shaka-player.ui'; +import { base64ToU8, Protos } from 'googlevideo'; +import { Constants, type Misc } from 'youtubei.js'; +import { HttpFetchPlugin, type SabrStreamingContext } from '$lib/sabr/shakaHttpPlugin'; +import { + fromFormat, + fromFormatInitializationMetadata, + fromMediaHeader, + getUniqueFormatId +} from '$lib/sabr/formatKeyUtils'; +import type { VideoPlay } from '$lib/api/model'; +import { poTokenCacheStore } from '$lib/store'; +import { get } from 'svelte/store'; + +export function injectSABR( + player: shaka.Player, + playerElement: HTMLMediaElement, + video: VideoPlay +): ResizeObserver | undefined { + if (!video.ytjs) return; + + shaka.net.NetworkingEngine.registerScheme( + 'http', + HttpFetchPlugin.parse, + shaka.net.NetworkingEngine.PluginPriority.PREFERRED + ); + shaka.net.NetworkingEngine.registerScheme( + 'https', + HttpFetchPlugin.parse, + shaka.net.NetworkingEngine.PluginPriority.PREFERRED + ); + + const sessionId = Array.from(Array(16), () => Math.floor(Math.random() * 36).toString(36)).join( + '' + ); + let videoPlaybackUstreamerConfig: string | undefined; + let serverAbrStreamingUrl: URL | undefined = undefined; + let drmParams: string | undefined; + const lastRequestMs = 0; + let lastSeekMs = 0; + let lastManualFormatSelectionMs = 0; + let lastActionMs = 0; + let clientViewportHeight = playerElement?.clientHeight || 0; + let clientViewportWidth = playerElement?.clientWidth || 0; + let lastPlaybackCookie: Protos.PlaybackCookie | undefined; + const initializedFormats = new Map< + string, + { + lastSegmentMetadata?: { + formatId: Protos.FormatId; + startTimeMs: number; + startSequenceNumber: number; + endSequenceNumber: number; + durationMs: number; + }; + formatInitializationMetadata: Protos.FormatInitializationMetadata; + } + >(); + let formatList: Misc.Format[] = []; + + const playerElementResizeObserver = new ResizeObserver(() => { + if (playerElement) { + clientViewportHeight = playerElement.clientHeight; + clientViewportWidth = playerElement.clientWidth; + } + }); + + playerElementResizeObserver?.observe(playerElement); + + if ( + video.ytjs.rawApiResponse.data.streamingData && + (video.ytjs.rawApiResponse.data.streamingData as any).drmParams + ) { + drmParams = (video.ytjs.rawApiResponse.data.streamingData as any).drmParams; + player.configure({ + drm: { + servers: { + 'com.widevine.alpha': + 'https://www.youtube.com/youtubei/v1/player/get_drm_license?alt=json' + } + } + }); + } + + videoPlaybackUstreamerConfig = + video.ytjs.video.page[0].player_config?.media_common_config.media_ustreamer_request_config + ?.video_playback_ustreamer_config; + + if (video.ytjs.video.streaming_data) { + formatList = video.ytjs.video.streaming_data.adaptive_formats; + } + + if (video.ytjs.video.streaming_data?.server_abr_streaming_url) + serverAbrStreamingUrl = new URL( + video.ytjs.innertube.session.player!.decipher( + video.ytjs.video.streaming_data.server_abr_streaming_url + ) + ); + + playerElement.addEventListener('seeked', () => (lastSeekMs = Date.now())); + + player.addEventListener('variantchanged', (event) => { + // Technically, all variant changes here are manual, given we don't handle ABR updates from the server. + if (event.type !== 'variant') { + lastManualFormatSelectionMs = Date.now(); + } + + lastActionMs = Date.now(); + }); + + const networkingEngine = player.getNetworkingEngine(); + + if (!networkingEngine) return; + + // Based off the following + // https://github.com/FreeTubeApp/FreeTube/blob/d270c9e251a433f1e4246a3f6a37acef707d22aa/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js#L1206 + // https://github.com/LuanRT/BgUtils/blob/6b121166be1ccb0b952dee1bdac488808365ae6b/examples/browser/web/src/main.ts#L293 + // https://github.com/LuanRT/yt-sabr-shaka-demo/blob/main/src/components/VideoPlayer.vue + networkingEngine.registerRequestFilter(async (type, request, context) => { + if (!player) return; + + const originalUrl = new URL(request.uris[0]); + const url = originalUrl.hostname === 'sabr' ? serverAbrStreamingUrl : originalUrl; + const headers = request.headers; + + if (!url) return; + + if ( + type === shaka.net.NetworkingEngine.RequestType.SEGMENT && + url.pathname.includes('videoplayback') + ) { + if (!video.liveNow) { + const currentFormat = formatList.find( + (format) => + fromFormat(format) === (new URL(request.uris[0]).searchParams.get('___key') || '') + ); + + if (!videoPlaybackUstreamerConfig) throw new Error('Ustreamer config not found.'); + + if (!currentFormat) throw new Error('No format found for SABR request.'); + + if (!playerElement) throw new Error('No video element found.'); + + const activeVariant = player + .getVariantTracks() + .find( + (track) => + getUniqueFormatId(currentFormat) === + (currentFormat.has_video ? track.originalVideoId : track.originalAudioId) + ); + + let videoFormat: Misc.Format | undefined; + let audioFormat: Misc.Format | undefined; + let videoFormatId: Protos.FormatId | undefined; + let audioFormatId: Protos.FormatId | undefined; + + if (activeVariant) { + for (const fmt of formatList) { + const uniqueFormatId = getUniqueFormatId(fmt); + if (uniqueFormatId === activeVariant.originalVideoId) { + videoFormat = fmt; + } else if (uniqueFormatId === activeVariant.originalAudioId) { + audioFormat = fmt; + } + } + } + + if (videoFormat) { + videoFormatId = { + itag: videoFormat.itag, + lastModified: parseInt(videoFormat.last_modified_ms), + xtags: videoFormat.xtags + }; + } + + if (audioFormat) { + audioFormatId = { + itag: audioFormat.itag, + lastModified: parseInt(audioFormat.last_modified_ms), + xtags: audioFormat.xtags + }; + } + + const isInit = context ? !context.segment : true; + + const videoPlaybackAbrRequest: Protos.VideoPlaybackAbrRequest = { + clientAbrState: { + playbackRate: player.getPlaybackRate(), + playerTimeMs: Math.round( + (context?.segment?.getStartTime() ?? playerElement.currentTime) * 1000 + ), + elapsedWallTimeMs: Date.now() - lastRequestMs, + timeSinceLastSeek: lastSeekMs === 0 ? 0 : Date.now() - lastSeekMs, + timeSinceLastActionMs: lastActionMs === 0 ? 0 : Date.now() - lastActionMs, + timeSinceLastManualFormatSelectionMs: + lastManualFormatSelectionMs === 0 ? 0 : Date.now() - lastManualFormatSelectionMs, + clientViewportIsFlexible: false, + bandwidthEstimate: Math.round(player.getStats().estimatedBandwidth), + drcEnabled: currentFormat.is_drc === true, + enabledTrackTypesBitfield: currentFormat.has_audio ? 1 : 2, + clientViewportHeight, + clientViewportWidth + }, + bufferedRanges: [], + selectedFormatIds: [], + selectedAudioFormatIds: [audioFormatId || {}], + selectedVideoFormatIds: [videoFormatId || {}], + videoPlaybackUstreamerConfig: base64ToU8(videoPlaybackUstreamerConfig), + streamerContext: { + poToken: base64ToU8(get(poTokenCacheStore) ?? ''), + playbackCookie: lastPlaybackCookie + ? Protos.PlaybackCookie.encode(lastPlaybackCookie).finish() + : undefined, + clientInfo: { + clientName: parseInt(Constants.CLIENT_NAME_IDS.WEB), + clientVersion: video.ytjs?.innertube.session.context.client.clientVersion, + osName: 'Windows', + osVersion: '10.0' + }, + field5: [], + field6: [] + }, + field1000: [] + }; + + // Normalize the resolution. + if (currentFormat.width && currentFormat.height) { + let resolution = currentFormat.height; + + const aspectRatio = currentFormat.height / currentFormat.width; + + if (aspectRatio > 16 / 9) { + resolution = Math.round((currentFormat.width * 9) / 16); + } + + if (resolution && videoPlaybackAbrRequest.clientAbrState) { + videoPlaybackAbrRequest.clientAbrState.stickyResolution = resolution; + videoPlaybackAbrRequest.clientAbrState.lastManualSelectedResolution = resolution; + } + } + + if (!isInit) { + // Add the currently/previously active formats to the list of buffered ranges and selected formats + // so that the server doesn't send its init data again. + const initializedFormatsArray = Array.from(initializedFormats.values()); + + for (const initializedFormat of initializedFormatsArray) { + if (initializedFormat.lastSegmentMetadata) { + videoPlaybackAbrRequest.bufferedRanges.push({ + formatId: initializedFormat.lastSegmentMetadata.formatId, + startSegmentIndex: initializedFormat.lastSegmentMetadata.startSequenceNumber, + durationMs: initializedFormat.lastSegmentMetadata.durationMs, + startTimeMs: 0, + endSegmentIndex: initializedFormat.lastSegmentMetadata.endSequenceNumber + }); + } + } + + if (audioFormatId) videoPlaybackAbrRequest.selectedFormatIds.push(audioFormatId); + + if (videoFormatId) videoPlaybackAbrRequest.selectedFormatIds.push(videoFormatId); + } + + request.body = Protos.VideoPlaybackAbrRequest.encode(videoPlaybackAbrRequest).finish(); + + const byteRange = headers.Range + ? { + start: Number(headers.Range.split('=')[1].split('-')[0]), + end: Number(headers.Range.split('=')[1].split('-')[1]) + } + : null; + + const sabrStreamingContext = { + byteRange, + format: currentFormat, + isInit, + isUMP: true, + isSABR: true, + playerTimeMs: videoPlaybackAbrRequest.clientAbrState?.playerTimeMs + }; + + // @NOTE: Not a real header. See the http plugin code for more info. + request.headers['X-Streaming-Context'] = btoa(JSON.stringify(sabrStreamingContext)); + delete headers.Range; + } else { + url.pathname += `/ump/1/srfvp/1/pot/${get(poTokenCacheStore)}`; + + request.headers['X-Streaming-Context'] = btoa( + JSON.stringify({ + isUMP: true, + isSABR: false + }) + ); + } + + request.method = 'POST'; + } else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) { + const wrapped = {} as Record; + wrapped.context = video.ytjs?.innertube.session.context; + wrapped.cpn = video.ytjs?.clientPlaybackNonce; + wrapped.drmParams = decodeURIComponent(drmParams || ''); + wrapped.drmSystem = 'DRM_SYSTEM_WIDEVINE'; + wrapped.drmVideoFeature = 'DRM_VIDEO_FEATURE_SDR'; + wrapped.licenseRequest = shaka.util.Uint8ArrayUtils.toBase64( + request.body as ArrayBuffer | ArrayBufferView + ); + wrapped.sessionId = sessionId; + wrapped.videoId = video.videoId; + request.body = shaka.util.StringUtils.toUTF8(JSON.stringify(wrapped)); + } + + request.uris[0] = url.toString(); + }); + + networkingEngine.registerResponseFilter(async (type, response, context) => { + if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) { + const url = new URL(response.uri); + 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' + ) { + const stringBody = new TextDecoder().decode(response.data); + // position:0% for LTR text and position:100% for RTL text + const cleaned = stringBody.replaceAll(/ align:start position:(?:10)?0%$/gm, ''); + + /* @ts-ignore */ + response.data = new TextEncoder().encode(cleaned).buffer; + } else { + const sabrStreamingContext = response.headers['X-Streaming-Context']; + + if (sabrStreamingContext) { + const { streamInfo, format, byteRange } = JSON.parse( + atob(sabrStreamingContext) + ) as SabrStreamingContext; + + if (streamInfo) { + const sabrRedirect = streamInfo.redirect; + const playbackCookie = streamInfo.playbackCookie; + const streamProtectionStatus = streamInfo.streamProtectionStatus; + const formatInitMetadata = streamInfo.formatInitMetadata || []; + const mainSegmentMediaHeader = streamInfo.mediaHeader; + + // If we have a redirect, follow it. + if (sabrRedirect?.url && !response.data.byteLength) { + let redirectUrl = new URL(sabrRedirect.url); + + serverAbrStreamingUrl = redirectUrl; + redirectUrl = new URL(`https://sabr?___key=${fromFormat(format) || ''}`); + + const retryParameters = player!.getConfiguration().streaming.retryParameters; + + const redirectRequest = shaka.net.NetworkingEngine.makeRequest( + [redirectUrl.toString()], + retryParameters + ); + + // Keep range so we can slice the response (only if it's the init segment). + if (typeof byteRange !== 'undefined') { + redirectRequest.headers['Range'] = `bytes=${byteRange.start}-${byteRange.end}`; + } + + const requestOperation = player! + .getNetworkingEngine() + ?.request(type, redirectRequest, context); + const redirectResponse = await requestOperation!.promise; + + // Modify the original response to contain the results of the redirect + // response. + Object.assign(response, redirectResponse); + return; + } + + if (playbackCookie) lastPlaybackCookie = streamInfo.playbackCookie; + + if (streamProtectionStatus && streamProtectionStatus.status === 3) { + console.warn('[UMP] Attestation required.'); + } + + for (const metadata of formatInitMetadata) { + const key = fromFormatInitializationMetadata(metadata); + if (!initializedFormats.has(key)) { + initializedFormats.set(key, { + formatInitializationMetadata: metadata + }); + console.log(`[SABR] Initialized format ${key}`); + } + } + + if (mainSegmentMediaHeader) { + const formatKey = fromMediaHeader(mainSegmentMediaHeader); + const initializedFormat = initializedFormats.get(formatKey); + + if (initializedFormat) { + initializedFormat.lastSegmentMetadata = { + formatId: mainSegmentMediaHeader.formatId!, + startTimeMs: mainSegmentMediaHeader.startMs || 0, + startSequenceNumber: mainSegmentMediaHeader.sequenceNumber || 1, + endSequenceNumber: mainSegmentMediaHeader.sequenceNumber || 1, + durationMs: mainSegmentMediaHeader.durationMs || 0 + }; + } + } + } + } + } + } else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) { + const wrappedString = shaka.util.StringUtils.fromUTF8(response.data); + const wrapped = JSON.parse(wrappedString); + const rawLicenseBase64 = wrapped.license; + response.data = shaka.util.Uint8ArrayUtils.fromBase64(rawLicenseBase64); + } + }); + + return playerElementResizeObserver; +}