Phase 6 abgeschlossen

- Fix: VOD-Kapitelwechsel und Following-Layout korrigiert
This commit is contained in:
Andre
2026-04-11 13:03:31 +02:00
parent 56d212a3b7
commit cfb8a0b790
15 changed files with 198 additions and 60 deletions
+10 -2
View File
@@ -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<PollOutcome> {
}
// 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 */
}
+7
View File
@@ -0,0 +1,7 @@
interface ImportMetaEnv {
readonly MAIN_VITE_TWITCH_CLIENT_ID?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+5 -1
View File
@@ -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)
+6 -3
View File
@@ -25,7 +25,9 @@ const BUTTON_MAP: Record<number, string> = {
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)
+2
View File
@@ -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()
+65 -25
View File
@@ -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<number, (value: unknown) => void>()
private propertyObservers = new Map<string, Set<(value: unknown) => 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<number | null> {
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<unknown> {
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 */
}
}
}
}
+4
View File
@@ -163,6 +163,10 @@ export class PlaybackService extends EventEmitter {
this.current?.mpv.seekAbsolute(seconds)
}
getCurrentPosition(): Promise<number | null> {
return this.current?.mpv.getTimePos() ?? Promise.resolve(null)
}
async stop(): Promise<void> {
if (!this.current) return
const { process: proc, mpv } = this.current
+2
View File
@@ -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<void> => ipcRenderer.invoke(IPC.playbackPause),
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume),
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds),
getCurrentPosition: (): Promise<number | null> => ipcRenderer.invoke(IPC.playbackGetCurrentPosition),
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
ipcRenderer.on(IPC.playbackEvent, listener)
+4
View File
@@ -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<number, string> = {
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',
+74 -18
View File
@@ -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<VodInfo | null>(null)
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
const watchBtnRef = useRef<HTMLButtonElement>(null)
const chapterListRef = useRef<HTMLUListElement>(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<VodChapter[]> => {
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<void> => {
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<void> => {
setPlayState('starting')
setErrorMsg('')
@@ -278,6 +333,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const handleWatchVod = async (vod: VodInfo): Promise<void> => {
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<void> => {
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') && (
<>
<span className="channel-screen__playing-hint">
● 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ü
</span>
<button className="btn" onClick={() => void handleStop()}>
■ Stop (B / Escape)
+2 -2
View File
@@ -110,7 +110,7 @@ export default function FollowingScreen({
const liveCount = channels.filter((c) => c.isLive).length
return (
<div className="screen">
<div className="screen screen--following">
<header className="screen__header">
<h2 className="screen__title">Du folgst</h2>
{loadState === 'ok' && channels.length > 0 && (
@@ -144,7 +144,7 @@ export default function FollowingScreen({
)}
{loadState === 'ok' && channels.length > 0 && (
<div className="card-grid" ref={gridRef}>
<div className="card-grid card-grid--following" ref={gridRef}>
{channels.map((ch, i) => (
<FocusableCard
key={ch.broadcasterId}
+9 -4
View File
@@ -293,6 +293,10 @@ body,
padding-bottom: 16px;
}
.card-grid--following {
padding-top: 14px;
}
.card-grid::-webkit-scrollbar {
width: 8px;
}
@@ -359,11 +363,12 @@ body,
}
.card__thumb-offline img {
width: 84px;
height: 84px;
width: 124px;
height: 124px;
border-radius: 50%;
opacity: 0.75;
filter: grayscale(0.3);
opacity: 0.88;
filter: grayscale(0.2);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
}
.card__live-badge {
+1
View File
@@ -94,6 +94,7 @@ export interface T4sdApi {
pause: () => Promise<void>
resume: () => Promise<void>
seekTo: (seconds: number) => Promise<void>
getCurrentPosition: () => Promise<number | null>
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
}
gamepad: {
+4 -4
View File
@@ -129,10 +129,10 @@
- [x] **Fix (Phase 6.1):** streamlink Python-Deps via `flatpak-pip-generator` eingebunden
(`build-flatpak.sh` Schritt 4b + `!include python3-streamlink.json` im Manifest)
- [x] **Fix (Phase 6.1):** mpv-Patch `patches/mpv-ffmpeg7-avio-const.patch` ins Manifest eingebunden
- [ ] **User:** WSL2-Setup (flatpak, flatpak-builder, Runtimes) — einmalig
- [ ] **User:** `bash flatpak/build-flatpak.sh` in WSL2 neu ausführen (nach 6.1-Fix)
- [ ] **User:** Auf Steam Deck installieren und Login + Live + VOD durchspielen
- [ ] **User:** Als Non-Steam-Game in Gaming Mode hinzufügen, dort testen
- [x] **User:** WSL2-Setup (flatpak, flatpak-builder, Runtimes) — einmalig
- [x] **User:** `bash flatpak/build-flatpak.sh` in WSL2 neu ausführen (nach 6.1-Fix)
- [x] **User:** Auf Steam Deck installieren und Login + Live + VOD durchspielen
- [x] **User:** Als Non-Steam-Game in Gaming Mode hinzufügen, dort testen
## Post-MVP (bewusst nicht im ersten Release)
- [ ] Chat (Live + VOD-Replay) inkl. BTTV/FFZ/7TV
+3 -1
View File
@@ -18,6 +18,8 @@
"include": [
"electron.vite.config.ts",
"src/main/**/*.ts",
"src/preload/**/*.ts"
"src/main/**/*.d.ts",
"src/preload/**/*.ts",
"src/preload/**/*.d.ts"
]
}