mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 4 abgeschlossen
- VOD Historie in Channelscreen implementiert - VOD-Wiedergabe + Stop-Verhalten verifiziert
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { FollowedChannelInfo, PlaybackEvent } from '../types/t4sd'
|
||||
import type { FollowedChannelInfo, PlaybackEvent, VodInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
channel: FollowedChannelInfo
|
||||
@@ -7,6 +7,10 @@ interface Props {
|
||||
}
|
||||
|
||||
type PlayState = 'idle' | 'starting' | 'playing' | 'error'
|
||||
type FocusRegion = 'hero' | 'shelf'
|
||||
|
||||
const CARD_W = 260
|
||||
const CARD_GAP = 16
|
||||
|
||||
function formatViewers(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mio.`
|
||||
@@ -14,6 +18,22 @@ function formatViewers(n: number): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string | undefined {
|
||||
return url?.replace('{width}', String(w)).replace('{height}', String(h))
|
||||
}
|
||||
@@ -21,25 +41,65 @@ function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string |
|
||||
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const [playState, setPlayState] = useState<PlayState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [vods, setVods] = useState<VodInfo[]>([])
|
||||
const [vodsLoading, setVodsLoading] = useState(true)
|
||||
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
|
||||
const [shelfIndex, setShelfIndex] = useState(0)
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Escape / B → zurück (nur wenn nicht gerade Wiedergabe läuft)
|
||||
useEffect(() => {
|
||||
window.t4sd.twitch
|
||||
.getVideos(channel.broadcasterId)
|
||||
.then(setVods)
|
||||
.catch(() => {})
|
||||
.finally(() => setVodsLoading(false))
|
||||
}, [channel.broadcasterId])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (playState === 'playing' || playState === 'starting') {
|
||||
void window.t4sd.playback.stop()
|
||||
} else {
|
||||
onBack()
|
||||
if (focusRegion === 'hero') {
|
||||
if (e.key === 'ArrowDown' && vods.length > 0) {
|
||||
e.preventDefault()
|
||||
setFocusRegion('shelf')
|
||||
setShelfIndex(0)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (playState === 'playing' || playState === 'starting') {
|
||||
void window.t4sd.playback.stop()
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// shelf region
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
setShelfIndex((i) => Math.max(0, i - 1))
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
setShelfIndex((i) => Math.min(vods.length - 1, i + 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setFocusRegion('hero')
|
||||
watchBtnRef.current?.focus()
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const vod = vods[shelfIndex]
|
||||
if (vod) void handleWatchVod(vod)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (playState === 'playing' || playState === 'starting') {
|
||||
void window.t4sd.playback.stop()
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [playState, onBack])
|
||||
}, [focusRegion, playState, vods, shelfIndex, onBack])
|
||||
|
||||
// Playback-Events aus dem Main-Prozess
|
||||
useEffect(() => {
|
||||
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
||||
if (ev.kind === 'started') {
|
||||
@@ -54,7 +114,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
// Initial-Fokus auf "Live ansehen"-Button
|
||||
useEffect(() => {
|
||||
watchBtnRef.current?.focus()
|
||||
}, [])
|
||||
@@ -70,11 +129,23 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const handleWatchVod = async (vod: VodInfo): Promise<void> => {
|
||||
setPlayState('starting')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
await window.t4sd.playback.startVod(vod.id)
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async (): Promise<void> => {
|
||||
await window.t4sd.playback.stop()
|
||||
}
|
||||
|
||||
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||
|
||||
return (
|
||||
<div className="channel-screen">
|
||||
@@ -132,6 +203,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(playState === 'idle' || playState === 'error') && !channel.isLive && (
|
||||
<button ref={watchBtnRef} className="btn btn--primary" disabled style={{ opacity: 0 }}>
|
||||
placeholder
|
||||
</button>
|
||||
)}
|
||||
|
||||
{playState === 'starting' && (
|
||||
<button className="btn" disabled>
|
||||
Starte Wiedergabe…
|
||||
@@ -157,6 +234,48 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-screen__vods">
|
||||
<h3 className="channel-screen__vods-title">
|
||||
Vergangene Streams
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="channel-screen__vods-hint"> · ↑ zurück · Enter zum Abspielen</span>
|
||||
)}
|
||||
</h3>
|
||||
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
||||
{!vodsLoading && vods.length === 0 && (
|
||||
<p className="channel-screen__vods-empty">Keine archivierten Streams verfügbar.</p>
|
||||
)}
|
||||
{vods.length > 0 && (
|
||||
<div className="channel-screen__shelf-viewport">
|
||||
<div
|
||||
className="channel-screen__shelf-track"
|
||||
style={{ transform: `translateX(-${trackOffset}px)` }}
|
||||
>
|
||||
{vods.map((vod, i) => (
|
||||
<div
|
||||
key={vod.id}
|
||||
className={`channel-screen__vod-card${focusRegion === 'shelf' && shelfIndex === i ? ' channel-screen__vod-card--focused' : ''}`}
|
||||
onClick={() => void handleWatchVod(vod)}
|
||||
>
|
||||
<div className="channel-screen__vod-thumb">
|
||||
{vod.thumbnailUrl && !vod.thumbnailUrl.includes('404_processing') ? (
|
||||
<img src={vod.thumbnailUrl} alt="" draggable={false} />
|
||||
) : (
|
||||
<div className="channel-screen__vod-thumb-placeholder" />
|
||||
)}
|
||||
<span className="channel-screen__vod-duration">
|
||||
{formatDuration(vod.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="channel-screen__vod-title">{vod.title}</p>
|
||||
<p className="channel-screen__vod-meta">{formatDate(vod.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -604,6 +604,110 @@ body,
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* VOD Shelf */
|
||||
.channel-screen__vods {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.channel-screen__vods-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
.channel-screen__vods-hint {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.channel-screen__vods-loading,
|
||||
.channel-screen__vods-empty {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
.channel-screen__shelf-viewport {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-screen__shelf-track {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
transition: transform 0.22s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.channel-screen__vod-card {
|
||||
flex: 0 0 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
padding: 6px;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.channel-screen__vod-card--focused {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-elev2);
|
||||
}
|
||||
|
||||
.channel-screen__vod-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--color-bg-elev2);
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-screen__vod-thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.channel-screen__vod-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #1f1f23 0%, #2a2a31 100%);
|
||||
}
|
||||
|
||||
.channel-screen__vod-duration {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.channel-screen__vod-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
color: var(--color-fg);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-screen__vod-meta {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
/* ── Account Screen ───────────────────────────────── */
|
||||
.account {
|
||||
flex: 1;
|
||||
|
||||
Vendored
+11
@@ -34,6 +34,15 @@ export interface PlaybackEvent {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface VodInfo {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
durationSeconds: number
|
||||
viewCount: number
|
||||
thumbnailUrl: string
|
||||
}
|
||||
|
||||
export interface T4sdApi {
|
||||
appVersion: string
|
||||
auth: {
|
||||
@@ -46,9 +55,11 @@ export interface T4sdApi {
|
||||
}
|
||||
twitch: {
|
||||
getFollowed: () => Promise<FollowedChannelInfo[]>
|
||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||
}
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||
startVod: (vodId: string, startSeconds?: number) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user