feat: playback overlay with seek bar, auto-hide and double-tap skip (v0.3.4)

New PlaybackOverlay component replaces the old hint bar in ChannelScreen.
The overlay sits as a fullscreen layer above the app during playback:

- Auto-hides after 5 s while playing; stays visible when paused;
  reactivated by any gamepad key press or mouse movement
- Center play/pause button, channel avatar, title, LIVE badge
- VOD: seek bar with pointer-capture scrubbing, current time / total duration
- Live: pulsing LIVE indicator instead of seek bar
- Double-tap zones left/right (±10 s) with animated skip feedback
- Gamepad hints in the top bar
- New icons: PlayIcon, PauseIcon, StopIcon in Icons.tsx
- New IPC channel `playback:time-update` (~1 Hz) pushes absolute playback
  position from main process to renderer; adds PlaybackTimeUpdate type
  and onTimeUpdate bridge in preload + t4sd.d.ts
- PlaybackEvent extended with durationSeconds and isLive fields
This commit is contained in:
Andre
2026-05-07 17:24:48 +02:00
parent 87eb37c60c
commit cde5fec9ff
10 changed files with 737 additions and 35 deletions
+14 -5
View File
@@ -5,7 +5,7 @@ import path from 'node:path'
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
import { getMpvIpcPath, MpvController } from './mpvController'
import { createTrimmedPlaylist, cleanupTrimmedPlaylist, type TrimResult } from './hlsTrimmer'
import type { PlaybackEvent } from './types'
import type { PlaybackEvent, PlaybackTimeUpdate } from './types'
import * as history from '../store/historyRepo'
const POSITION_WRITE_INTERVAL_MS = 5_000
@@ -137,7 +137,7 @@ export class PlaybackService extends EventEmitter {
loadGeneration: 0,
pendingLoadGeneration: null
}
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
this.emit('playback-event', { kind: 'started', channelLogin, isLive: true } satisfies PlaybackEvent)
}
async startVod(
@@ -241,7 +241,7 @@ export class PlaybackService extends EventEmitter {
loadGeneration: 0,
pendingLoadGeneration: null
}
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
this.emit('playback-event', { kind: 'started', durationSeconds } satisfies PlaybackEvent)
// IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
mpv.connect().then(() => {
@@ -268,15 +268,24 @@ export class PlaybackService extends EventEmitter {
// Position-Tracking
let lastWrite = 0
let lastTimeUpdateEmit = 0
const writePosition = (seconds: number): void => {
const now = Date.now()
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
if (!this.current || this.current.mpv !== mpv) return
if (this.isReloading(this.current)) return
lastWrite = now
const absoluteSeconds = seconds + this.current.playbackOffsetSeconds
this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds
// ~1 Hz push to renderer for the player overlay seek bar
if (now - lastTimeUpdateEmit >= 1_000) {
lastTimeUpdateEmit = now
this.emit('playback-time-update', { positionSeconds: absoluteSeconds } satisfies PlaybackTimeUpdate)
}
// 5s throttle for SQLite history writes
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
lastWrite = now
const pos = Math.floor(absoluteSeconds)
history.updatePosition(vodId, pos)
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {