Phase 4 abgeschlossen

- VOD Historie in Channelscreen implementiert
- VOD-Wiedergabe + Stop-Verhalten verifiziert
This commit is contained in:
Andre
2026-04-08 17:49:30 +02:00
parent 3e8e5c2078
commit ed1f710866
10 changed files with 390 additions and 28 deletions
+10
View File
@@ -14,8 +14,10 @@ export const IPC = {
authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos',
playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop',
/** main → renderer */
playbackEvent: 'playback:event'
@@ -41,11 +43,19 @@ export function registerIpcHandlers(
})
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
ipcMain.handle(
IPC.twitchGetVideos,
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
)
ipcMain.handle(
IPC.playbackStartLive,
(_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality)
)
ipcMain.handle(
IPC.playbackStartVod,
(_e, vodId: string, startSeconds?: number) => playback.startVod(vodId, startSeconds)
)
ipcMain.handle(IPC.playbackStop, () => playback.stop())
playback.on('playback-event', (event: PlaybackEvent) => {
+45 -1
View File
@@ -23,7 +23,7 @@ export class PlaybackService extends EventEmitter {
}
const sl = spawnStreamlink({
channelLogin,
url: `twitch.tv/${channelLogin}`,
quality,
mpvIpcPath: this.ipcPath
})
@@ -65,6 +65,50 @@ export class PlaybackService extends EventEmitter {
mpv.connect().catch(() => {})
}
async startVod(vodId: string, startSeconds = 0): Promise<void> {
if (this.current) {
await this.stop()
}
const sl = spawnStreamlink({
url: `https://www.twitch.tv/videos/${vodId}`,
quality: 'best',
mpvIpcPath: this.ipcPath,
startSeconds
})
sl.stdout?.on('data', (d: Buffer) => {
console.log('[streamlink stdout]', d.toString().trim())
})
sl.stderr?.on('data', (d: Buffer) => {
console.log('[streamlink stderr]', d.toString().trim())
})
sl.on('error', (e: Error) => {
console.error('[streamlink] spawn error:', e)
if (this.current?.streamlink === sl) this.current = null
this.emit('playback-event', {
kind: 'error',
message: `streamlink Fehler: ${e.message}`
} satisfies PlaybackEvent)
})
sl.on('exit', (code) => {
if (this.current?.streamlink === sl) this.current = null
if (code !== 0 && code !== null) {
console.warn('[streamlink] exit code', code)
}
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
})
const mpv = new MpvController(this.ipcPath)
this.current = { channelLogin: vodId, streamlink: sl, mpv }
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
mpv.connect().catch(() => {})
}
async stop(): Promise<void> {
if (!this.current) return
const { streamlink: sl, mpv } = this.current
+10 -5
View File
@@ -34,25 +34,30 @@ const STREAMLINK_BIN = resolveStreamlinkBin()
const MPV_BIN = resolveMpvBin()
export interface StreamlinkOptions {
channelLogin: string
url: string
quality: string
mpvIpcPath: string
startSeconds?: number
}
export function spawnStreamlink({
channelLogin,
url,
quality,
mpvIpcPath
mpvIpcPath,
startSeconds
}: StreamlinkOptions): ChildProcess {
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
const playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
let playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
if (startSeconds && startSeconds > 0) {
playerArgs += ` --start=${startSeconds}`
}
const args = [
'--player',
MPV_BIN,
'--player-args',
playerArgs,
`twitch.tv/${channelLogin}`,
url,
quality
]
+32 -1
View File
@@ -3,9 +3,22 @@ import type {
HelixFollowedChannel,
HelixPaginatedResponse,
HelixStream,
HelixUser
HelixUser,
HelixVideo,
VodInfo
} from './types'
function parseDuration(d: string): number {
let s = 0
const h = d.match(/(\d+)h/)
const m = d.match(/(\d+)m/)
const sec = d.match(/(\d+)s/)
if (h) s += parseInt(h[1]) * 3600
if (m) s += parseInt(m[1]) * 60
if (sec) s += parseInt(sec[1])
return s
}
const BASE = 'https://api.twitch.tv/helix'
export class HelixClient {
@@ -96,6 +109,24 @@ export class HelixClient {
return map
}
async getVideos(broadcasterId: string, limit = 20): Promise<VodInfo[]> {
const data = await this.get<HelixPaginatedResponse<HelixVideo>>('/videos', {
user_id: broadcasterId,
type: 'archive',
first: String(limit)
})
return data.data.map((v) => ({
id: v.id,
title: v.title,
createdAt: v.created_at,
durationSeconds: parseDuration(v.duration),
viewCount: v.view_count,
thumbnailUrl: v.thumbnail_url
.replace('%{width}', '440')
.replace('%{height}', '248')
}))
}
async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> {
const userId = await this.getOwnUserId()
const followed = await this.getAllFollowed(userId)
+22
View File
@@ -28,6 +28,28 @@ export interface HelixPaginatedResponse<T> {
pagination?: { cursor?: string }
}
export interface HelixVideo {
id: string
user_id: string
title: string
created_at: string
thumbnail_url: string
duration: string // e.g. "3h40m1s"
view_count: number
type: 'archive' | 'highlight' | 'upload'
}
/** Simplified VOD info für den Renderer. */
export interface VodInfo {
id: string
title: string
createdAt: string
durationSeconds: number
viewCount: number
/** Thumbnail URL bereits auf 440×248 aufgelöst */
thumbnailUrl: string
}
/** Merged type that the renderer receives. */
export interface FollowedChannelInfo {
broadcasterId: string
+16 -1
View File
@@ -9,8 +9,10 @@ const IPC = {
authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos',
playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop',
playbackEvent: 'playback:event'
} as const
@@ -49,6 +51,15 @@ export interface PlaybackEvent {
message?: string
}
export interface VodInfo {
id: string
title: string
createdAt: string
durationSeconds: number
viewCount: number
thumbnailUrl: string
}
const api = {
appVersion: process.env.npm_package_version ?? '0.0.0',
auth: {
@@ -65,11 +76,15 @@ const api = {
},
twitch: {
getFollowed: (): Promise<FollowedChannelInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetFollowed)
ipcRenderer.invoke(IPC.twitchGetFollowed),
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId)
},
playback: {
startLive: (channelLogin: string, quality?: string): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
startVod: (vodId: string, startSeconds?: number): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartVod, vodId, startSeconds),
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
+130 -11
View File
@@ -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>
)
}
+104
View File
@@ -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;
+11
View File
@@ -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
}