From 11c63da98ec19e4e6e184217a78d629fea0ac1e6 Mon Sep 17 00:00:00 2001
From: WardPearce
Date: Wed, 21 Jan 2026 21:34:53 +1300
Subject: [PATCH] Improved TV controls
---
materialious/src/lib/components/Player.svelte | 42 +++++----
.../routes/(no-layout)/tv/[slug]/+page.svelte | 87 +++++++++++--------
2 files changed, 74 insertions(+), 55 deletions(-)
diff --git a/materialious/src/lib/components/Player.svelte b/materialious/src/lib/components/Player.svelte
index 61ff7720..8a7ae06e 100644
--- a/materialious/src/lib/components/Player.svelte
+++ b/materialious/src/lib/components/Player.svelte
@@ -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;
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}
@@ -1248,12 +1250,13 @@
- {#if !playerUserManualSeeking}
+ {#if !userManualSeeking}
-
+
{#each data.content.timestamps as chapter, index (chapter)}
{#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;
diff --git a/materialious/src/routes/(no-layout)/tv/[slug]/+page.svelte b/materialious/src/routes/(no-layout)/tv/[slug]/+page.svelte
index 86fc2e11..5fac02d1 100644
--- a/materialious/src/routes/(no-layout)/tv/[slug]/+page.svelte
+++ b/materialious/src/routes/(no-layout)/tv/[slug]/+page.svelte
@@ -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
| 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 don’t 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);
- }
});
{#key data.video.videoId}
-
+
{/key}
{#if showInfo}