mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
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:
+2
-2
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,4 +12,10 @@ export interface PlaybackEvent {
|
||||
kind: 'started' | 'stopped' | 'error'
|
||||
channelLogin?: string
|
||||
message?: string
|
||||
durationSeconds?: number
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export interface PlaybackTimeUpdate {
|
||||
positionSeconds: number
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -58,6 +58,31 @@ export function UserIcon(props: IconProps): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
export function PlayIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props} fill="currentColor" stroke="none">
|
||||
<polygon points="5,3 19,12 5,21" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PauseIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props} fill="currentColor" stroke="none">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function StopIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props} fill="currentColor" stroke="none">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | null>(null)
|
||||
const seekBarRef = useRef<HTMLDivElement>(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<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): void => {
|
||||
if (!isScrubbing) return
|
||||
setScrubPosition(positionForEvent(e))
|
||||
}, [isScrubbing, positionForEvent])
|
||||
|
||||
const onSeekBarPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>): 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<HTMLDivElement>): 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 (
|
||||
<div
|
||||
className="playback-overlay"
|
||||
data-visible={visible}
|
||||
onPointerDown={onOverlayPointerDown}
|
||||
onMouseMove={showOverlay}
|
||||
>
|
||||
{/* Top gradient scrim */}
|
||||
<div className="playback-overlay__scrim-top" />
|
||||
|
||||
{/* Top hints bar */}
|
||||
<div className="playback-overlay__top-bar">
|
||||
<GamepadHintItem prompt="a">{playState === 'playing' ? 'Pause' : 'Resume'}</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="dpad-right">+30s</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="lt">−5min</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="lb">Kapitel ←</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="rb">Kapitel →</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
|
||||
</div>
|
||||
|
||||
{/* Center play/pause button */}
|
||||
<div className="playback-overlay__center">
|
||||
<button
|
||||
className="playback-overlay__center-btn"
|
||||
onClick={() => { onTogglePause(); showOverlay() }}
|
||||
aria-label={playState === 'playing' ? 'Pause' : 'Wiedergabe'}
|
||||
>
|
||||
{playState === 'playing'
|
||||
? <PauseIcon width={40} height={40} />
|
||||
: <PlayIcon width={40} height={40} />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Double-tap skip feedback */}
|
||||
{skipFeedback && (
|
||||
<div
|
||||
key={skipFeedback.key}
|
||||
className={`playback-overlay__skip-feedback playback-overlay__skip-feedback--${skipFeedback.side}`}
|
||||
>
|
||||
{skipFeedback.side === 'left' ? '−10s' : '+10s'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="playback-overlay__bottom">
|
||||
{/* Channel info */}
|
||||
<div className="playback-overlay__channel">
|
||||
{channelAvatar && (
|
||||
<img
|
||||
className="playback-overlay__avatar"
|
||||
src={channelAvatar}
|
||||
alt={channelName}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
<span className="playback-overlay__channel-name">{channelName}</span>
|
||||
{isLive && <span className="playback-overlay__live-badge">LIVE</span>}
|
||||
</div>
|
||||
|
||||
{/* Video title */}
|
||||
{title && <p className="playback-overlay__title">{title}</p>}
|
||||
|
||||
{/* Progress row */}
|
||||
{!isLive && durationSeconds > 0 ? (
|
||||
<div className="playback-overlay__progress-row">
|
||||
<span className="playback-overlay__time">{fmt(displayPosition)}</span>
|
||||
|
||||
<div
|
||||
ref={seekBarRef}
|
||||
className="playback-overlay__seek-bar"
|
||||
onPointerDown={onSeekBarPointerDown}
|
||||
onPointerMove={onSeekBarPointerMove}
|
||||
onPointerUp={onSeekBarPointerUp}
|
||||
onPointerCancel={onSeekBarPointerUp}
|
||||
>
|
||||
<div className="playback-overlay__seek-track">
|
||||
<div
|
||||
className="playback-overlay__seek-filled"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="playback-overlay__seek-knob"
|
||||
style={{ left: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="playback-overlay__time">{fmt(durationSeconds)}</span>
|
||||
</div>
|
||||
) : isLive ? (
|
||||
<div className="playback-overlay__live-indicator">
|
||||
<span className="playback-overlay__live-dot" />
|
||||
Live
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stop button */}
|
||||
<button
|
||||
className="playback-overlay__stop-btn"
|
||||
onClick={() => { onStop(); showOverlay() }}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<StopIcon width={18} height={18} />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient scrim */}
|
||||
<div className="playback-overlay__scrim-bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<VodInfo | null>(null)
|
||||
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
|
||||
// Overlay state
|
||||
const [currentPosition, setCurrentPosition] = useState(0)
|
||||
const [vodDuration, setVodDuration] = useState(0)
|
||||
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const chapterListRef = useRef<HTMLUListElement>(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') && (
|
||||
<>
|
||||
<span className="channel-screen__playing-hint gamepad-hint-line">
|
||||
<span>● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}</span>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Pause/Resume</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-right">+30s</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="lt">−5min</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="lb">Kapitel zurück</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="rb">Kapitel vor</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
|
||||
</span>
|
||||
<button className="btn" onClick={() => void handleStop()}>
|
||||
■ Stop
|
||||
</button>
|
||||
</>
|
||||
<span className="channel-screen__playing-hint">
|
||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!channel.isLive && playState === 'idle' && (
|
||||
@@ -627,6 +621,31 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Player overlay – shown during VOD/Live playback */}
|
||||
{(playState === 'playing' || playState === 'paused') && (
|
||||
<PlaybackOverlay
|
||||
playState={playState}
|
||||
durationSeconds={vodDuration}
|
||||
isLive={isLivePlayback}
|
||||
currentPosition={currentPosition}
|
||||
channelName={channel.broadcasterName}
|
||||
channelAvatar={channel.profileImageUrl}
|
||||
title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')}
|
||||
onTogglePause={() => {
|
||||
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()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)); }
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -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<void>
|
||||
getLogPath: () => Promise<string>
|
||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||
onTimeUpdate: (cb: (data: { positionSeconds: number }) => void) => () => void
|
||||
}
|
||||
gamepad: {
|
||||
onInput: (cb: (key: string) => void) => () => void
|
||||
|
||||
Reference in New Issue
Block a user