From cde5fec9ff7c0eb263320ff28a2da9a2c6b02d68 Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Thu, 7 May 2026 17:24:48 +0200 Subject: [PATCH] feat: playback overlay with seek bar, auto-hide and double-tap skip (v0.3.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 4 +- src/main/ipc/handlers.ts | 12 +- src/main/playback/playbackService.ts | 19 +- src/main/playback/types.ts | 6 + src/preload/index.ts | 8 + src/renderer/src/components/Icons.tsx | 25 ++ .../src/components/PlaybackOverlay.tsx | 305 +++++++++++++++++ src/renderer/src/screens/ChannelScreen.tsx | 71 ++-- src/renderer/src/styles/global.css | 319 ++++++++++++++++++ src/renderer/src/types/t4sd.d.ts | 3 + 10 files changed, 737 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/components/PlaybackOverlay.tsx diff --git a/package.json b/package.json index 66acffe..138b2d5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "twitch4steamdeck", - "version": "0.3.3", - "description": "Werbefreier Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)", + "version": "0.3.4", + "description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)", "main": "out/main/index.js", "author": "twitch4steamdeck", "license": "MIT", diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index b58d6a6..9ed4e06 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain } from 'electron' import type { AuthEvent, AuthService } from '../auth/authService' import type { HelixClient } from '../twitch/helixClient' import type { PlaybackService } from '../playback/playbackService' -import type { PlaybackEvent } from '../playback/types' +import type { PlaybackEvent, PlaybackTimeUpdate } from '../playback/types' import { getProgressMap } from '../store/historyRepo' export const IPC = { @@ -35,7 +35,9 @@ export const IPC = { playbackSetLoggingEnabled: 'playback:set-logging-enabled', playbackGetLogPath: 'playback:get-log-path', /** main → renderer */ - playbackEvent: 'playback:event' + playbackEvent: 'playback:event', + /** main → renderer: ~1 Hz position updates during VOD playback */ + playbackTimeUpdate: 'playback:time-update' } as const export function registerIpcHandlers( @@ -122,4 +124,10 @@ export function registerIpcHandlers( } } }) + + playback.on('playback-time-update', (data: PlaybackTimeUpdate) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IPC.playbackTimeUpdate, data) + } + }) } diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts index e77c0d4..7a5fdac 100644 --- a/src/main/playback/playbackService.ts +++ b/src/main/playback/playbackService.ts @@ -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) { diff --git a/src/main/playback/types.ts b/src/main/playback/types.ts index 4e927a0..670a93b 100644 --- a/src/main/playback/types.ts +++ b/src/main/playback/types.ts @@ -12,4 +12,10 @@ export interface PlaybackEvent { kind: 'started' | 'stopped' | 'error' channelLogin?: string message?: string + durationSeconds?: number + isLive?: boolean +} + +export interface PlaybackTimeUpdate { + positionSeconds: number } diff --git a/src/preload/index.ts b/src/preload/index.ts index 4484b41..cd61e33 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -29,6 +29,7 @@ const IPC = { playbackSetLoggingEnabled: 'playback:set-logging-enabled', playbackGetLogPath: 'playback:get-log-path', playbackEvent: 'playback:event', + playbackTimeUpdate: 'playback:time-update', gamepadInput: 'gamepad-input' } as const @@ -74,6 +75,8 @@ export interface PlaybackEvent { kind: 'started' | 'stopped' | 'error' channelLogin?: string message?: string + durationSeconds?: number + isLive?: boolean } export interface GameInfo { @@ -163,6 +166,11 @@ const api = { const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) ipcRenderer.on(IPC.playbackEvent, listener) return () => ipcRenderer.removeListener(IPC.playbackEvent, listener) + }, + onTimeUpdate: (cb: (data: { positionSeconds: number }) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, data: { positionSeconds: number }): void => cb(data) + ipcRenderer.on(IPC.playbackTimeUpdate, listener) + return () => ipcRenderer.removeListener(IPC.playbackTimeUpdate, listener) } }, gamepad: { diff --git a/src/renderer/src/components/Icons.tsx b/src/renderer/src/components/Icons.tsx index 6b4edab..67be8a6 100644 --- a/src/renderer/src/components/Icons.tsx +++ b/src/renderer/src/components/Icons.tsx @@ -58,6 +58,31 @@ export function UserIcon(props: IconProps): JSX.Element { ) } +export function PlayIcon(props: IconProps): JSX.Element { + return ( + + + + ) +} + +export function PauseIcon(props: IconProps): JSX.Element { + return ( + + + + + ) +} + +export function StopIcon(props: IconProps): JSX.Element { + return ( + + + + ) +} + export function SettingsIcon(props: IconProps): JSX.Element { return ( diff --git a/src/renderer/src/components/PlaybackOverlay.tsx b/src/renderer/src/components/PlaybackOverlay.tsx new file mode 100644 index 0000000..0c0ef09 --- /dev/null +++ b/src/renderer/src/components/PlaybackOverlay.tsx @@ -0,0 +1,305 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { GamepadHintItem } from './GamepadPrompt' +import { PauseIcon, PlayIcon, StopIcon } from './Icons' + +interface PlaybackOverlayProps { + playState: 'playing' | 'paused' + durationSeconds: number + isLive: boolean + currentPosition: number + channelName: string + channelAvatar: string + title: string + onTogglePause: () => void + onSeek: (seconds: number) => void + onSeekTo: (seconds: number) => void + onStop: () => void +} + +/** Formats seconds as HH:MM:SS */ +function fmt(s: number): string { + const total = Math.max(0, Math.floor(s)) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const sec = total % 60 + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` +} + +const HIDE_DELAY_MS = 5_000 +const DOUBLE_TAP_MS = 300 + +export function PlaybackOverlay({ + playState, + durationSeconds, + isLive, + currentPosition, + channelName, + channelAvatar, + title, + onTogglePause, + onSeek, + onSeekTo, + onStop +}: PlaybackOverlayProps): JSX.Element { + const [visible, setVisible] = useState(true) + const [isScrubbing, setIsScrubbing] = useState(false) + const [scrubPosition, setScrubPosition] = useState(0) + // "+10s" / "-10s" skip feedback + const [skipFeedback, setSkipFeedback] = useState<{ side: 'left' | 'right'; key: number } | null>(null) + + const hideTimerRef = useRef | null>(null) + const seekBarRef = useRef(null) + + // Double-tap tracking + const lastTapRef = useRef<{ time: number; side: 'left' | 'right' | 'center' } | null>(null) + + // ─── Auto-hide logic ──────────────────────────────────────────────────────── + + const clearHideTimer = useCallback(() => { + if (hideTimerRef.current !== null) { + clearTimeout(hideTimerRef.current) + hideTimerRef.current = null + } + }, []) + + const scheduleHide = useCallback(() => { + clearHideTimer() + hideTimerRef.current = setTimeout(() => setVisible(false), HIDE_DELAY_MS) + }, [clearHideTimer]) + + /** Call on any user interaction to show overlay and reset auto-hide timer. */ + const showOverlay = useCallback(() => { + setVisible(true) + if (playState === 'playing') { + scheduleHide() + } else { + clearHideTimer() + } + }, [playState, scheduleHide, clearHideTimer]) + + // React to playState changes + useEffect(() => { + if (playState === 'paused') { + clearHideTimer() + setVisible(true) + } else if (playState === 'playing') { + scheduleHide() + } + }, [playState, clearHideTimer, scheduleHide]) + + // Cleanup on unmount + useEffect(() => { + return () => clearHideTimer() + }, [clearHideTimer]) + + // Listen for keyboard events to show overlay on controller interaction + useEffect(() => { + const onKey = (): void => showOverlay() + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [showOverlay]) + + // ─── Seek bar ─────────────────────────────────────────────────────────────── + + const positionForEvent = useCallback((e: React.PointerEvent): number => { + const bar = seekBarRef.current + if (!bar || durationSeconds <= 0) return 0 + const rect = bar.getBoundingClientRect() + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + return ratio * durationSeconds + }, [durationSeconds]) + + const onSeekBarPointerDown = useCallback((e: React.PointerEvent): void => { + if (isLive || durationSeconds <= 0) return + e.currentTarget.setPointerCapture(e.pointerId) + const pos = positionForEvent(e) + setIsScrubbing(true) + setScrubPosition(pos) + showOverlay() + clearHideTimer() // keep overlay visible during scrub + }, [isLive, durationSeconds, positionForEvent, showOverlay, clearHideTimer]) + + const onSeekBarPointerMove = useCallback((e: React.PointerEvent): void => { + if (!isScrubbing) return + setScrubPosition(positionForEvent(e)) + }, [isScrubbing, positionForEvent]) + + const onSeekBarPointerUp = useCallback((e: React.PointerEvent): void => { + if (!isScrubbing) return + const pos = positionForEvent(e) + setIsScrubbing(false) + onSeekTo(pos) + showOverlay() // restart hide timer after seek + }, [isScrubbing, positionForEvent, onSeekTo, showOverlay]) + + // ─── Double-tap skip zones ────────────────────────────────────────────────── + + const showSkipFeedback = useCallback((side: 'left' | 'right') => { + setSkipFeedback({ side, key: Date.now() }) + setTimeout(() => setSkipFeedback(null), 700) + }, []) + + const onOverlayPointerDown = useCallback((e: React.PointerEvent): void => { + // Ignore if click came from seekbar or buttons + if ((e.target as HTMLElement).closest('.playback-overlay__seek-bar, button, img')) return + + const width = (e.currentTarget as HTMLDivElement).offsetWidth + const x = e.nativeEvent.offsetX + const side: 'left' | 'right' | 'center' = + x < width * 0.33 ? 'left' : x > width * 0.67 ? 'right' : 'center' + + const now = Date.now() + const last = lastTapRef.current + + if (last && now - last.time < DOUBLE_TAP_MS && last.side === side && side !== 'center') { + // Double tap + lastTapRef.current = null + if (side === 'left') { + onSeek(-10) + showSkipFeedback('left') + } else { + onSeek(10) + showSkipFeedback('right') + } + } else { + lastTapRef.current = { time: now, side } + // Single tap: toggle overlay visibility + if (!visible) { + showOverlay() + } else if (playState === 'playing') { + scheduleHide() + } + } + }, [visible, playState, onSeek, showOverlay, scheduleHide, showSkipFeedback]) + + // ─── Derived values ───────────────────────────────────────────────────────── + + const displayPosition = isScrubbing ? scrubPosition : currentPosition + const progress = durationSeconds > 0 ? Math.min(1, displayPosition / durationSeconds) : 0 + + // ─── Render ───────────────────────────────────────────────────────────────── + + return ( +
+ {/* Top gradient scrim */} +
+ + {/* Top hints bar */} +
+ {playState === 'playing' ? 'Pause' : 'Resume'} + · + −30s + · + +30s + · + −5min + · + +5min + · + Kapitel ← + · + Kapitel → + · + Kapitelmenü + · + Stop +
+ + {/* Center play/pause button */} +
+ +
+ + {/* Double-tap skip feedback */} + {skipFeedback && ( +
+ {skipFeedback.side === 'left' ? '−10s' : '+10s'} +
+ )} + + {/* Bottom bar */} +
+ {/* Channel info */} +
+ {channelAvatar && ( + {channelName} + )} + {channelName} + {isLive && LIVE} +
+ + {/* Video title */} + {title &&

{title}

} + + {/* Progress row */} + {!isLive && durationSeconds > 0 ? ( +
+ {fmt(displayPosition)} + +
+
+
+
+
+
+ + {fmt(durationSeconds)} +
+ ) : isLive ? ( +
+ + Live +
+ ) : null} + + {/* Stop button */} + +
+ + {/* Bottom gradient scrim */} +
+
+ ) +} diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx index 651725e..5b511f2 100644 --- a/src/renderer/src/screens/ChannelScreen.tsx +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' +import { PlaybackOverlay } from '../components/PlaybackOverlay' import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd' interface Props { @@ -72,6 +73,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false) const [currentVod, setCurrentVod] = useState(null) const [pendingChapterVod, setPendingChapterVod] = useState(null) + // Overlay state + const [currentPosition, setCurrentPosition] = useState(0) + const [vodDuration, setVodDuration] = useState(0) + const [isLivePlayback, setIsLivePlayback] = useState(false) const watchBtnRef = useRef(null) const chapterListRef = useRef(null) @@ -245,6 +250,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => { if (ev.kind === 'started') { setPlayState('playing') + setVodDuration(ev.durationSeconds ?? 0) + setIsLivePlayback(ev.isLive ?? false) + setCurrentPosition(0) } else if (ev.kind === 'stopped') { const nextChapterVod = pendingChapterVod setPlayState('idle') @@ -274,6 +282,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { return unsub }, [pendingChapterVod, vods]) + // Subscribe to ~1 Hz position updates from main process for the overlay seek bar + useEffect(() => { + if (playState !== 'playing' && playState !== 'paused') return + const unsub = window.t4sd.playback.onTimeUpdate(({ positionSeconds }) => { + setCurrentPosition(positionSeconds) + }) + return unsub + }, [playState]) + useEffect(() => { watchBtnRef.current?.focus() }, []) @@ -459,32 +476,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { )} {(playState === 'playing' || playState === 'paused') && ( - <> - - ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} - · - Pause/Resume - · - −30s - · - +30s - · - −5min - · - +5min - · - Kapitel zurück - · - Kapitel vor - · - Kapitelmenü - · - Stop - - - + + ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} + )} {!channel.isLive && playState === 'idle' && ( @@ -627,6 +621,31 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
)} + + {/* Player overlay – shown during VOD/Live playback */} + {(playState === 'playing' || playState === 'paused') && ( + { + if (playState === 'playing') { + void window.t4sd.playback.pause() + setPlayState('paused') + } else { + void window.t4sd.playback.resume() + setPlayState('playing') + } + }} + onSeek={(s) => void window.t4sd.playback.seek(s)} + onSeekTo={(s) => void window.t4sd.playback.seekTo(s)} + onStop={() => void handleStop()} + /> + )}
) } diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 39af1aa..62f1cb1 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -1541,3 +1541,322 @@ body, box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.22), 0 14px 28px rgba(0, 0, 0, 0.32); transform: translateY(-1px); } + +/* ═══════════════════════════════════════════════════════════════════ + PLAYBACK OVERLAY + Covers the full window during playback. Fades in/out. + z-index 300 places it above the chapter overlay (z-index 200). +═══════════════════════════════════════════════════════════════════ */ + +.playback-overlay { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + flex-direction: column; + /* Always capture pointer events so touch can re-show the overlay. + The overlay only renders during active playback, so nothing useful + sits beneath it that the user needs to reach. */ + pointer-events: auto; + /* Fade transition */ + opacity: 0; + transition: opacity 0.3s ease; +} + +.playback-overlay[data-visible='true'] { + opacity: 1; +} + +/* Top gradient scrim – darkens top-third for readability */ +.playback-overlay__scrim-top { + position: absolute; + inset: 0 0 auto 0; + height: 30%; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75) 0%, transparent 100%); + pointer-events: none; +} + +/* Bottom gradient scrim – covers bottom half */ +.playback-overlay__scrim-bottom { + position: absolute; + inset: auto 0 0 0; + height: 50%; + background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, transparent 100%); + pointer-events: none; +} + +/* ── Top hints bar ───────────────────────────────────────────────── */ + +.playback-overlay__top-bar { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 4px 2px; + padding: 18px 28px 0; + font-size: 15px; + color: rgba(255, 255, 255, 0.75); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} + +.playback-overlay__hint-sep { + margin: 0 4px; + opacity: 0.4; +} + +/* ── Center play/pause ───────────────────────────────────────────── */ + +.playback-overlay__center { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; +} + +.playback-overlay__center-btn { + /* Visual circle */ + width: 88px; + height: 88px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + border: 2px solid rgba(255, 255, 255, 0.18); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s, border-color 0.15s; + /* Larger touch/click target via padding trick */ + padding: 0; + /* Minimum accessible touch target */ + min-width: 88px; + min-height: 88px; + flex-shrink: 0; +} + +.playback-overlay__center-btn:hover, +.playback-overlay__center-btn:focus-visible { + background: rgba(145, 71, 255, 0.45); + border-color: var(--color-accent); + outline: none; +} + +.playback-overlay__center-btn:active { + transform: scale(0.93); +} + +/* ── Bottom bar ──────────────────────────────────────────────────── */ + +.playback-overlay__bottom { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 28px 24px; +} + +/* Channel badge row */ +.playback-overlay__channel { + display: flex; + align-items: center; + gap: 10px; +} + +.playback-overlay__avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(255, 255, 255, 0.2); + flex-shrink: 0; +} + +.playback-overlay__channel-name { + font-size: 18px; + font-weight: 600; + color: #fff; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); +} + +.playback-overlay__title { + margin: 0; + font-size: 17px; + font-weight: 400; + color: rgba(255, 255, 255, 0.85); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* Fills the full bottom-bar width (minus 28px padding each side). + Wider screens (docked Steam Deck at 1080p/4K) naturally show more text. */ + max-width: 100%; +} + +.playback-overlay__live-badge { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + color: #fff; + background: var(--color-live); + border-radius: 4px; + padding: 2px 7px; + flex-shrink: 0; +} + +/* Progress row: time — seekbar — time */ +.playback-overlay__progress-row { + display: flex; + align-items: center; + gap: 12px; +} + +.playback-overlay__time { + font-size: 16px; + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.9); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); + white-space: nowrap; + flex-shrink: 0; + min-width: 80px; +} + +.playback-overlay__time:last-child { + text-align: right; +} + +/* Seek bar container – tall hit target, visual bar inside */ +.playback-overlay__seek-bar { + flex: 1; + height: 44px; /* large touch target */ + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + touch-action: none; +} + +.playback-overlay__seek-track { + position: relative; + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.25); + border-radius: 3px; + transition: height 0.15s; +} + +.playback-overlay__seek-bar:hover .playback-overlay__seek-track, +.playback-overlay__seek-bar:active .playback-overlay__seek-track { + height: 10px; + border-radius: 5px; +} + +.playback-overlay__seek-filled { + position: absolute; + left: 0; + top: 0; + height: 100%; + background: var(--color-accent); + border-radius: inherit; + pointer-events: none; +} + +.playback-overlay__seek-knob { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 0 0 2px var(--color-accent), 0 2px 6px rgba(0, 0, 0, 0.5); + pointer-events: none; + transition: transform 0.1s, width 0.1s, height 0.1s; +} + +.playback-overlay__seek-bar:hover .playback-overlay__seek-knob, +.playback-overlay__seek-bar:active .playback-overlay__seek-knob { + width: 22px; + height: 22px; +} + +/* Live indicator (no seekbar) */ +.playback-overlay__live-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +.playback-overlay__live-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-live); + animation: live-pulse 1.8s ease-in-out infinite; +} + +@keyframes live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Stop button */ +.playback-overlay__stop-btn { + align-self: flex-end; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); + color: rgba(255, 255, 255, 0.85); + font-size: 16px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + min-height: 44px; +} + +.playback-overlay__stop-btn:hover, +.playback-overlay__stop-btn:focus-visible { + background: rgba(235, 4, 0, 0.35); + border-color: var(--color-live); + outline: none; +} + +/* Double-tap skip feedback */ +.playback-overlay__skip-feedback { + position: absolute; + top: 50%; + transform: translateY(-50%); + font-size: 28px; + font-weight: 700; + color: #fff; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8); + pointer-events: none; + animation: skip-fade 0.7s ease-out forwards; + z-index: 2; +} + +.playback-overlay__skip-feedback--left { + left: 60px; +} + +.playback-overlay__skip-feedback--right { + right: 60px; +} + +@keyframes skip-fade { + 0% { opacity: 1; transform: translateY(-50%); } + 60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); } + 100% { opacity: 0; transform: translateY(calc(-50% - 24px)); } +} diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 7fbf13a..4380673 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -48,6 +48,8 @@ export interface PlaybackEvent { kind: 'started' | 'stopped' | 'error' channelLogin?: string message?: string + durationSeconds?: number + isLive?: boolean } export interface VodInfo { @@ -114,6 +116,7 @@ export interface T4sdApi { setLoggingEnabled: (enabled: boolean) => Promise getLogPath: () => Promise onEvent: (cb: (event: PlaybackEvent) => void) => () => void + onTimeUpdate: (cb: (data: { positionSeconds: number }) => void) => () => void } gamepad: { onInput: (cb: (key: string) => void) => () => void