feat: add viewer metrics and dynamic category display to playback overlay

- Live streams: show viewer count and current category inline next to LIVE indicator
- VODs: display clapperboard badge, total view count, and chapter-based category
- Chapter-based category updates automatically based on playback position
- Chapters are preloaded on VOD start; fallback category is "Unbekannt" if missing
- Add icons: EyeIcon, GamepadIcon, ClapperboardIcon
This commit is contained in:
Andre
2026-05-10 09:55:23 +02:00
parent 48ab6e551f
commit 23eeb9912c
9 changed files with 170 additions and 646 deletions
+31
View File
@@ -91,3 +91,34 @@ export function SettingsIcon(props: IconProps): JSX.Element {
</svg>
)
}
export function EyeIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
export function GamepadIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<rect x="2" y="6" width="20" height="12" rx="4" />
<path d="M6 12h4" />
<path d="M8 10v4" />
<circle cx="15" cy="11" r="1" fill="currentColor" stroke="none" />
<circle cx="17" cy="13" r="1" fill="currentColor" stroke="none" />
</svg>
)
}
export function ClapperboardIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<path d="M4 11H20V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V11z" />
<path d="M4 11V7a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v4" />
<path d="M4 9l4-3 4 3 4-3" />
</svg>
)
}
+77 -31
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from './GamepadPrompt'
import { PauseIcon, PlayIcon, StopIcon } from './Icons'
import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, StopIcon } from './Icons'
interface PlaybackOverlayProps {
playState: 'playing' | 'paused'
@@ -10,12 +10,21 @@ interface PlaybackOverlayProps {
channelName: string
channelAvatar: string
title: string
viewerCount?: number
viewCount?: number
gameName?: string
onTogglePause: () => void
onSeek: (seconds: number) => void
onSeekTo: (seconds: number) => void
onStop: () => void
}
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
/** Formats seconds as HH:MM:SS */
function fmt(s: number): string {
const total = Math.max(0, Math.floor(s))
@@ -36,6 +45,9 @@ export function PlaybackOverlay({
channelName,
channelAvatar,
title,
viewerCount,
viewCount,
gameName,
onTogglePause,
onSeek,
onSeekTo,
@@ -253,38 +265,72 @@ export function PlaybackOverlay({
{/* 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>
{/* Progress row / status row */}
{isLive ? (
<div className="playback-overlay__meta-row">
<div className="playback-overlay__live-indicator">
<span className="playback-overlay__live-dot" />
Live
</div>
{viewerCount !== undefined && viewerCount > 0 && (
<span className="playback-overlay__meta-chip">
<EyeIcon width={14} height={14} />
{formatCount(viewerCount)}
</span>
)}
{gameName && (
<span className="playback-overlay__meta-chip">
<GamepadIcon width={14} height={14} />
{gameName}
</span>
)}
</div>
) : durationSeconds > 0 ? (
<>
<div className="playback-overlay__meta-row">
<div className="playback-overlay__vod-badge">
<ClapperboardIcon width={13} height={13} />
VOD
</div>
{viewCount !== undefined && viewCount > 0 && (
<span className="playback-overlay__meta-chip">
<EyeIcon width={14} height={14} />
{formatCount(viewCount)}
</span>
)}
{gameName && (
<span className="playback-overlay__meta-chip">
<GamepadIcon width={14} height={14} />
{gameName}
</span>
)}
</div>
<div className="playback-overlay__progress-row">
<span className="playback-overlay__time">{fmt(displayPosition)}</span>
<span className="playback-overlay__time">{fmt(durationSeconds)}</span>
</div>
) : isLive ? (
<div className="playback-overlay__live-indicator">
<span className="playback-overlay__live-dot" />
Live
</div>
<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>
</>
) : null}
{/* Stop button */}
+2
View File
@@ -321,6 +321,8 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
channelName={liveChannel?.broadcasterName ?? ''}
channelAvatar={liveChannel?.profileImageUrl ?? ''}
title={liveChannel?.streamTitle ?? ''}
viewerCount={liveChannel?.viewerCount ?? undefined}
gameName={liveChannel?.gameName ?? undefined}
onTogglePause={() => {
if (livePlayState === 'playing') {
liveVideoRef.current?.pause()
+23 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { PlaybackOverlay } from '../components/PlaybackOverlay'
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
@@ -69,6 +69,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [shelfIndex, setShelfIndex] = useState(0)
const [chapterPanelVod, setChapterPanelVod] = useState<VodInfo | null>(null)
const [chapters, setChapters] = useState<VodChapter[]>([])
const [vodChaptersLoaded, setVodChaptersLoaded] = useState(false)
const [chaptersLoading, setChaptersLoading] = useState(false)
const [chapterIndex, setChapterIndex] = useState(0)
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false)
@@ -81,6 +82,16 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
// HLS-Player state
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
// Derive active chapter name from chapters list + current playback position.
// Three states: loading → undefined (chip hidden), loaded+empty → 'Unbekannt', loaded+chapters → active chapter.
const currentChapterName = useMemo<string | undefined>(() => {
if (isLivePlayback) return undefined
if (!vodChaptersLoaded) return undefined
if (chapters.length === 0) return 'Unbekannt'
const active = [...chapters].reverse().find((ch) => ch.positionSeconds <= currentPosition)
return active?.gameName ?? 'Unbekannt'
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
const watchBtnRef = useRef<HTMLButtonElement>(null)
const chapterListRef = useRef<HTMLUListElement>(null)
const videoRef = useRef<VideoPlayerHandle>(null)
@@ -363,6 +374,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setWasPlayingBeforeChapters(false)
setCurrentVod(vod)
setPendingChapterVod(null)
setVodChaptersLoaded(false)
void loadChapters(vod.id)
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
.catch(() => { setVodChaptersLoaded(true) })
try {
await window.t4sd.playback.startVod(
vod.id,
@@ -403,6 +418,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setWasPlayingBeforeChapters(false)
setCurrentVod(vod)
setPendingChapterVod(null)
setVodChaptersLoaded(false)
void loadChapters(vod.id)
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
.catch(() => { setVodChaptersLoaded(true) })
try {
await window.t4sd.playback.startVod(
vod.id,
@@ -672,6 +691,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
channelName={channel.broadcasterName}
channelAvatar={channel.profileImageUrl}
title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')}
viewerCount={isLivePlayback ? (channel.viewerCount ?? undefined) : undefined}
viewCount={!isLivePlayback ? (currentVod?.viewCount ?? undefined) : undefined}
gameName={isLivePlayback ? (channel.gameName ?? undefined) : currentChapterName}
onTogglePause={() => {
if (playState === 'playing') {
void window.t4sd.playback.pause()
+36
View File
@@ -1809,6 +1809,42 @@ body,
50% { opacity: 0.4; }
}
/* Meta-Info-Zeile (Live-Indikator + Chips) */
.playback-overlay__meta-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
/* Info-Chip: Auge+Zahl / Gamepad+Name */
.playback-overlay__meta-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 2px 7px;
white-space: nowrap;
}
/* VOD-Badge (analog zum LIVE-Badge, aber Twitch-Lila statt rot) */
.playback-overlay__vod-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
color: #fff;
background: var(--color-accent);
border-radius: 4px;
padding: 2px 8px;
}
/* Stop button */
.playback-overlay__stop-btn {
align-self: flex-end;