From 161ea539f07d78a66b70b0e54853816259a54a44 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 18 Feb 2026 04:17:19 +1300 Subject: [PATCH] Started to seperate player out to more components --- .../src/lib/components/player/Player.svelte | 458 +----------------- .../components/player/settings/Airplay.svelte | 29 ++ .../player/settings/CaptionSettings.svelte | 41 ++ .../player/settings/FullscreenToggle.svelte | 16 + .../lib/components/player/settings/Pip.svelte | 14 + .../player/settings/Settings.svelte | 186 +++++++ materialious/src/lib/css/global.css | 6 + materialious/src/lib/player/android.ts | 95 ++++ materialious/src/lib/player/index.ts | 91 ++++ 9 files changed, 502 insertions(+), 434 deletions(-) create mode 100644 materialious/src/lib/components/player/settings/Airplay.svelte create mode 100644 materialious/src/lib/components/player/settings/CaptionSettings.svelte create mode 100644 materialious/src/lib/components/player/settings/FullscreenToggle.svelte create mode 100644 materialious/src/lib/components/player/settings/Pip.svelte create mode 100644 materialious/src/lib/components/player/settings/Settings.svelte create mode 100644 materialious/src/lib/player/android.ts diff --git a/materialious/src/lib/components/player/Player.svelte b/materialious/src/lib/components/player/Player.svelte index 5355a102..afe7d5b3 100644 --- a/materialious/src/lib/components/player/Player.svelte +++ b/materialious/src/lib/components/player/Player.svelte @@ -3,11 +3,8 @@ import { getBestThumbnail, ImageCache } from '$lib/images'; import { videoLength } from '$lib/numbers'; import { generateChapterWebVTT, type ParsedDescription, type Timestamp } from '$lib/description'; - import { SystemBars, SystemBarsStyle, SystemBarType } from '@capacitor/core'; import { Capacitor } from '@capacitor/core'; - import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation'; import { error, type Page } from '@sveltejs/kit'; - import ISO6391 from 'iso-639-1'; import Mousetrap from 'mousetrap'; import { CapacitorMusicControls } from 'capacitor-music-controls-plugin'; import shaka from 'shaka-player/dist/shaka-player.ui'; @@ -23,13 +20,10 @@ invidiousInstanceStore, isAndroidTvStore, playerAlwaysLoopStore, - playerAndroidLockOrientation, - playerAndroidPauseOnNetworkChange, playerAutoPlayStore, playerCCByDefault, playerDefaultLanguage, playerDefaultPlaybackSpeed, - playerDefaultQualityStore, playerPreferredVolumeStore, playerProxyVideosStore, playerSavePlaybackPositionStore, @@ -49,14 +43,14 @@ import { goToNextVideo, goToPreviousVideo, - playbackRates, - playerDoubleTapSeek + playerDoubleTapSeek, + restoreDefaultLanguage, + restoreQualityPreference, + toggleSubtitles } from '$lib/player/index'; import { manifestDomainInclusion } from '$lib/player/manifest'; import { injectSabr } from '$lib/player/sabr'; import type { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter'; - import { SvelteSet } from 'svelte/reactivity'; - import { Network, type ConnectionStatus } from '@capacitor/network'; import { fade } from 'svelte/transition'; import { addToast } from '$lib/components/Toast.svelte'; import { getPublicEnv, isMobile, isUnrestrictedPlatform, isYTBackend, truncate } from '$lib/misc'; @@ -67,6 +61,12 @@ type TimelineThumbnail } from '$lib/player/thumbnails'; import { isOwnBackend } from '$lib/shared'; + import Settings from './settings/Settings.svelte'; + import CaptionSettings from './settings/CaptionSettings.svelte'; + import Airplay from './settings/Airplay.svelte'; + import Pip from './settings/Pip.svelte'; + import FullscreenToggle from './settings/FullscreenToggle.svelte'; + import { AndroidPlayer } from '$lib/player/android'; interface Props { data: { video: VideoPlay; content: ParsedDescription; playlistId: string | null }; @@ -99,11 +99,10 @@ music_offtopic: $_('layout.sponsors.musicOffTopic') }; - let originalOrigination: ScreenOrientationResult | undefined; let watchProgressInterval: ReturnType; let showVideoRetry = $state(false); - let androidInitialNetworkStatus: ConnectionStatus | undefined; + let androidPlayer: AndroidPlayer; let player: shaka.Player; let sabrAdapter: SabrStreamingAdapter | null; @@ -114,11 +113,6 @@ let playerMaxKnownTime = $state(data.video.lengthSeconds); let playerIsBuffering = $state(true); let playerVolume = $state($playerPreferredVolumeStore); - let playerSettings: 'quality' | 'speed' | 'language' | 'root' = $state('root'); - let playerTextTracks: shaka.extern.TextTrack[] | undefined = $state(undefined); - let playerCurrentVideoTrack: shaka.extern.VideoTrack | undefined = $state(undefined); - let playerCurrentAudioTrack: shaka.extern.AudioTrack | undefined = $state(undefined); - let playerLoop = $state($playerAlwaysLoopStore); let playerTimelineTimeHover: number = $state(0); let playerVideoEndTimePretty: string = $state(''); let playerBufferedTo: number = $state(0); @@ -206,43 +200,6 @@ return `${percent}%`; } - function restoreDefaultLanguage() { - if (!$playerDefaultLanguage || $playerDefaultLanguage === 'original') { - const languageAndRole = player - .getAudioLanguagesAndRoles() - .find(({ role }) => role === 'main'); - if (languageAndRole !== undefined) { - player.selectAudioLanguage(languageAndRole.language); - return; - } - } else 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 filterUniqueAudioTracks(tracks: shaka.extern.AudioTrack[]): shaka.extern.AudioTrack[] { - const uniqueTracks: shaka.extern.AudioTrack[] = []; - const seen = new SvelteSet(); - - for (const track of tracks) { - const identifier = `${track.language}-${track.label || 'No Label'}`; - if (!seen.has(identifier)) { - seen.add(identifier); - uniqueTracks.push(track); - } - } - - return uniqueTracks; - } - function saveVolumePreference() { if (!playerElement) return; playerVolume = playerElement.volume; @@ -255,93 +212,6 @@ playerVolume = playerElement.volume; } - function restoreQualityPreference() { - const numericValue = parseInt($playerDefaultQualityStore, 10); - - if (isNaN(numericValue)) { - player.configure({ abr: { enabled: true } }); - return; - } - - // Get video-only variant tracks - const tracks = player.getVariantTracks().filter((t) => t.height !== null); - - // Sort by resolution descending - const sortedTracks = tracks.sort((a, b) => (b.height as number) - (a.height as number)); - - // Try exact match - let selectedTrack = sortedTracks.find((t) => t.height === numericValue); - - // Try next best (lower than target) - if (!selectedTrack) { - selectedTrack = sortedTracks.find((t) => (t.height as number) < numericValue); - } - - // Try next higher - if (!selectedTrack) { - selectedTrack = sortedTracks.find((t) => (t.height as number) > numericValue); - } - - if (selectedTrack) { - player.configure({ abr: { enabled: false } }); - player.selectVariantTrack(selectedTrack, true); - } else { - player.configure({ abr: { enabled: true } }); - } - } - - async function onAndroidFullscreenChange() { - const videoFormats = data.video.adaptiveFormats.filter((format) => - format.type.startsWith('video/') - ); - - const isFullScreen = !!document.fullscreenElement; - - if (isFullScreen) { - // Ensure bar color is black while in fullscreen - await SystemBars.setStyle({ style: SystemBarsStyle.Light }); - await SystemBars.hide({ - bar: SystemBarType.NavigationBar - }); - await SystemBars.hide({ - bar: SystemBarType.StatusBar - }); - } else { - await setStatusBarColor(); - } - - if (!$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 ScreenOrientation.lock({ orientation: 'landscape' }); - } else { - await ScreenOrientation.lock({ orientation: 'portrait' }); - } - } else { - await ScreenOrientation.lock({ - orientation: (originalOrigination as ScreenOrientationResult).type - }); - } - } - - async function androidHandleRotate() { - if ( - Capacitor.getPlatform() !== 'android' || - data.video.adaptiveFormats.length === 0 || - $isAndroidTvStore - ) - return; - - originalOrigination = await ScreenOrientation.orientation(); - - document.addEventListener('fullscreenchange', onAndroidFullscreenChange); - } - function skipSegment(segment: Segment) { if (!playerElement) return; @@ -423,50 +293,8 @@ return false; } - function toggleSubtitles() { - const isVisible = player.isTextTrackVisible(); - if (isVisible) { - player.setTextTrackVisibility(false); - } else { - let langCode: string; - if ($playerDefaultLanguage === 'original') { - const languageAndRole = player - .getAudioLanguagesAndRoles() - .find(({ role }) => role === 'main'); - - if (!languageAndRole) { - return; - } - - langCode = languageAndRole.language; - } else { - const defaultLanguage = get(playerDefaultLanguage); - langCode = ISO6391.getCode(defaultLanguage); - } - - const tracks = player.getTextTracks(); - - const subtitleTrack = tracks.find((track) => track.language === langCode); - - if (subtitleTrack) { - player.selectTextTrack(subtitleTrack); - player.setTextTrackVisibility(true); - } - } - } - page.subscribe((pageUpdate) => loadTimeFromUrl(pageUpdate)); - function setActiveVideoTrack() { - const videoTracks = player.getVideoTracks(); - playerCurrentVideoTrack = videoTracks.find((track) => track.active); - } - - function setActiveAudioTrack() { - const audioTracks = player.getAudioTracks(); - playerCurrentAudioTrack = audioTracks.find((track) => track.active); - } - function toggleFullscreen() { if (document.fullscreenElement) { document.exitFullscreen(); @@ -491,14 +319,12 @@ setupSponsorSkip(); player.addEventListener('loaded', () => { - restoreQualityPreference(); - restoreDefaultLanguage(); - setActiveVideoTrack(); - setActiveAudioTrack(); + restoreQualityPreference(player); + restoreDefaultLanguage(player); updateVideoPlayerHeight(); if ($playerCCByDefault) { - toggleSubtitles(); + toggleSubtitles(player); } }); @@ -696,12 +522,7 @@ }); playerElement = document.getElementById('player') as HTMLMediaElement; - // Enable AirPlay if supported - if (hasWebkitShowPlaybackTargetPicker(playerElement)) { - playerElement.setAttribute('x-webkit-airplay', 'allow'); - } - - playerElement.loop = playerLoop; + playerElement.loop = $playerAlwaysLoopStore; if ($playerState) { playerState.set({ ...$playerState, playerElement: playerElement }); @@ -715,13 +536,14 @@ window.addEventListener('resize', updateVideoPlayerHeight); updateVideoPlayerHeight(); + androidPlayer = new AndroidPlayer(data.video, playerElement); + await player.attach(playerElement); player?.addEventListener('error', (event) => { const error = (event as CustomEvent).detail as shaka.util.Error; console.error('Player error:', error); }); - player.getNetworkingEngine()?.registerResponseFilter((type, response) => { if ( type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || @@ -750,20 +572,7 @@ playerElement?.addEventListener('volumechange', saveVolumePreference); - await androidHandleRotate(); - if (Capacitor.getPlatform() === 'android') { - androidInitialNetworkStatus = await Network.getStatus(); - - Network.addListener('networkStatusChange', (networkStatus) => { - if ( - androidInitialNetworkStatus?.connectionType !== networkStatus.connectionType && - $playerAndroidPauseOnNetworkChange - ) { - playerElement?.pause(); - } - }); - await CapacitorMusicControls.create({ track: data.video.title, artist: data.video.author, @@ -914,7 +723,7 @@ } Mousetrap.bind('c', () => { - toggleSubtitles(); + toggleSubtitles(player); showPlayerUI(); return false; }); @@ -1040,22 +849,8 @@ // Update video player height again on video loaded. updateVideoPlayerHeight(); - - playerTextTracks = player.getTextTracks(); }); - function hasWebkitShowPlaybackTargetPicker( - el: HTMLMediaElement - ): el is HTMLMediaElement & { webkitShowPlaybackTargetPicker: () => void } { - return typeof (el as any).webkitShowPlaybackTargetPicker === 'function'; - } - - function handleAirPlayClick() { - if (playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)) { - playerElement.webkitShowPlaybackTargetPicker(); - } - } - async function getLastPlayPos(): Promise { if (loadTimeFromUrl($page) || !$playerSavePlaybackPositionStore) return 0; @@ -1252,21 +1047,9 @@ onDestroy(async () => { if (Capacitor.getPlatform() === 'android') { - if (!$isAndroidTvStore) { - document.removeEventListener('fullscreenchange', onAndroidFullscreenChange); - - if (originalOrigination) { - await ScreenOrientation.lock({ - orientation: originalOrigination.type - }); - } - } - - await Network.removeAllListeners(); - await setStatusBarColor(); - await CapacitorMusicControls.destroy(); + await androidPlayer.destroy(); } try { @@ -1467,194 +1250,11 @@ {/if}

{#if !$isAndroidTvStore} - {#if playerTextTracks && playerTextTracks.length > 0 && !data.video.liveNow} - - {/if} - - {#if playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)} - - {/if} - {#if document.pictureInPictureEnabled} - - {/if} - + + + + + {/if} @@ -1700,10 +1300,6 @@ padding: 10px; } - #player-controls span { - clip-path: none; - } - #player-tap-controls-area { touch-action: manipulation; } @@ -1835,12 +1431,6 @@ cursor: not-allowed; } - menu.mobile { - transform: scale(1) translateY(-40%) translateX(0); - width: 300px; - height: 200px; - } - .contain-video { max-height: 80vh; max-width: calc(80vh * 16 / 9); diff --git a/materialious/src/lib/components/player/settings/Airplay.svelte b/materialious/src/lib/components/player/settings/Airplay.svelte new file mode 100644 index 00000000..def8e0ea --- /dev/null +++ b/materialious/src/lib/components/player/settings/Airplay.svelte @@ -0,0 +1,29 @@ + + +{#if playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)} + +{/if} diff --git a/materialious/src/lib/components/player/settings/CaptionSettings.svelte b/materialious/src/lib/components/player/settings/CaptionSettings.svelte new file mode 100644 index 00000000..512cc532 --- /dev/null +++ b/materialious/src/lib/components/player/settings/CaptionSettings.svelte @@ -0,0 +1,41 @@ + + +{#if playerTextTracks && playerTextTracks.length > 0 && !video.liveNow} + +{/if} diff --git a/materialious/src/lib/components/player/settings/FullscreenToggle.svelte b/materialious/src/lib/components/player/settings/FullscreenToggle.svelte new file mode 100644 index 00000000..d4c65578 --- /dev/null +++ b/materialious/src/lib/components/player/settings/FullscreenToggle.svelte @@ -0,0 +1,16 @@ + + + diff --git a/materialious/src/lib/components/player/settings/Pip.svelte b/materialious/src/lib/components/player/settings/Pip.svelte new file mode 100644 index 00000000..805c628a --- /dev/null +++ b/materialious/src/lib/components/player/settings/Pip.svelte @@ -0,0 +1,14 @@ + + +{#if document.pictureInPictureEnabled} + +{/if} diff --git a/materialious/src/lib/components/player/settings/Settings.svelte b/materialious/src/lib/components/player/settings/Settings.svelte new file mode 100644 index 00000000..98425d7b --- /dev/null +++ b/materialious/src/lib/components/player/settings/Settings.svelte @@ -0,0 +1,186 @@ + + + diff --git a/materialious/src/lib/css/global.css b/materialious/src/lib/css/global.css index 16f69699..b6ffae70 100644 --- a/materialious/src/lib/css/global.css +++ b/materialious/src/lib/css/global.css @@ -133,6 +133,12 @@ menu { border: none !important; } +menu.player-settings { + transform: scale(1) translateY(-40%) translateX(0) !important; + width: 300px !important; + height: 200px !important; +} + @media screen and (max-width: 1000px) { menu.mobile { position: fixed !important; diff --git a/materialious/src/lib/player/android.ts b/materialious/src/lib/player/android.ts new file mode 100644 index 00000000..70587c9f --- /dev/null +++ b/materialious/src/lib/player/android.ts @@ -0,0 +1,95 @@ +import type { VideoPlay } from '$lib/api/model'; +import { playerAndroidLockOrientation, playerAndroidPauseOnNetworkChange } from '$lib/store'; +import { setStatusBarColor } from '$lib/theme'; +import { get } from 'svelte/store'; +import { Capacitor, SystemBars, SystemBarsStyle, SystemBarType } from '@capacitor/core'; +import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation'; +import { Network, type ConnectionStatus } from '@capacitor/network'; + +export class AndroidPlayer { + video: VideoPlay; + originalOrigination: ScreenOrientationResult | undefined; + initialNetworkStatus: ConnectionStatus | undefined; + playerElement: HTMLMediaElement | undefined; + + constructor(video: VideoPlay, playerElement: HTMLMediaElement | undefined) { + this.video = video; + this.playerElement = playerElement; + + if (Capacitor.getPlatform() !== 'android') return; + + ScreenOrientation.orientation().then((originalOrigination) => { + this.originalOrigination = originalOrigination; + }); + Network.getStatus().then((initialNetworkStatus) => { + this.initialNetworkStatus = initialNetworkStatus; + }); + + document.addEventListener('fullscreenchange', this.onFullscreenChange); + + Network.addListener('networkStatusChange', (networkStatus) => { + if ( + this.initialNetworkStatus?.connectionType !== networkStatus.connectionType && + get(playerAndroidPauseOnNetworkChange) + ) { + this.playerElement?.pause(); + } + }); + } + + async destroy() { + if (Capacitor.getPlatform() !== 'android') return; + + document.removeEventListener('fullscreenchange', this.onFullscreenChange); + + if (this.originalOrigination) { + await ScreenOrientation.lock({ + orientation: this.originalOrigination.type + }); + } + + await Network.removeAllListeners(); + } + + async onFullscreenChange() { + if (Capacitor.getPlatform() !== 'android') return; + + const videoFormats = this.video.adaptiveFormats.filter((format) => + format.type.startsWith('video/') + ); + + const isFullScreen = !!document.fullscreenElement; + + if (isFullScreen) { + // Ensure bar color is black while in fullscreen + await SystemBars.setStyle({ style: SystemBarsStyle.Light }); + await SystemBars.hide({ + bar: SystemBarType.NavigationBar + }); + await SystemBars.hide({ + bar: SystemBarType.StatusBar + }); + } 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 ScreenOrientation.lock({ orientation: 'landscape' }); + } else { + await ScreenOrientation.lock({ orientation: 'portrait' }); + } + } else { + if (this.originalOrigination) + await ScreenOrientation.lock({ + orientation: this.originalOrigination.type + }); + } + } +} diff --git a/materialious/src/lib/player/index.ts b/materialious/src/lib/player/index.ts index de96dc57..4dc41653 100644 --- a/materialious/src/lib/player/index.ts +++ b/materialious/src/lib/player/index.ts @@ -3,6 +3,8 @@ import type { PlaylistPageVideo, VideoPlay } from '$lib/api/model'; import { isAndroidTvStore, playerAutoplayNextByDefaultStore, + playerDefaultLanguage, + playerDefaultQualityStore, playerPlaylistHistory, playlistSettingsStore, syncPartyConnectionsStore @@ -11,6 +13,8 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { loadEntirePlaylist } from '$lib/playlist'; import { unsafeRandomItem } from '$lib/misc'; +import type shaka from 'shaka-player/dist/shaka-player.ui'; +import ISO6391 from 'iso-639-1'; export interface PlayerEvent { type: 'pause' | 'seek' | 'change-video' | 'play' | 'playlist' | 'goto'; @@ -104,3 +108,90 @@ export async function goToNextVideo(video: VideoPlay, playlistId: string | null) ); } } + +export function restoreQualityPreference(player: shaka.Player) { + const numericValue = parseInt(get(playerDefaultQualityStore), 10); + + if (isNaN(numericValue)) { + player.configure({ abr: { enabled: true } }); + return; + } + + // Get video-only variant tracks + const tracks = player.getVariantTracks().filter((t) => t.height !== null); + + // Sort by resolution descending + const sortedTracks = tracks.sort((a, b) => (b.height as number) - (a.height as number)); + + // Try exact match + let selectedTrack = sortedTracks.find((t) => t.height === numericValue); + + // Try next best (lower than target) + if (!selectedTrack) { + selectedTrack = sortedTracks.find((t) => (t.height as number) < numericValue); + } + + // Try next higher + if (!selectedTrack) { + selectedTrack = sortedTracks.find((t) => (t.height as number) > numericValue); + } + + if (selectedTrack) { + player.configure({ abr: { enabled: false } }); + player.selectVariantTrack(selectedTrack, true); + } else { + player.configure({ abr: { enabled: true } }); + } +} + +export function restoreDefaultLanguage(player: shaka.Player) { + if (!get(playerDefaultLanguage) || get(playerDefaultLanguage) === 'original') { + const languageAndRole = player.getAudioLanguagesAndRoles().find(({ role }) => role === 'main'); + if (languageAndRole !== undefined) { + player.selectAudioLanguage(languageAndRole.language); + return; + } + } else if (get(playerDefaultLanguage)) { + const audioLanguages = player.getAudioLanguages(); + const langCode = ISO6391.getCode(get(playerDefaultLanguage)); + + for (const audioLanguage of audioLanguages) { + if (audioLanguage.startsWith(langCode)) { + player.selectAudioLanguage(audioLanguage); + break; + } + } + } +} + +export function toggleSubtitles(player: shaka.Player) { + const isVisible = player.isTextTrackVisible(); + if (isVisible) { + player.setTextTrackVisibility(false); + } else { + let langCode: string; + if (get(playerDefaultLanguage) === 'original') { + const languageAndRole = player + .getAudioLanguagesAndRoles() + .find(({ role }) => role === 'main'); + + if (!languageAndRole) { + return; + } + + langCode = languageAndRole.language; + } else { + const defaultLanguage = get(playerDefaultLanguage); + langCode = ISO6391.getCode(defaultLanguage); + } + + const tracks = player.getTextTracks(); + + const subtitleTrack = tracks.find((track) => track.language === langCode); + + if (subtitleTrack) { + player.selectTextTrack(subtitleTrack); + player.setTextTrackVisibility(true); + } + } +}