Progress on custom player ui

This commit is contained in:
WardPearce
2025-12-22 06:30:44 +13:00
parent 437c8da281
commit 8adf956ab1
5 changed files with 326 additions and 87 deletions
+311 -84
View File
@@ -10,8 +10,6 @@
import ui from 'beercss';
import ISO6391 from 'iso-639-1';
import Mousetrap from 'mousetrap';
import 'shaka-player/dist/controls.css';
import '$lib/css/shaka-player-theme.css';
import { CapacitorMusicControls } from 'capacitor-music-controls-plugin';
import shaka from 'shaka-player/dist/shaka-player.ui';
import { SponsorBlock, type Category, type Segment } from 'sponsorblock-api';
@@ -22,9 +20,9 @@
import type { VideoPlay } from '../api/model';
import {
authStore,
darkModeStore,
instanceStore,
isAndroidTvStore,
playerAlwaysLoopStore,
playerAndroidLockOrientation,
playerAutoPlayStore,
playerCCByDefault,
@@ -34,7 +32,6 @@
playerProxyVideosStore,
playerSavePlaybackPositionStore,
playerState,
playerStatisticsByDefault,
playertheatreModeIsActive,
playerYouTubeJsFallback,
sponsorBlockCategoriesStore,
@@ -42,24 +39,30 @@
sponsorBlockStore,
sponsorBlockUrlStore,
synciousInstanceStore,
synciousStore,
themeColorStore
synciousStore
} from '../store';
import { getDynamicTheme, setStatusBarColor } from '../theme';
import { setStatusBarColor } from '../theme';
import { patchYoutubeJs } from '$lib/patches/youtubejs';
import { goToNextVideo, goToPreviousVideo, playbackRates } from '$lib/player';
import { EndTimeElement } from '$lib/shaka-elements/endTime';
import { dashManifestDomainInclusion } from '$lib/android/youtube/dash';
import { injectSabr } from '$lib/sabr';
import type { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
isEmbed?: boolean;
playerElement?: HTMLMediaElement | undefined;
hideControls?: boolean;
}
let { data, isEmbed = false, playerElement = $bindable(undefined) }: Props = $props();
let {
data,
isEmbed = false,
playerElement = $bindable(undefined),
hideControls = false
}: Props = $props();
let segments: Segment[] = [];
@@ -70,24 +73,21 @@
let showVideoRetry = $state(false);
let player: shaka.Player;
let shakaUi: shaka.ui.Overlay;
let sabrAdapter: SabrStreamingAdapter | null;
let playerContainer: HTMLElement;
let playerCurrentPlaybackState = $state(false);
let playerCurrentTime = $state(0);
let playerIsSeeking = $state(false);
let playerIsBuffering = $state(false);
let playerVolume = $state(0);
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);
const STORAGE_KEY_VOLUME = 'shaka-preferred-volume';
async function updateSeekBarTheme() {
if (!shakaUi) return;
await tick();
shakaUi.configure({
seekBarColors: {
played: (await getDynamicTheme())['--primary']
}
});
}
themeColorStore.subscribe(updateSeekBarTheme);
darkModeStore.subscribe(updateSeekBarTheme);
playertheatreModeIsActive.subscribe(async () => {
await tick();
updateVideoPlayerHeight();
@@ -115,8 +115,24 @@
}
}
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;
localStorage.setItem(STORAGE_KEY_VOLUME, playerElement.volume.toString());
}
@@ -124,6 +140,7 @@
const savedVolume = localStorage.getItem(STORAGE_KEY_VOLUME);
if (savedVolume && playerElement) {
playerElement.volume = parseFloat(savedVolume);
playerVolume = playerElement.volume;
}
}
@@ -304,20 +321,26 @@
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);
}
async function loadVideo() {
showVideoRetry = false;
sabrAdapter = await injectSabr(data.video, player);
try {
document.getElementsByClassName('shaka-ad-info')[0].remove();
} catch {
// Continue regardless of error
}
player.addEventListener('loaded', () => {
restoreQualityPreference();
restoreDefaultLanguage();
setActiveVideoTrack();
setActiveAudioTrack();
if ($playerCCByDefault) {
toggleSubtitles();
@@ -427,14 +450,6 @@
if ($playerDefaultPlaybackSpeed && playerElement) {
playerElement.playbackRate = $playerDefaultPlaybackSpeed;
}
if ($playerStatisticsByDefault) {
// Appears to be no native way in shaka to toggle statistics on and off
const shakaStatisticsButton = document.querySelector('.shaka-statistics-button') as
| HTMLButtonElement
| undefined;
shakaStatisticsButton?.click();
}
}
async function reloadVideo() {
@@ -443,6 +458,14 @@
await loadVideo();
}
function toggleVideoPlaybackStatus() {
if (playerCurrentPlaybackState) {
playerElement?.pause();
} else {
playerElement?.play();
}
}
// Due to how our player is rendered in layout for stateful pip
// we calaculate player height to then allow children pages
// to wrap around it.
@@ -482,6 +505,8 @@
});
playerElement = document.getElementById('player') as HTMLMediaElement;
playerElement.loop = playerLoop;
if ($playerState) {
playerState.set({ ...$playerState, playerElement: playerElement });
}
@@ -489,13 +514,11 @@
// Change instantly to stop video from being loud for a second
restoreVolumePreference();
playerContainer = document.getElementById('shaka-container') as HTMLElement;
playerContainer = document.getElementById('player-container') as HTMLElement;
window.addEventListener('resize', updateVideoPlayerHeight);
updateVideoPlayerHeight();
shakaUi = new shaka.ui.Overlay(player, playerContainer, playerElement);
await player.attach(playerElement);
shaka.ui.Controls.registerElement('end_time', {
@@ -504,33 +527,6 @@
}
});
shakaUi.configure({
addBigPlayButton: Capacitor.getPlatform() === 'android',
controlPanelElements: [
'play_pause',
Capacitor.getPlatform() === 'android' ? '' : 'volume',
'spacer',
'time_and_duration',
data.video.liveNow || data.video.lengthSeconds < 240 ? '' : 'end_time',
'captions',
'overflow_menu',
'fullscreen'
],
overflowMenuButtons: [
'cast',
'airplay',
'quality',
'playback_rate',
'loop',
'language',
'statistics'
],
playbackRates: playbackRates,
enableTooltips: false
});
updateSeekBarTheme();
player?.addEventListener('error', (event) => {
const error = (event as CustomEvent).detail as shaka.util.Error;
console.error('Player error:', error);
@@ -715,17 +711,6 @@
});
}
const volumeContainer = playerContainer.getElementsByClassName('shaka-volume-bar-container');
volumeContainer[0]?.addEventListener('mousewheel', (event) => {
event.preventDefault();
const delta = Math.sign((event as any).deltaY);
const newVolume = Math.max(
0,
Math.min(1, (playerElement as HTMLMediaElement).volume - delta * 0.05)
);
(playerElement as HTMLMediaElement).volume = newVolume;
});
Mousetrap.bind('c', () => {
toggleSubtitles();
return false;
@@ -735,7 +720,7 @@
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
shakaUi.getControls()?.toggleFullScreen();
// TODO
}
return false;
});
@@ -771,13 +756,32 @@
});
playerElement?.addEventListener('pause', async () => {
playerCurrentPlaybackState = false;
playerIsBuffering = false;
savePlayerPos();
});
playerElement?.addEventListener('ended', async () => {
playerCurrentPlaybackState = false;
playerIsBuffering = false;
await goToNextVideo(data.video, data.playlistId);
});
playerElement?.addEventListener('playing', () => {
playerCurrentPlaybackState = true;
playerIsBuffering = false;
});
playerElement?.addEventListener('waiting', () => {
playerCurrentPlaybackState = false;
playerIsBuffering = true;
});
playerElement?.addEventListener('timeupdate', () => {
if (playerIsSeeking) return;
playerCurrentTime = playerElement?.currentTime ?? 0;
});
try {
await loadVideo();
} catch (error: unknown) {
@@ -893,19 +897,13 @@
player.unload();
player.destroy();
}
if (shakaUi) {
shakaUi.destroy();
}
});
</script>
<div
id="shaka-container"
class="player-theme"
id="player-container"
class:contain-video={!$isAndroidTvStore}
class:tv-contain-video={$isAndroidTvStore}
data-shaka-player-container
class:hide={showVideoRetry}
>
<video
@@ -919,6 +917,200 @@
{data.video.title}
</div>
{/if}
<div id="player-center">
{#if playerIsBuffering}
<progress class="circle large indeterminate" value="50" max="100"></progress>
{:else if !playerCurrentPlaybackState}
<button class="circle large">
<i>play_arrow</i>
</button>
{/if}
</div>
{#if !hideControls}
<div id="player-controls">
<article class="round white" style="width: 100%;padding: 0;height: 10px;">
<label class="slider max">
<input
oninput={() => {
playerIsSeeking = true;
if (playerElement) playerElement.currentTime = playerCurrentTime;
}}
onchange={() => (playerIsSeeking = false)}
type="range"
min="0"
bind:value={playerCurrentTime}
max={data.video.lengthSeconds}
/>
<span></span>
</label>
</article>
<nav>
<nav class="no-wrap">
<button onclick={toggleVideoPlaybackStatus}>
<i>
{#if playerCurrentPlaybackState}
pause
{:else}
play_arrow
{/if}
</i>
</button>
{#if Capacitor.getPlatform() !== 'android'}
<label class="slider">
<input
oninput={() => {
if (!playerElement) return;
playerElement.volume = playerVolume;
}}
bind:value={playerVolume}
type="range"
step="0.1"
max="1"
/>
<span></span>
</label>
{/if}
</nav>
<div class="max"></div>
<nav class="no-wrap">
<p class="no-padding no-margin">
{videoLength(playerCurrentTime)} / {videoLength(data.video.lengthSeconds)}
</p>
<button>
<i>settings</i>
<menu class="no-wrap mobile">
{#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>
<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}
{ISO6391.getName(playerCurrentAudioTrack.language)}
{/if}
</span>
</nav>
</li>
<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();
}}
>
{ISO6391.getName(track.language)} - {track.label}
</li>
{/each}
{/if}
</menu>
</button>
</nav>
</nav>
</div>
{/if}
</div>
{#if showVideoRetry}
@@ -948,6 +1140,41 @@
{/if}
<style>
#player-container {
position: relative;
width: 100%;
}
#player-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
opacity: 0;
transition: opacity 2s ease;
padding: 10px;
}
#player-center {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#player-container:hover #player-controls {
opacity: 1;
transition: opacity 0.3s ease;
}
menu.mobile {
transform: scale(1) translateY(-50%) translateX(0);
width: 300px;
height: 200px;
}
.contain-video {
max-height: 80vh;
max-width: calc(80vh * 16 / 9);
+1
View File
@@ -82,6 +82,7 @@ menu {
top: 10% !important;
width: 100% !important;
z-index: 99999;
transform: unset !important;
}
main.root {
+11 -1
View File
@@ -124,7 +124,17 @@
"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"
"enableYoutubejsPerm": "Always allow local video fallback",
"controls": {
"back": "Back",
"quality": "Quality",
"auto": "Auto",
"playbackSpeed": "Playback speed",
"loop": "Loop",
"on": "On",
"off": "Off",
"language": "Language"
}
},
"layout": {
"interface": "Interface",
+2 -1
View File
@@ -418,6 +418,7 @@
<i>close</i>
</button>
</nav>
<div class="space"></div>
<nav class="s">
<a
class="button border"
@@ -432,7 +433,7 @@
{/if}
<div class="player">
{#key $playerState.data.video.videoId}
<Player data={$playerState.data} />
<Player data={$playerState.data} hideControls={playerIsPip} />
{/key}
</div>
</div>
@@ -112,7 +112,7 @@
</script>
{#key data.video.videoId}
<Player bind:playerElement isEmbed={true} {data} />
<Player bind:playerElement isEmbed={true} {data} hideControls={true} />
{/key}
{#if showInfo}