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',
|
authEvent: 'auth:event',
|
||||||
|
|
||||||
twitchGetFollowed: 'twitch:get-followed',
|
twitchGetFollowed: 'twitch:get-followed',
|
||||||
|
twitchGetVideos: 'twitch:get-videos',
|
||||||
|
|
||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
|
playbackStartVod: 'playback:start-vod',
|
||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
/** main → renderer */
|
/** main → renderer */
|
||||||
playbackEvent: 'playback:event'
|
playbackEvent: 'playback:event'
|
||||||
@@ -41,11 +43,19 @@ export function registerIpcHandlers(
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
|
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.twitchGetVideos,
|
||||||
|
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.playbackStartLive,
|
IPC.playbackStartLive,
|
||||||
(_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality)
|
(_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())
|
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||||
|
|
||||||
playback.on('playback-event', (event: PlaybackEvent) => {
|
playback.on('playback-event', (event: PlaybackEvent) => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class PlaybackService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sl = spawnStreamlink({
|
const sl = spawnStreamlink({
|
||||||
channelLogin,
|
url: `twitch.tv/${channelLogin}`,
|
||||||
quality,
|
quality,
|
||||||
mpvIpcPath: this.ipcPath
|
mpvIpcPath: this.ipcPath
|
||||||
})
|
})
|
||||||
@@ -65,6 +65,50 @@ export class PlaybackService extends EventEmitter {
|
|||||||
mpv.connect().catch(() => {})
|
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> {
|
async stop(): Promise<void> {
|
||||||
if (!this.current) return
|
if (!this.current) return
|
||||||
const { streamlink: sl, mpv } = this.current
|
const { streamlink: sl, mpv } = this.current
|
||||||
|
|||||||
@@ -34,25 +34,30 @@ const STREAMLINK_BIN = resolveStreamlinkBin()
|
|||||||
const MPV_BIN = resolveMpvBin()
|
const MPV_BIN = resolveMpvBin()
|
||||||
|
|
||||||
export interface StreamlinkOptions {
|
export interface StreamlinkOptions {
|
||||||
channelLogin: string
|
url: string
|
||||||
quality: string
|
quality: string
|
||||||
mpvIpcPath: string
|
mpvIpcPath: string
|
||||||
|
startSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnStreamlink({
|
export function spawnStreamlink({
|
||||||
channelLogin,
|
url,
|
||||||
quality,
|
quality,
|
||||||
mpvIpcPath
|
mpvIpcPath,
|
||||||
|
startSeconds
|
||||||
}: StreamlinkOptions): ChildProcess {
|
}: StreamlinkOptions): ChildProcess {
|
||||||
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
|
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 = [
|
const args = [
|
||||||
'--player',
|
'--player',
|
||||||
MPV_BIN,
|
MPV_BIN,
|
||||||
'--player-args',
|
'--player-args',
|
||||||
playerArgs,
|
playerArgs,
|
||||||
`twitch.tv/${channelLogin}`,
|
url,
|
||||||
quality
|
quality
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,22 @@ import type {
|
|||||||
HelixFollowedChannel,
|
HelixFollowedChannel,
|
||||||
HelixPaginatedResponse,
|
HelixPaginatedResponse,
|
||||||
HelixStream,
|
HelixStream,
|
||||||
HelixUser
|
HelixUser,
|
||||||
|
HelixVideo,
|
||||||
|
VodInfo
|
||||||
} from './types'
|
} 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'
|
const BASE = 'https://api.twitch.tv/helix'
|
||||||
|
|
||||||
export class HelixClient {
|
export class HelixClient {
|
||||||
@@ -96,6 +109,24 @@ export class HelixClient {
|
|||||||
return map
|
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[]> {
|
async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> {
|
||||||
const userId = await this.getOwnUserId()
|
const userId = await this.getOwnUserId()
|
||||||
const followed = await this.getAllFollowed(userId)
|
const followed = await this.getAllFollowed(userId)
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ export interface HelixPaginatedResponse<T> {
|
|||||||
pagination?: { cursor?: string }
|
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. */
|
/** Merged type that the renderer receives. */
|
||||||
export interface FollowedChannelInfo {
|
export interface FollowedChannelInfo {
|
||||||
broadcasterId: string
|
broadcasterId: string
|
||||||
|
|||||||
+16
-1
@@ -9,8 +9,10 @@ const IPC = {
|
|||||||
authEvent: 'auth:event',
|
authEvent: 'auth:event',
|
||||||
|
|
||||||
twitchGetFollowed: 'twitch:get-followed',
|
twitchGetFollowed: 'twitch:get-followed',
|
||||||
|
twitchGetVideos: 'twitch:get-videos',
|
||||||
|
|
||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
|
playbackStartVod: 'playback:start-vod',
|
||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
playbackEvent: 'playback:event'
|
playbackEvent: 'playback:event'
|
||||||
} as const
|
} as const
|
||||||
@@ -49,6 +51,15 @@ export interface PlaybackEvent {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VodInfo {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: string
|
||||||
|
durationSeconds: number
|
||||||
|
viewCount: number
|
||||||
|
thumbnailUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||||
auth: {
|
auth: {
|
||||||
@@ -65,11 +76,15 @@ const api = {
|
|||||||
},
|
},
|
||||||
twitch: {
|
twitch: {
|
||||||
getFollowed: (): Promise<FollowedChannelInfo[]> =>
|
getFollowed: (): Promise<FollowedChannelInfo[]> =>
|
||||||
ipcRenderer.invoke(IPC.twitchGetFollowed)
|
ipcRenderer.invoke(IPC.twitchGetFollowed),
|
||||||
|
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
|
||||||
|
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId)
|
||||||
},
|
},
|
||||||
playback: {
|
playback: {
|
||||||
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
||||||
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
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),
|
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||||
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
|
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
|
||||||
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
|
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { FollowedChannelInfo, PlaybackEvent } from '../types/t4sd'
|
import type { FollowedChannelInfo, PlaybackEvent, VodInfo } from '../types/t4sd'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: FollowedChannelInfo
|
channel: FollowedChannelInfo
|
||||||
@@ -7,6 +7,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PlayState = 'idle' | 'starting' | 'playing' | 'error'
|
type PlayState = 'idle' | 'starting' | 'playing' | 'error'
|
||||||
|
type FocusRegion = 'hero' | 'shelf'
|
||||||
|
|
||||||
|
const CARD_W = 260
|
||||||
|
const CARD_GAP = 16
|
||||||
|
|
||||||
function formatViewers(n: number): string {
|
function formatViewers(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mio.`
|
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)
|
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 {
|
function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string | undefined {
|
||||||
return url?.replace('{width}', String(w)).replace('{height}', String(h))
|
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 {
|
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||||
const [playState, setPlayState] = useState<PlayState>('idle')
|
const [playState, setPlayState] = useState<PlayState>('idle')
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
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)
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
if (e.key === 'Escape') {
|
if (focusRegion === 'hero') {
|
||||||
e.preventDefault()
|
if (e.key === 'ArrowDown' && vods.length > 0) {
|
||||||
if (playState === 'playing' || playState === 'starting') {
|
e.preventDefault()
|
||||||
void window.t4sd.playback.stop()
|
setFocusRegion('shelf')
|
||||||
} else {
|
setShelfIndex(0)
|
||||||
onBack()
|
} 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)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [playState, onBack])
|
}, [focusRegion, playState, vods, shelfIndex, onBack])
|
||||||
|
|
||||||
// Playback-Events aus dem Main-Prozess
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
||||||
if (ev.kind === 'started') {
|
if (ev.kind === 'started') {
|
||||||
@@ -54,7 +114,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
return unsub
|
return unsub
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Initial-Fokus auf "Live ansehen"-Button
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
watchBtnRef.current?.focus()
|
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> => {
|
const handleStop = async (): Promise<void> => {
|
||||||
await window.t4sd.playback.stop()
|
await window.t4sd.playback.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||||
|
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="channel-screen">
|
<div className="channel-screen">
|
||||||
@@ -132,6 +203,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(playState === 'idle' || playState === 'error') && !channel.isLive && (
|
||||||
|
<button ref={watchBtnRef} className="btn btn--primary" disabled style={{ opacity: 0 }}>
|
||||||
|
placeholder
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{playState === 'starting' && (
|
{playState === 'starting' && (
|
||||||
<button className="btn" disabled>
|
<button className="btn" disabled>
|
||||||
Starte Wiedergabe…
|
Starte Wiedergabe…
|
||||||
@@ -157,6 +234,48 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,6 +604,110 @@ body,
|
|||||||
font-size: 18px;
|
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 Screen ───────────────────────────────── */
|
||||||
.account {
|
.account {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Vendored
+11
@@ -34,6 +34,15 @@ export interface PlaybackEvent {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VodInfo {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: string
|
||||||
|
durationSeconds: number
|
||||||
|
viewCount: number
|
||||||
|
thumbnailUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface T4sdApi {
|
export interface T4sdApi {
|
||||||
appVersion: string
|
appVersion: string
|
||||||
auth: {
|
auth: {
|
||||||
@@ -46,9 +55,11 @@ export interface T4sdApi {
|
|||||||
}
|
}
|
||||||
twitch: {
|
twitch: {
|
||||||
getFollowed: () => Promise<FollowedChannelInfo[]>
|
getFollowed: () => Promise<FollowedChannelInfo[]>
|
||||||
|
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||||
}
|
}
|
||||||
playback: {
|
playback: {
|
||||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||||
|
startVod: (vodId: string, startSeconds?: number) => Promise<void>
|
||||||
stop: () => Promise<void>
|
stop: () => Promise<void>
|
||||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,17 +66,18 @@
|
|||||||
- [x] Spatial-Nav: Escape/B im ChannelScreen → Stop oder Zurück
|
- [x] Spatial-Nav: Escape/B im ChannelScreen → Stop oder Zurück
|
||||||
|
|
||||||
### Verifikation
|
### Verifikation
|
||||||
- [ ] **mpv PATH-Problem lösen** (VS Code neu starten, dann testen — siehe CLAUDE_PROGRESS.md)
|
- [x] Stream startet: mpv-Fenster öffnet sich, Stream spielt ab (2026-04-08)
|
||||||
- [ ] 5 min Live-Stream ohne Werbe-Unterbrechung auf Windows (Dev-Setup)
|
_(Fix: --twitch-api-header entfernt, resolveMpvBin() ergänzt)_
|
||||||
- [ ] Beim Stop: mpv-Fenster schließt sich sauber, streamlink-Prozess terminiert,
|
- [x] **User:** Stop-Verhalten: mpv-Fenster schließt sich, keine Zombie-Prozesse (2026-04-08)
|
||||||
keine Zombie-Prozesse im Task-Manager
|
- [x] **User:** Logout → Login → Stream starten funktioniert ohne App-Neustart (2026-04-08)
|
||||||
- [ ] Logout/Login und erneutes Starten funktioniert ohne App-Neustart
|
⚠️ `--twitch-disable-ads` ist von streamlink deaktiviert — Werbung wird ggf. abgespielt (Post-MVP)
|
||||||
|
|
||||||
## Phase 4 — VOD-Browsing + -Wiedergabe
|
## Phase 4 — VOD-Browsing + -Wiedergabe
|
||||||
- [ ] Helix `GET /videos?user_id=…&type=archive` (paginiert)
|
- [x] Helix `GET /videos?user_id=…&type=archive` → `getVideos()` in helixClient
|
||||||
- [ ] VOD-Listen-Screen pro Channel
|
- [x] VOD-Shelf im ChannelScreen (horizontal, obere Hälfte fix)
|
||||||
- [ ] `playback:start-vod` (mit optionaler Resume-Position)
|
- [x] `playback:start-vod` IPC-Kanal + `PlaybackService.startVod()`
|
||||||
- [ ] mpv-Resume: `--start=<seconds>` beim Spawn
|
- [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
|
## Phase 5 — Lokaler VOD-Verlauf
|
||||||
- [ ] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migrations)
|
- [ ] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migrations)
|
||||||
|
|||||||
Reference in New Issue
Block a user