mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 4 abgeschlossen
- VOD Historie in Channelscreen implementiert - VOD-Wiedergabe + Stop-Verhalten verifiziert
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
if (!this.current) return
|
||||
const { streamlink: sl, mpv } = this.current
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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<VodInfo[]> {
|
||||
const data = await this.get<HelixPaginatedResponse<HelixVideo>>('/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<FollowedChannelInfo[]> {
|
||||
const userId = await this.getOwnUserId()
|
||||
const followed = await this.getAllFollowed(userId)
|
||||
|
||||
@@ -28,6 +28,28 @@ export interface HelixPaginatedResponse<T> {
|
||||
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
|
||||
|
||||
+16
-1
@@ -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<FollowedChannelInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetFollowed)
|
||||
ipcRenderer.invoke(IPC.twitchGetFollowed),
|
||||
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId)
|
||||
},
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
||||
startVod: (vodId: string, startSeconds?: number): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, startSeconds),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
|
||||
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
|
||||
|
||||
@@ -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<PlayState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [vods, setVods] = useState<VodInfo[]>([])
|
||||
const [vodsLoading, setVodsLoading] = useState(true)
|
||||
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
|
||||
const [shelfIndex, setShelfIndex] = useState(0)
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(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<void> => {
|
||||
setPlayState('starting')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
await window.t4sd.playback.startVod(vod.id)
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async (): Promise<void> => {
|
||||
await window.t4sd.playback.stop()
|
||||
}
|
||||
|
||||
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||
|
||||
return (
|
||||
<div className="channel-screen">
|
||||
@@ -132,6 +203,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(playState === 'idle' || playState === 'error') && !channel.isLive && (
|
||||
<button ref={watchBtnRef} className="btn btn--primary" disabled style={{ opacity: 0 }}>
|
||||
placeholder
|
||||
</button>
|
||||
)}
|
||||
|
||||
{playState === 'starting' && (
|
||||
<button className="btn" disabled>
|
||||
Starte Wiedergabe…
|
||||
@@ -157,6 +234,48 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-screen__vods">
|
||||
<h3 className="channel-screen__vods-title">
|
||||
Vergangene Streams
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="channel-screen__vods-hint"> · ↑ zurück · Enter zum Abspielen</span>
|
||||
)}
|
||||
</h3>
|
||||
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
||||
{!vodsLoading && vods.length === 0 && (
|
||||
<p className="channel-screen__vods-empty">Keine archivierten Streams verfügbar.</p>
|
||||
)}
|
||||
{vods.length > 0 && (
|
||||
<div className="channel-screen__shelf-viewport">
|
||||
<div
|
||||
className="channel-screen__shelf-track"
|
||||
style={{ transform: `translateX(-${trackOffset}px)` }}
|
||||
>
|
||||
{vods.map((vod, i) => (
|
||||
<div
|
||||
key={vod.id}
|
||||
className={`channel-screen__vod-card${focusRegion === 'shelf' && shelfIndex === i ? ' channel-screen__vod-card--focused' : ''}`}
|
||||
onClick={() => void handleWatchVod(vod)}
|
||||
>
|
||||
<div className="channel-screen__vod-thumb">
|
||||
{vod.thumbnailUrl && !vod.thumbnailUrl.includes('404_processing') ? (
|
||||
<img src={vod.thumbnailUrl} alt="" draggable={false} />
|
||||
) : (
|
||||
<div className="channel-screen__vod-thumb-placeholder" />
|
||||
)}
|
||||
<span className="channel-screen__vod-duration">
|
||||
{formatDuration(vod.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="channel-screen__vod-title">{vod.title}</p>
|
||||
<p className="channel-screen__vod-meta">{formatDate(vod.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+11
@@ -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<FollowedChannelInfo[]>
|
||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||
}
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||
startVod: (vodId: string, startSeconds?: number) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||
}
|
||||
|
||||
@@ -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=<seconds>` 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=<seconds>` 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)
|
||||
|
||||
Reference in New Issue
Block a user