Started to seperate player out to more components

This commit is contained in:
WardPearce
2026-02-18 04:17:19 +13:00
parent 7782e636e5
commit 161ea539f0
9 changed files with 502 additions and 434 deletions
@@ -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<typeof setInterval>;
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<string>();
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<number> {
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}
</p>
{#if !$isAndroidTvStore}
{#if playerTextTracks && playerTextTracks.length > 0 && !data.video.liveNow}
<button class="surface-container-highest">
<i>closed_caption</i>
<menu class="no-wrap mobile" id="cc-menu" data-ui="#cc-menu">
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => player.setTextTrackVisibility(false)}
>
{$_('player.controls.off')}
</li>
{#each playerTextTracks as track (track)}
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => {
player.selectTextTrack(track);
player.setTextTrackVisibility(true);
}}
>
{track.label}
</li>
{/each}
</menu>
</button>
{/if}
<button class="surface-container-highest">
<i>settings</i>
<menu class="no-wrap mobile" id="settings-menu">
{#if playerSettings !== 'root'}
<li role="presentation" onclick={() => (playerSettings = 'root')}>
<i>arrow_back</i>
{$_('player.controls.back')}
</li>
{/if}
{#if playerSettings === 'root'}
<li role="presentation" onclick={() => (playerSettings = 'quality')}>
<nav class="no-wrap" style="width: 100%;">
<i>high_quality</i>
{$_('player.controls.quality')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentVideoTrack}
{playerCurrentVideoTrack.height}p
{:else}
{$_('player.controls.auto')}
{/if}
</span>
</nav>
</li>
<li role="presentation" onclick={() => (playerSettings = 'speed')}>
<nav class="no-wrap" style="width: 100%;">
<i>speed</i>
{$_('player.controls.playbackSpeed')}
<div class="max"></div>
<span class="chip">
{playerElement?.playbackRate}x
</span>
</nav>
</li>
{#if playerCurrentAudioTrack && playerCurrentAudioTrack.label !== null}
<li role="presentation" onclick={() => (playerSettings = 'language')}>
<nav class="no-wrap" style="width: 100%;">
<i>language</i>
{$_('player.controls.language')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentAudioTrack}
{playerCurrentAudioTrack.language !== 'und'
? ISO6391.getName(playerCurrentAudioTrack.language)
: playerCurrentAudioTrack.label}
{/if}
</span>
</nav>
</li>
{/if}
<li
role="presentation"
onclick={() => {
if (playerElement) playerElement.loop = !playerLoop;
playerLoop = !playerLoop;
}}
>
<nav class="no-wrap" style="width: 100%;">
<i>all_inclusive</i>
{$_('player.controls.loop')}
<div class="max"></div>
<span class="chip">
{playerLoop ? $_('player.controls.on') : $_('player.controls.off')}
</span>
</nav>
</li>
{:else if playerSettings === 'quality'}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.configure({ abr: true });
playerCurrentVideoTrack = undefined;
}}
>
{$_('player.controls.auto')}
</li>
{#each player.getVideoTracks().sort((a, b) => {
const heightA = a.height || 0;
const heightB = b.height || 0;
const widthA = a.width || 0;
const widthB = b.width || 0;
return heightB - heightA || widthB - widthA;
}) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectVideoTrack(track, true);
setActiveVideoTrack();
}}
>
{track.height}p
</li>
{/each}
{:else if playerSettings === 'speed'}
{#each playbackRates as playbackRate (playbackRate)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
if (playerElement) playerElement.playbackRate = playbackRate;
}}
>
{playbackRate}
</li>
{/each}
{:else if playerSettings === 'language'}
{#each filterUniqueAudioTracks(player.getAudioTracks()) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectAudioTrack(track);
setActiveAudioTrack();
}}
>
{#if track.language !== 'und'}
{ISO6391.getName(track.language)} -
{/if}
{track.label}
</li>
{/each}
{/if}
</menu>
</button>
{#if playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)}
<button
class="surface-container-highest"
onclick={handleAirPlayClick}
title="AirPlay"
>
<i>airplay</i>
</button>
{/if}
{#if document.pictureInPictureEnabled}
<button
class="surface-container-highest"
onclick={() => {
(playerElement as HTMLVideoElement).requestPictureInPicture();
}}
>
<i>pip</i>
</button>
{/if}
<button class="surface-container-highest" onclick={toggleFullscreen}>
<i>
{#if playerIsFullscreen}
fullscreen_exit
{:else}
fullscreen
{/if}
</i>
</button>
<CaptionSettings {player} video={data.video} />
<Settings {player} {playerElement} />
<Airplay {playerElement} />
<Pip {playerElement} />
<FullscreenToggle {toggleFullscreen} {playerIsFullscreen} />
{/if}
</nav>
</nav>
@@ -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);
@@ -0,0 +1,29 @@
<script lang="ts">
let { playerElement }: { playerElement: HTMLMediaElement | undefined } = $props();
function hasWebkitShowPlaybackTargetPicker(
el: HTMLMediaElement
): el is HTMLMediaElement & { webkitShowPlaybackTargetPicker: () => void } {
return typeof (el as any).webkitShowPlaybackTargetPicker === 'function';
}
function handleAirPlayClick() {
if (playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)) {
playerElement.webkitShowPlaybackTargetPicker();
}
}
$effect(() => {
if (!playerElement || playerElement.hasAttribute('x-webkit-airplay')) return;
if (hasWebkitShowPlaybackTargetPicker(playerElement)) {
playerElement.setAttribute('x-webkit-airplay', 'allow');
}
});
</script>
{#if playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)}
<button class="surface-container-highest" onclick={handleAirPlayClick} title="AirPlay">
<i>airplay</i>
</button>
{/if}
@@ -0,0 +1,41 @@
<script lang="ts">
import type shaka from 'shaka-player/dist/shaka-player.ui';
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import type { VideoPlay } from '$lib/api/model';
let { player, video }: { player: shaka.Player; video: VideoPlay } = $props();
let playerTextTracks: shaka.extern.TextTrack[] | undefined = $state(undefined);
onMount(() => {
playerTextTracks = player.getTextTracks();
});
</script>
{#if playerTextTracks && playerTextTracks.length > 0 && !video.liveNow}
<button class="surface-container-highest">
<i>closed_caption</i>
<menu class="no-wrap mobile" id="cc-menu" data-ui="#cc-menu">
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => player.setTextTrackVisibility(false)}
>
{$_('player.controls.off')}
</li>
{#each playerTextTracks as track (track)}
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => {
player.selectTextTrack(track);
player.setTextTrackVisibility(true);
}}
>
{track.label}
</li>
{/each}
</menu>
</button>
{/if}
@@ -0,0 +1,16 @@
<script lang="ts">
let {
playerIsFullscreen,
toggleFullscreen
}: { playerIsFullscreen: boolean; toggleFullscreen: () => void } = $props();
</script>
<button class="surface-container-highest" onclick={toggleFullscreen}>
<i>
{#if playerIsFullscreen}
fullscreen_exit
{:else}
fullscreen
{/if}
</i>
</button>
@@ -0,0 +1,14 @@
<script lang="ts">
let { playerElement }: { playerElement: HTMLMediaElement | undefined } = $props();
</script>
{#if document.pictureInPictureEnabled}
<button
class="surface-container-highest"
onclick={() => {
(playerElement as HTMLVideoElement).requestPictureInPicture();
}}
>
<i>pip</i>
</button>
{/if}
@@ -0,0 +1,186 @@
<script lang="ts">
import { _ } from '$lib/i18n';
import type shaka from 'shaka-player/dist/shaka-player.ui';
import { SvelteSet } from 'svelte/reactivity';
import ISO6391 from 'iso-639-1';
import { playerAlwaysLoopStore } from '$lib/store';
import { playbackRates } from '$lib/player/index';
import { onMount } from 'svelte';
let {
player,
playerElement
}: { player: shaka.Player; playerElement: HTMLMediaElement | undefined } = $props();
let playerSettings: 'quality' | 'speed' | 'language' | 'root' = $state('root');
let playerCurrentVideoTrack: shaka.extern.VideoTrack | undefined = $state(undefined);
let playerCurrentAudioTrack: shaka.extern.AudioTrack | undefined = $state(undefined);
let playerLoop = $state($playerAlwaysLoopStore);
onMount(() => {
player.addEventListener('loaded', () => {
setActiveVideoTrack();
setActiveAudioTrack();
});
});
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 filterUniqueAudioTracks(tracks: shaka.extern.AudioTrack[]): shaka.extern.AudioTrack[] {
const uniqueTracks: shaka.extern.AudioTrack[] = [];
const seen = new SvelteSet<string>();
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;
}
</script>
<button class="surface-container-highest">
<i>settings</i>
<menu class="no-wrap mobile player-settings">
{#if playerSettings !== 'root'}
<li role="presentation" onclick={() => (playerSettings = 'root')}>
<i>arrow_back</i>
{$_('player.controls.back')}
</li>
{/if}
{#if playerSettings === 'root'}
<li role="presentation" onclick={() => (playerSettings = 'quality')}>
<nav class="no-wrap" style="width: 100%;">
<i>high_quality</i>
{$_('player.controls.quality')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentVideoTrack}
{playerCurrentVideoTrack.height}p
{:else}
{$_('player.controls.auto')}
{/if}
</span>
</nav>
</li>
<li role="presentation" onclick={() => (playerSettings = 'speed')}>
<nav class="no-wrap" style="width: 100%;">
<i>speed</i>
{$_('player.controls.playbackSpeed')}
<div class="max"></div>
<span class="chip">
{playerElement?.playbackRate}x
</span>
</nav>
</li>
{#if playerCurrentAudioTrack && playerCurrentAudioTrack.label !== null}
<li role="presentation" onclick={() => (playerSettings = 'language')}>
<nav class="no-wrap" style="width: 100%;">
<i>language</i>
{$_('player.controls.language')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentAudioTrack}
{playerCurrentAudioTrack.language !== 'und'
? ISO6391.getName(playerCurrentAudioTrack.language)
: playerCurrentAudioTrack.label}
{/if}
</span>
</nav>
</li>
{/if}
<li
role="presentation"
onclick={() => {
if (playerElement) playerElement.loop = !playerLoop;
playerLoop = !playerLoop;
}}
>
<nav class="no-wrap" style="width: 100%;">
<i>all_inclusive</i>
{$_('player.controls.loop')}
<div class="max"></div>
<span class="chip">
{playerLoop ? $_('player.controls.on') : $_('player.controls.off')}
</span>
</nav>
</li>
{:else if playerSettings === 'quality'}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.configure({ abr: true });
playerCurrentVideoTrack = undefined;
}}
>
{$_('player.controls.auto')}
</li>
{#each player.getVideoTracks().sort((a, b) => {
const heightA = a.height || 0;
const heightB = b.height || 0;
const widthA = a.width || 0;
const widthB = b.width || 0;
return heightB - heightA || widthB - widthA;
}) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectVideoTrack(track, true);
setActiveVideoTrack();
}}
>
{track.height}p
</li>
{/each}
{:else if playerSettings === 'speed'}
{#each playbackRates as playbackRate (playbackRate)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
if (playerElement) playerElement.playbackRate = playbackRate;
}}
>
{playbackRate}
</li>
{/each}
{:else if playerSettings === 'language'}
{#each filterUniqueAudioTracks(player.getAudioTracks()) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectAudioTrack(track);
setActiveAudioTrack();
}}
>
{#if track.language !== 'und'}
{ISO6391.getName(track.language)} -
{/if}
{track.label}
</li>
{/each}
{/if}
</menu>
</button>
+6
View File
@@ -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;
+95
View File
@@ -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
});
}
}
}
+91
View File
@@ -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);
}
}
}