diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index dedb4d4..e757329 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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) => { diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts index e0d565e..9b5e2c3 100644 --- a/src/main/playback/playbackService.ts +++ b/src/main/playback/playbackService.ts @@ -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 { + 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 { if (!this.current) return const { streamlink: sl, mpv } = this.current diff --git a/src/main/playback/streamlink.ts b/src/main/playback/streamlink.ts index 24b654a..7b5fc95 100644 --- a/src/main/playback/streamlink.ts +++ b/src/main/playback/streamlink.ts @@ -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 ] diff --git a/src/main/twitch/helixClient.ts b/src/main/twitch/helixClient.ts index 3e9044c..79547f1 100644 --- a/src/main/twitch/helixClient.ts +++ b/src/main/twitch/helixClient.ts @@ -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 { + const data = await this.get>('/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 { const userId = await this.getOwnUserId() const followed = await this.getAllFollowed(userId) diff --git a/src/main/twitch/types.ts b/src/main/twitch/types.ts index 51629fe..196c7a2 100644 --- a/src/main/twitch/types.ts +++ b/src/main/twitch/types.ts @@ -28,6 +28,28 @@ export interface HelixPaginatedResponse { 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 1c51c20..db885b5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => - ipcRenderer.invoke(IPC.twitchGetFollowed) + ipcRenderer.invoke(IPC.twitchGetFollowed), + getVideos: (broadcasterId: string): Promise => + ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId) }, playback: { startLive: (channelLogin: string, quality?: string): Promise => ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality), + startVod: (vodId: string, startSeconds?: number): Promise => + ipcRenderer.invoke(IPC.playbackStartVod, vodId, startSeconds), stop: (): Promise => ipcRenderer.invoke(IPC.playbackStop), onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => { const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx index e38a89f..d0dd789 100644 --- a/src/renderer/src/screens/ChannelScreen.tsx +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -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('idle') const [errorMsg, setErrorMsg] = useState('') + const [vods, setVods] = useState([]) + const [vodsLoading, setVodsLoading] = useState(true) + const [focusRegion, setFocusRegion] = useState('hero') + const [shelfIndex, setShelfIndex] = useState(0) const watchBtnRef = useRef(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 => { + setPlayState('starting') + setErrorMsg('') + try { + await window.t4sd.playback.startVod(vod.id) + } catch (e) { + setPlayState('error') + setErrorMsg(String(e)) + } + } + const handleStop = async (): Promise => { await window.t4sd.playback.stop() } const thumb = resolveThumbnail(channel.thumbnailUrl) + const trackOffset = shelfIndex * (CARD_W + CARD_GAP) return (
@@ -132,6 +203,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { )} + {(playState === 'idle' || playState === 'error') && !channel.isLive && ( + + )} + {playState === 'starting' && (
+ +
+

+ Vergangene Streams + {focusRegion === 'shelf' && ( + · ↑ zurück · Enter zum Abspielen + )} +

+ {vodsLoading &&

Lade VODs…

} + {!vodsLoading && vods.length === 0 && ( +

Keine archivierten Streams verfügbar.

+ )} + {vods.length > 0 && ( +
+
+ {vods.map((vod, i) => ( +
void handleWatchVod(vod)} + > +
+ {vod.thumbnailUrl && !vod.thumbnailUrl.includes('404_processing') ? ( + + ) : ( +
+ )} + + {formatDuration(vod.durationSeconds)} + +
+

{vod.title}

+

{formatDate(vod.createdAt)}

+
+ ))} +
+
+ )} +
) } diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 6fc6a5d..b8db0be 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -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; diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 82503ce..3f1ea67 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -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 + getVideos: (broadcasterId: string) => Promise } playback: { startLive: (channelLogin: string, quality?: string) => Promise + startVod: (vodId: string, startSeconds?: number) => Promise stop: () => Promise onEvent: (cb: (event: PlaybackEvent) => void) => () => void } diff --git a/todo.md b/todo.md index 316665c..3a6c56d 100644 --- a/todo.md +++ b/todo.md @@ -66,17 +66,18 @@ - [x] Spatial-Nav: Escape/B im ChannelScreen → Stop oder Zurück ### Verifikation -- [ ] **mpv PATH-Problem lösen** (VS Code neu starten, dann testen — siehe CLAUDE_PROGRESS.md) -- [ ] 5 min Live-Stream ohne Werbe-Unterbrechung auf Windows (Dev-Setup) -- [ ] Beim Stop: mpv-Fenster schließt sich sauber, streamlink-Prozess terminiert, - keine Zombie-Prozesse im Task-Manager -- [ ] Logout/Login und erneutes Starten funktioniert ohne App-Neustart +- [x] Stream startet: mpv-Fenster öffnet sich, Stream spielt ab (2026-04-08) + _(Fix: --twitch-api-header entfernt, resolveMpvBin() ergänzt)_ +- [x] **User:** Stop-Verhalten: mpv-Fenster schließt sich, keine Zombie-Prozesse (2026-04-08) +- [x] **User:** Logout → Login → Stream starten funktioniert ohne App-Neustart (2026-04-08) + ⚠️ `--twitch-disable-ads` ist von streamlink deaktiviert — Werbung wird ggf. abgespielt (Post-MVP) ## Phase 4 — VOD-Browsing + -Wiedergabe -- [ ] Helix `GET /videos?user_id=…&type=archive` (paginiert) -- [ ] VOD-Listen-Screen pro Channel -- [ ] `playback:start-vod` (mit optionaler Resume-Position) -- [ ] mpv-Resume: `--start=` beim Spawn +- [x] Helix `GET /videos?user_id=…&type=archive` → `getVideos()` in helixClient +- [x] VOD-Shelf im ChannelScreen (horizontal, obere Hälfte fix) +- [x] `playback:start-vod` IPC-Kanal + `PlaybackService.startVod()` +- [x] mpv `--start=` beim Spawn (Parameter vorbereitet für Phase 5) +- [x] **User:** VOD-Wiedergabe + Stop-Verhalten verifiziert (2026-04-08) ## Phase 5 — Lokaler VOD-Verlauf - [ ] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migrations)