From cfb8a0b790911f522afd9ae25bc77ddb11bcd792 Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:03:31 +0200 Subject: [PATCH] Phase 6 abgeschlossen - Fix: VOD-Kapitelwechsel und Following-Layout korrigiert --- src/main/auth/deviceCodeFlow.ts | 12 ++- src/main/env.d.ts | 7 ++ src/main/index.ts | 6 +- src/main/input/gamepadReader.ts | 9 +- src/main/ipc/handlers.ts | 2 + src/main/playback/mpvController.ts | 90 +++++++++++++------ src/main/playback/playbackService.ts | 4 + src/preload/index.ts | 2 + src/renderer/src/input/gamepad.ts | 4 + src/renderer/src/screens/ChannelScreen.tsx | 92 ++++++++++++++++---- src/renderer/src/screens/FollowingScreen.tsx | 4 +- src/renderer/src/styles/global.css | 13 ++- src/renderer/src/types/t4sd.d.ts | 1 + todo.md | 8 +- tsconfig.node.json | 4 +- 15 files changed, 198 insertions(+), 60 deletions(-) create mode 100644 src/main/env.d.ts diff --git a/src/main/auth/deviceCodeFlow.ts b/src/main/auth/deviceCodeFlow.ts index 1b4dab7..1284e7d 100644 --- a/src/main/auth/deviceCodeFlow.ts +++ b/src/main/auth/deviceCodeFlow.ts @@ -20,6 +20,11 @@ export interface TokenResponse { token_type: string } +interface ErrorResponse { + message?: string + status?: number +} + export async function requestDeviceCode( clientId: string, scopes: readonly string[] @@ -88,9 +93,12 @@ export async function pollForToken(opts: PollOptions): Promise { } // Twitch liefert bei Pending einen 400 mit message-Feld. - let payload: { message?: string; status?: number } = {} + let payload: ErrorResponse = {} try { - payload = await res.json() + const json = (await res.json()) as unknown + if (json && typeof json === 'object') { + payload = json as ErrorResponse + } } catch { /* nicht-JSON Antwort, ignorieren */ } diff --git a/src/main/env.d.ts b/src/main/env.d.ts new file mode 100644 index 0000000..f5d9689 --- /dev/null +++ b/src/main/env.d.ts @@ -0,0 +1,7 @@ +interface ImportMetaEnv { + readonly MAIN_VITE_TWITCH_CLIENT_ID?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/main/index.ts b/src/main/index.ts index 013023e..a35d31d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -45,7 +45,11 @@ app.whenReady().then(async () => { const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? '' const auth = new AuthService(clientId) await auth.init() - const helix = new HelixClient(clientId, () => auth.getValidAccessToken()) + const helix = new HelixClient(clientId, async () => { + const token = await auth.getValidAccessToken() + if (!token) throw new Error('Kein gueltiges Twitch-Access-Token vorhanden') + return token + }) const playback = new PlaybackService() registerIpcHandlers(auth, helix, playback) diff --git a/src/main/input/gamepadReader.ts b/src/main/input/gamepadReader.ts index 713f6fb..dc930f2 100644 --- a/src/main/input/gamepadReader.ts +++ b/src/main/input/gamepadReader.ts @@ -25,7 +25,9 @@ const BUTTON_MAP: Record = { 0: 'Enter', // A 1: 'Escape', // B 2: 'x', // X - 3: 'y' // Y + 3: 'y', // Y + 4: 'l1', // LB / L1 + 5: 'r1' // RB / R1 } const STICK_THRESHOLD = 16384 // ~50% von 32767 @@ -53,8 +55,9 @@ class JoystickReader { start(): boolean { try { this.stream = fs.createReadStream(this.devicePath) - this.stream.on('data', (chunk: Buffer) => { - this.buf = Buffer.concat([this.buf, chunk]) + this.stream.on('data', (chunk) => { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + this.buf = Buffer.concat([this.buf, buf]) while (this.buf.length >= 8) { this.processEvent(this.buf.subarray(0, 8)) this.buf = this.buf.subarray(8) diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 8e53538..5ebfdbc 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -29,6 +29,7 @@ export const IPC = { playbackPause: 'playback:pause', playbackResume: 'playback:resume', playbackSeekTo: 'playback:seek-to', + playbackGetCurrentPosition: 'playback:get-current-position', /** main → renderer */ playbackEvent: 'playback:event' } as const @@ -88,6 +89,7 @@ export function registerIpcHandlers( ipcMain.handle(IPC.playbackTogglePause, () => playback.togglePause()) ipcMain.handle(IPC.playbackSeekTo, (_e, seconds: number) => playback.seekTo(seconds)) ipcMain.handle(IPC.playbackResume, () => playback.resume()) + ipcMain.handle(IPC.playbackGetCurrentPosition, () => playback.getCurrentPosition()) ipcMain.handle(IPC.playbackPause, () => { playback.pause() for (const win of BrowserWindow.getAllWindows()) win.focus() diff --git a/src/main/playback/mpvController.ts b/src/main/playback/mpvController.ts index 73762fa..2d09046 100644 --- a/src/main/playback/mpvController.ts +++ b/src/main/playback/mpvController.ts @@ -12,6 +12,9 @@ export function getMpvIpcPath(): string { export class MpvController { private socket: net.Socket | null = null private buf = '' + private nextRequestId = 1 + private pendingRequests = new Map void>() + private propertyObservers = new Map void>>() constructor(private ipcPath: string) {} @@ -35,6 +38,7 @@ export class MpvController { sock.once('connect', () => { this.socket = sock sock.on('error', () => { /* ignore socket errors after connect */ }) + sock.on('data', (chunk: Buffer) => this.onData(chunk)) resolve() }) sock.once('error', reject) @@ -45,32 +49,15 @@ export class MpvController { observeTimePos(cb: (seconds: number) => void): void { if (!this.socket?.writable) return - // Observer registrieren - this._send({ command: ['observe_property', 1, 'time-pos'] }) + let observers = this.propertyObservers.get('time-pos') + if (!observers) { + observers = new Set() + this.propertyObservers.set('time-pos', observers) + this._send({ command: ['observe_property', 1, 'time-pos'] }) + } - this.socket.on('data', (chunk: Buffer) => { - this.buf += chunk.toString() - const lines = this.buf.split('\n') - this.buf = lines.pop() ?? '' - for (const line of lines) { - if (!line.trim()) continue - try { - const msg = JSON.parse(line) as { - event?: string - name?: string - data?: unknown - } - if ( - msg.event === 'property-change' && - msg.name === 'time-pos' && - typeof msg.data === 'number' - ) { - cb(msg.data) - } - } catch { - /* ignore malformed lines */ - } - } + observers.add((value: unknown) => { + if (typeof value === 'number') cb(value) }) } @@ -98,6 +85,12 @@ export class MpvController { } } + getTimePos(): Promise { + return this.getProperty('time-pos').then((value) => ( + typeof value === 'number' ? value : null + )) + } + quit(): void { if (this.socket?.writable) { try { @@ -112,9 +105,56 @@ export class MpvController { this.socket?.destroy() this.socket = null this.buf = '' + this.pendingRequests.clear() + this.propertyObservers.clear() } private _send(msg: object): void { this.socket?.write(JSON.stringify(msg) + '\n') } + + private getProperty(name: string): Promise { + if (!this.socket?.writable) return Promise.resolve(null) + + const requestId = this.nextRequestId++ + return new Promise((resolve) => { + this.pendingRequests.set(requestId, resolve) + this._send({ command: ['get_property', name], request_id: requestId }) + }) + } + + private onData(chunk: Buffer): void { + this.buf += chunk.toString() + const lines = this.buf.split('\n') + this.buf = lines.pop() ?? '' + + for (const line of lines) { + if (!line.trim()) continue + try { + const msg = JSON.parse(line) as { + event?: string + name?: string + data?: unknown + request_id?: number + } + + if (typeof msg.request_id === 'number') { + const resolver = this.pendingRequests.get(msg.request_id) + if (resolver) { + this.pendingRequests.delete(msg.request_id) + resolver(msg.data) + } + } + + if (msg.event === 'property-change' && typeof msg.name === 'string') { + const observers = this.propertyObservers.get(msg.name) + if (observers) { + for (const observer of observers) observer(msg.data) + } + } + } catch { + /* ignore malformed lines */ + } + } + } } diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts index ac913ea..1d0466b 100644 --- a/src/main/playback/playbackService.ts +++ b/src/main/playback/playbackService.ts @@ -163,6 +163,10 @@ export class PlaybackService extends EventEmitter { this.current?.mpv.seekAbsolute(seconds) } + getCurrentPosition(): Promise { + return this.current?.mpv.getTimePos() ?? Promise.resolve(null) + } + async stop(): Promise { if (!this.current) return const { process: proc, mpv } = this.current diff --git a/src/preload/index.ts b/src/preload/index.ts index bbd2001..1fe87d2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -23,6 +23,7 @@ const IPC = { playbackPause: 'playback:pause', playbackResume: 'playback:resume', playbackSeekTo: 'playback:seek-to', + playbackGetCurrentPosition: 'playback:get-current-position', playbackEvent: 'playback:event', gamepadInput: 'gamepad-input' @@ -134,6 +135,7 @@ const api = { pause: (): Promise => ipcRenderer.invoke(IPC.playbackPause), resume: (): Promise => ipcRenderer.invoke(IPC.playbackResume), seekTo: (seconds: number): Promise => ipcRenderer.invoke(IPC.playbackSeekTo, seconds), + getCurrentPosition: (): Promise => ipcRenderer.invoke(IPC.playbackGetCurrentPosition), onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => { const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) ipcRenderer.on(IPC.playbackEvent, listener) diff --git a/src/renderer/src/input/gamepad.ts b/src/renderer/src/input/gamepad.ts index 0aebd12..1e647d8 100644 --- a/src/renderer/src/input/gamepad.ts +++ b/src/renderer/src/input/gamepad.ts @@ -14,6 +14,8 @@ * Button 1 = B → Escape * Button 2 = X → x * Button 3 = Y → y + * Button 4 = LB → l1 + * Button 5 = RB → r1 * Axis 1 (left stick Y) → Up/Down * Axis 0 (left stick X) → Left/Right */ @@ -23,6 +25,8 @@ const BUTTON_MAP: Record = { 1: 'Escape', // B 2: 'x', // X 3: 'y', // Y + 4: 'l1', // LB / L1 + 5: 'r1', // RB / R1 6: 'l2', // LT / L2 7: 'r2', // RT / R2 12: 'ArrowUp', diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx index ef9edb7..8fe2f74 100644 --- a/src/renderer/src/screens/ChannelScreen.tsx +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -69,6 +69,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { const [chaptersLoading, setChaptersLoading] = useState(false) const [chapterIndex, setChapterIndex] = useState(0) const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false) + const [currentVod, setCurrentVod] = useState(null) + const [pendingChapterVod, setPendingChapterVod] = useState(null) const watchBtnRef = useRef(null) const chapterListRef = useRef(null) @@ -89,7 +91,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { const isPlaying = playState === 'playing' || playState === 'starting' || playState === 'paused' // Kapitel-Panel hat oberste Priorität (gilt auch während Wiedergabe) - if (focusRegion === 'chapters') { + if (focusRegion === 'chapters' && chapterPanelVod) { const duringPlayback = isPlaying if (e.key === 'Escape') { e.preventDefault() @@ -176,10 +178,18 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { break case 'y': { e.preventDefault() - const vod = vods[shelfIndex] + const vod = currentVod ?? vods[shelfIndex] if (vod) openChapterPanel(vod, true) break } + case 'l1': + e.preventDefault() + void jumpChapter(-1) + break + case 'r1': + e.preventDefault() + void jumpChapter(1) + break default: e.preventDefault() } @@ -228,16 +238,17 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters]) + }, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters, currentVod]) useEffect(() => { const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => { if (ev.kind === 'started') { setPlayState('playing') } else if (ev.kind === 'stopped') { + const nextChapterVod = pendingChapterVod setPlayState('idle') setChapterPanelVod(null) - setFocusRegion('shelf') + setCurrentVod(null) // Progress nach Stop neu laden if (vods.length > 0) { void window.t4sd.history @@ -245,14 +256,22 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { .then(setProgressMap) .catch(() => {}) } + if (nextChapterVod) { + setPendingChapterVod(null) + showChapterPanel(nextChapterVod) + } else { + setFocusRegion('shelf') + } } else if (ev.kind === 'error') { setPlayState('error') setChapterPanelVod(null) + setCurrentVod(null) + setPendingChapterVod(null) setErrorMsg(ev.message ?? 'Unbekannter Fehler') } }) return unsub - }, [vods]) + }, [pendingChapterVod, vods]) useEffect(() => { watchBtnRef.current?.focus() @@ -264,6 +283,42 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { item?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }, [chapterIndex]) + const loadChapters = async (vodId: string): Promise => { + return window.t4sd.twitch.getVodChapters(vodId) + } + + const showChapterPanel = (vod: VodInfo): void => { + setChapterPanelVod(vod) + setChapters([]) + setChapterIndex(0) + setChaptersLoading(true) + setFocusRegion('chapters') + setWasPlayingBeforeChapters(false) + void loadChapters(vod.id) + .then((loaded) => setChapters(loaded)) + .catch(() => {}) + .finally(() => setChaptersLoading(false)) + } + + const jumpChapter = async (direction: 1 | -1): Promise => { + if (!currentVod) return + + const [loadedChapters, currentPosition] = await Promise.all([ + loadChapters(currentVod.id), + window.t4sd.playback.getCurrentPosition() + ]) + if (loadedChapters.length === 0 || currentPosition === null) return + + const epsilon = 1 + const target = direction > 0 + ? loadedChapters.find((chapter) => chapter.positionSeconds > currentPosition + epsilon) + : [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentPosition - epsilon) + + if (target) { + await window.t4sd.playback.seekTo(target.positionSeconds) + } + } + const handleWatch = async (): Promise => { setPlayState('starting') setErrorMsg('') @@ -278,6 +333,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { const handleWatchVod = async (vod: VodInfo): Promise => { setPlayState('starting') setErrorMsg('') + setFocusRegion('hero') + setChapterPanelVod(null) + setWasPlayingBeforeChapters(false) + setCurrentVod(vod) + setPendingChapterVod(null) try { await window.t4sd.playback.startVod( vod.id, @@ -292,26 +352,22 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { } const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => { - setChapterPanelVod(vod) - setChapters([]) - setChapterIndex(0) - setChaptersLoading(true) - setFocusRegion('chapters') if (duringPlayback) { - setWasPlayingBeforeChapters(playState === 'playing') - void window.t4sd.playback.pause() - setPlayState('paused') + setPendingChapterVod(vod) + void window.t4sd.playback.stop() + return } - window.t4sd.twitch.getVodChapters(vod.id) - .then((loaded) => setChapters(loaded)) - .catch(() => {}) - .finally(() => setChaptersLoading(false)) + showChapterPanel(vod) } const handleWatchVodFromChapter = async (vod: VodInfo, startSeconds: number): Promise => { setPlayState('starting') setErrorMsg('') + setFocusRegion('hero') setChapterPanelVod(null) + setWasPlayingBeforeChapters(false) + setCurrentVod(vod) + setPendingChapterVod(null) try { await window.t4sd.playback.startVod( vod.id, @@ -404,7 +460,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { {(playState === 'playing' || playState === 'paused') && ( <> - ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · Y Kapitel + ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · LB Kapitel zurück · RB Kapitel vor · Y Kapitelmenü