mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user