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