Improved TV controls

This commit is contained in:
WardPearce
2026-01-21 21:34:53 +13:00
parent a4604b122a
commit 11c63da98e
2 changed files with 74 additions and 55 deletions
+23 -19
View File
@@ -61,6 +61,8 @@
interface Props {
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
currentTime?: number;
userManualSeeking?: boolean;
isEmbed?: boolean;
playerElement?: HTMLMediaElement | undefined;
showControls?: boolean;
@@ -70,6 +72,8 @@
data,
isEmbed = false,
playerElement = $bindable(undefined),
currentTime = $bindable(0),
userManualSeeking = $bindable(false),
showControls = false
}: Props = $props();
@@ -98,7 +102,6 @@
let playerContainer: HTMLElement;
let playerBufferBar: HTMLElement | undefined = $state();
let playerCurrentPlaybackState = $state(false);
let playerCurrentTime = $state(0);
let playerMaxKnownTime = $state(data.video.lengthSeconds);
let playerIsBuffering = $state(true);
let playerVolume = $state(0);
@@ -115,23 +118,22 @@
let playerSliderElement: HTMLElement | undefined = $state();
let playerSliderDebounce: ReturnType<typeof setTimeout>;
let playerVolumeElement: HTMLElement | undefined = $state();
let playerUserManualSeeking: boolean = $state(false);
let playerIsFullscreen: boolean = $state(false);
const playerTimelineSlider = new Slider({
min: 0,
step: 0.1,
value: () => playerCurrentTime,
value: () => currentTime,
onValueChange: (timeToSet) => {
playerUserManualSeeking = true;
playerCurrentTime = timeToSet;
userManualSeeking = true;
currentTime = timeToSet;
if (playerSliderDebounce) clearTimeout(playerSliderDebounce);
playerSliderDebounce = setTimeout(() => {
if (playerElement) {
playerElement.currentTime = timeToSet;
playerUserManualSeeking = false;
userManualSeeking = false;
}
}, 300);
},
@@ -912,11 +914,11 @@
playerElement?.addEventListener('timeupdate', () => {
if (!playerElement) return;
if (!playerUserManualSeeking) {
playerCurrentTime = playerElement.currentTime ?? 0;
if (!userManualSeeking) {
currentTime = playerElement.currentTime ?? 0;
}
if (playerMaxKnownTime === 0 || playerCurrentTime > playerMaxKnownTime) {
if (playerMaxKnownTime === 0 || currentTime > playerMaxKnownTime) {
playerMaxKnownTime = Number(playerElement.currentTime);
}
@@ -935,7 +937,7 @@
playerBufferedTo = buffered.end(0);
const bufferedPercent = (playerBufferedTo / playerMaxKnownTime) * 100;
const progressPercent = (playerCurrentTime / playerMaxKnownTime) * 100;
const progressPercent = (currentTime / playerMaxKnownTime) * 100;
const bufferAhead = Math.max(0, bufferedPercent - progressPercent);
@@ -1203,7 +1205,7 @@
{#if data.video.liveNow}
{$_('thumbnail.live')}
{:else}
{videoLength(playerCurrentTime)} / {videoLength(playerMaxKnownTime)}
{videoLength(currentTime)} / {videoLength(playerMaxKnownTime)}
{/if}
</p>
<p class="chip inverse-primary">
@@ -1248,12 +1250,13 @@
<div id="player-controls" transition:fade>
<div
class="player-slider full-width"
class:disable-tv={$isAndroidTvStore}
{...playerTimelineSlider.root}
onmousemove={timelineMouseMove}
bind:this={playerSliderElement}
>
<div class="track">
{#if !playerUserManualSeeking}
{#if !userManualSeeking}
<div class="tooltip" style="position: absolute;left: var(--timeline-tooltip-left);">
{#if playerCloestSponsor}
{sponsorSegments[playerCloestSponsor.category]}
@@ -1269,15 +1272,11 @@
<div class="range"></div>
<div {...playerTimelineSlider.thumb}>
<div class="tooltip thumb-tooltip">
{videoLength(playerCurrentTime)}
{videoLength(currentTime)}
</div>
</div>
</div>
<div
bind:this={playerBufferBar}
class="buffered-bar"
class:hide={playerUserManualSeeking}
></div>
<div bind:this={playerBufferBar} class="buffered-bar" class:hide={userManualSeeking}></div>
{#each data.content.timestamps as chapter, index (chapter)}
<div
class="chapter-marker"
@@ -1333,7 +1332,7 @@
{#if data.video.liveNow}
{$_('thumbnail.live')}
{:else}
{videoLength(playerCurrentTime)} / {videoLength(data.video.lengthSeconds)}
{videoLength(currentTime)} / {videoLength(data.video.lengthSeconds)}
{/if}
</p>
{#if !$isAndroidTvStore}
@@ -1685,6 +1684,11 @@
background-color: var(--tertiary);
}
.disable-tv {
pointer-events: none;
cursor: not-allowed;
}
menu.mobile {
transform: scale(1) translateY(-40%) translateX(0);
width: 300px;
@@ -18,45 +18,51 @@
let showInfo = $state(false);
let playerCurrentTime: number = $state(0);
let showControls = $state(false);
let currentTime = $state(0);
let userManualSeeking = $state(false);
let seekInterval: ReturnType<typeof setTimeout> | undefined;
let seekStartTime: number = 0;
let seekRaf: number | null = null;
let seekStartTime = 0;
let isSeeking = false;
let lastUpdate = 0;
function startSeeking(direction: 'left' | 'right') {
showControls = true;
if (!playerElement || showInfo || isSeeking) return true;
userManualSeeking = true;
isSeeking = true;
showControls = true;
seekStartTime = Date.now();
lastUpdate = performance.now();
const videoDuration = data.video.lengthSeconds;
const baseSeekAmount = videoDuration * 0.005; // 0.5% of video duration as base
const duration = data.video.lengthSeconds;
const base = duration * 0.005; // ~0.5%
// Initial seek
if (direction === 'right') {
playerElement.currentTime = Math.min(
playerElement.currentTime + baseSeekAmount,
videoDuration
);
} else {
playerElement.currentTime = Math.max(playerElement.currentTime - baseSeekAmount, 0);
function step(now: number) {
if (!isSeeking) return;
const elapsedTotal = (now - seekStartTime) / 1000; // total hold time
const throttleDelta = now - lastUpdate;
// only update every ~100ms so we dont overshoot
if (throttleDelta > 100) {
const factor = 1 + Math.min(elapsedTotal ** 1.2, 3); // gentler acceleration
const amount = base * factor;
if (direction === 'right') {
currentTime = Math.min(currentTime + amount, duration);
} else {
currentTime = Math.max(currentTime - amount, 0);
}
lastUpdate = now;
}
seekRaf = requestAnimationFrame(step);
}
// Progressive seeking
seekInterval = setInterval(() => {
if (!playerElement || !isSeeking) return;
const holdDuration = (Date.now() - seekStartTime) / 1000; // in seconds
const acceleration = Math.min(holdDuration * 0.5, 5); // Cap at 5x acceleration
const seekAmount = baseSeekAmount * (1 + acceleration);
if (direction === 'right') {
playerElement.currentTime = Math.min(playerElement.currentTime + seekAmount, videoDuration);
} else {
playerElement.currentTime = Math.max(playerElement.currentTime - seekAmount, 0);
}
}, 100);
seekStartTime = performance.now();
seekRaf = requestAnimationFrame(step);
return false;
}
@@ -64,11 +70,16 @@
function stopSeeking() {
isSeeking = false;
showControls = false;
if (seekInterval) {
clearInterval(seekInterval);
seekInterval = undefined;
userManualSeeking = false;
if (seekRaf !== null) {
cancelAnimationFrame(seekRaf);
seekRaf = null;
}
if (playerElement) {
playerElement.currentTime = currentTime;
}
return false;
}
onMount(() => {
@@ -149,14 +160,18 @@
onDestroy(() => {
Mousetrap.unbind(['up', 'down', 'left', 'right', 'enter']);
if (seekInterval) {
clearInterval(seekInterval);
}
});
</script>
{#key data.video.videoId}
<Player bind:playerElement isEmbed={true} {data} {showControls} />
<Player
bind:playerElement
bind:currentTime
bind:userManualSeeking
isEmbed={true}
{data}
{showControls}
/>
{/key}
{#if showInfo}