Improved TV controls
This commit is contained in:
@@ -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 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);
|
||||
}
|
||||
});
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user