feat: add quality selection, improve chapter panel UX, and show VOD view counts

Quality selection during playback:
- Add getAvailableQualities() in streamlink via --json, sorted by fixed order
- Expose playback:get-qualities IPC channel (handlers, preload, type declarations)
- Add quality panel in PlaybackOverlay:
  - Button + dropdown
  - Keyboard navigation (X: open, arrows: navigate, Enter: confirm, Escape: close)
- Quality change triggers streamlink restart; VOD resumes at current position
- Available in ChannelScreen (Live + VOD) and AppShell global overlay (Live)
- Add QualityIcon

Chapter panel UX improvements:
- Video no longer pauses when chapter panel opens
- PlaybackOverlay is hidden via conditional render instead
- Remove wasPlayingBeforeChapters state (no longer needed)
- X button seeks to video start when no chapters are found, with hint in panel UI

VOD cards and defaults:
- Show view count with eye icon on VOD cards (matching playback overlay style)
- Change default streamBadgeMode from 'language' to 'both'
This commit is contained in:
Andre
2026-05-10 12:13:34 +02:00
parent 23eeb9912c
commit 1251cc278a
14 changed files with 594 additions and 81 deletions
+7 -2
View File
@@ -31,6 +31,7 @@ export const IPC = {
playbackStop: 'playback:stop',
playbackPause: 'playback:pause',
playbackReportPosition: 'playback:report-position',
playbackGetQualities: 'playback:get-qualities',
/** main → renderer */
playbackEvent: 'playback:event',
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
@@ -95,8 +96,12 @@ export function registerIpcHandlers(
)
ipcMain.handle(
IPC.playbackStartVod,
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) =>
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) =>
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds, quality)
)
ipcMain.handle(
IPC.playbackGetQualities,
(_e, twitchUrl: string) => playback.getAvailableQualities(twitchUrl)
)
ipcMain.handle(IPC.playbackStop, () => playback.stop())
ipcMain.handle(IPC.playbackPause, () => {
+8 -3
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events'
import { getStreamUrl } from './streamlink'
import { getAvailableQualities, getStreamUrl } from './streamlink'
import type { PlaybackEvent } from './types'
import * as history from '../store/historyRepo'
@@ -32,12 +32,17 @@ export class PlaybackService extends EventEmitter {
this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent)
}
getAvailableQualities(twitchUrl: string): Promise<string[]> {
return getAvailableQualities(twitchUrl)
}
async startVod(
vodId: string,
channelLogin: string,
title: string,
durationSeconds: number,
startSeconds?: number
startSeconds?: number,
quality = 'best'
): Promise<void> {
const resumePos = history.getPosition(vodId)
history.upsertVod({
@@ -50,7 +55,7 @@ export class PlaybackService extends EventEmitter {
let hlsUrl: string
try {
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, 'best')
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, quality)
} catch (e) {
this.emit('playback-event', {
kind: 'error',
+37
View File
@@ -18,6 +18,43 @@ function resolveStreamlinkBin(): string {
const STREAMLINK_BIN = resolveStreamlinkBin()
const QUALITY_ORDER = ['best', '1080p60', '1080p', '720p60', '720p', '480p', '360p', '160p', 'audio_only', 'worst']
function sortQualities(qualities: string[]): string[] {
return [...qualities].sort((a, b) => {
const ai = QUALITY_ORDER.indexOf(a)
const bi = QUALITY_ORDER.indexOf(b)
if (ai === -1 && bi === -1) return a.localeCompare(b)
if (ai === -1) return 1
if (bi === -1) return -1
return ai - bi
})
}
/**
* Fragt streamlink nach allen verfügbaren Qualitätsstufen für eine URL (--json).
* Gibt eine sortierte Liste zurück; bei Fehler leeres Array.
*/
export function getAvailableQualities(url: string): Promise<string[]> {
return new Promise((resolve) => {
const proc = spawn(STREAMLINK_BIN, ['--json', url], {
stdio: ['ignore', 'pipe', 'pipe']
})
let out = ''
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
proc.on('error', () => resolve([]))
proc.on('exit', () => {
try {
const json = JSON.parse(out) as { streams?: Record<string, unknown> }
const keys = Object.keys(json.streams ?? {})
resolve(sortQualities(keys))
} catch {
resolve([])
}
})
})
}
/**
* Fragt streamlink nach der direkten HLS-URL für einen Stream oder VOD.
* Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet.