mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Phase 6 Feat: VOD-Kapitelauswahl
- Kapitel werden via Twitch GQL API geladen - Kapitelauswahl als Modal-Overlay zentriert dargestellt - A-Taste pausiert/setzt VOD-Wiedergabe fort (war: Stop)
This commit is contained in:
@@ -18,12 +18,17 @@ export const IPC = {
|
|||||||
twitchGetVideos: 'twitch:get-videos',
|
twitchGetVideos: 'twitch:get-videos',
|
||||||
twitchGetTopGames: 'twitch:get-top-games',
|
twitchGetTopGames: 'twitch:get-top-games',
|
||||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||||
|
twitchGetVodChapters: 'twitch:get-vod-chapters',
|
||||||
historyGetProgress: 'history:get-progress',
|
historyGetProgress: 'history:get-progress',
|
||||||
|
|
||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
playbackStartVod: 'playback:start-vod',
|
playbackStartVod: 'playback:start-vod',
|
||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
playbackSeek: 'playback:seek',
|
playbackSeek: 'playback:seek',
|
||||||
|
playbackTogglePause: 'playback:toggle-pause',
|
||||||
|
playbackPause: 'playback:pause',
|
||||||
|
playbackResume: 'playback:resume',
|
||||||
|
playbackSeekTo: 'playback:seek-to',
|
||||||
/** main → renderer */
|
/** main → renderer */
|
||||||
playbackEvent: 'playback:event'
|
playbackEvent: 'playback:event'
|
||||||
} as const
|
} as const
|
||||||
@@ -60,6 +65,10 @@ export function registerIpcHandlers(
|
|||||||
IPC.twitchGetVideos,
|
IPC.twitchGetVideos,
|
||||||
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
|
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
|
||||||
)
|
)
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.twitchGetVodChapters,
|
||||||
|
(_e, videoId: string) => helix.getVodChapters(videoId)
|
||||||
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.historyGetProgress,
|
IPC.historyGetProgress,
|
||||||
(_e, vodIds: string[]) => getProgressMap(vodIds)
|
(_e, vodIds: string[]) => getProgressMap(vodIds)
|
||||||
@@ -71,11 +80,18 @@ export function registerIpcHandlers(
|
|||||||
)
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.playbackStartVod,
|
IPC.playbackStartVod,
|
||||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number) =>
|
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) =>
|
||||||
playback.startVod(vodId, channelLogin, title, durationSeconds)
|
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
|
||||||
)
|
)
|
||||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||||
ipcMain.handle(IPC.playbackSeek, (_e, seconds: number) => playback.seek(seconds))
|
ipcMain.handle(IPC.playbackSeek, (_e, seconds: number) => playback.seek(seconds))
|
||||||
|
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.playbackPause, () => {
|
||||||
|
playback.pause()
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) win.focus()
|
||||||
|
})
|
||||||
|
|
||||||
playback.on('playback-event', (event: PlaybackEvent) => {
|
playback.on('playback-event', (event: PlaybackEvent) => {
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
|||||||
@@ -80,6 +80,24 @@ export class MpvController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seekAbsolute(seconds: number): void {
|
||||||
|
if (this.socket?.writable) {
|
||||||
|
this._send({ command: ['seek', seconds, 'absolute'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePause(): void {
|
||||||
|
if (this.socket?.writable) {
|
||||||
|
this._send({ command: ['cycle', 'pause'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPause(paused: boolean): void {
|
||||||
|
if (this.socket?.writable) {
|
||||||
|
this._send({ command: ['set_property', 'pause', paused] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
quit(): void {
|
quit(): void {
|
||||||
if (this.socket?.writable) {
|
if (this.socket?.writable) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export class PlaybackService extends EventEmitter {
|
|||||||
vodId: string,
|
vodId: string,
|
||||||
channelLogin: string,
|
channelLogin: string,
|
||||||
title: string,
|
title: string,
|
||||||
durationSeconds: number
|
durationSeconds: number,
|
||||||
|
startSeconds?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.current) await this.stop()
|
if (this.current) await this.stop()
|
||||||
|
|
||||||
@@ -88,7 +89,8 @@ export class PlaybackService extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, startSeconds: resumePos })
|
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
||||||
|
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, startSeconds: effectiveStart })
|
||||||
|
|
||||||
let mpvStderrBuf = ''
|
let mpvStderrBuf = ''
|
||||||
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
|
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
|
||||||
@@ -145,6 +147,22 @@ export class PlaybackService extends EventEmitter {
|
|||||||
this.current?.mpv.seek(seconds)
|
this.current?.mpv.seek(seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
togglePause(): void {
|
||||||
|
this.current?.mpv.togglePause()
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
this.current?.mpv.setPause(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(): void {
|
||||||
|
this.current?.mpv.setPause(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
seekTo(seconds: number): void {
|
||||||
|
this.current?.mpv.seekAbsolute(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (!this.current) return
|
if (!this.current) return
|
||||||
const { process: proc, mpv } = this.current
|
const { process: proc, mpv } = this.current
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
HelixStream,
|
HelixStream,
|
||||||
HelixUser,
|
HelixUser,
|
||||||
HelixVideo,
|
HelixVideo,
|
||||||
|
VodChapter,
|
||||||
VodInfo
|
VodInfo
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
@@ -200,6 +201,58 @@ export class HelixClient {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly GQL_URL = 'https://gql.twitch.tv/gql'
|
||||||
|
// GQL requires the Twitch web client ID — the Helix app client ID is not accepted here
|
||||||
|
private static readonly GQL_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
|
||||||
|
private static readonly CHAPTERS_QUERY =
|
||||||
|
'query VideoPlayer_ChapterSelectButtonVideo($videoID: ID!) {' +
|
||||||
|
' video(id: $videoID) {' +
|
||||||
|
' moments(momentRequestType: VIDEO_CHAPTER_MARKERS) {' +
|
||||||
|
' edges { node {' +
|
||||||
|
' positionMilliseconds durationMilliseconds description type' +
|
||||||
|
' details { ... on GameChangeMomentDetails { game { id name } } }' +
|
||||||
|
' } }' +
|
||||||
|
' }' +
|
||||||
|
' }' +
|
||||||
|
'}'
|
||||||
|
|
||||||
|
async getVodChapters(videoId: string): Promise<VodChapter[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(HelixClient.GQL_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Client-Id': HelixClient.GQL_CLIENT_ID
|
||||||
|
},
|
||||||
|
body: JSON.stringify([{
|
||||||
|
operationName: 'VideoPlayer_ChapterSelectButtonVideo',
|
||||||
|
variables: { videoID: videoId },
|
||||||
|
query: HelixClient.CHAPTERS_QUERY
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
const json = await res.json() as Array<{
|
||||||
|
data?: { video?: { moments?: { edges: Array<{ node: {
|
||||||
|
positionMilliseconds: number
|
||||||
|
durationMilliseconds: number
|
||||||
|
description: string
|
||||||
|
details?: { game?: { id: string; name: string } | null }
|
||||||
|
}}> } } }
|
||||||
|
errors?: Array<{ message: string }>
|
||||||
|
}>
|
||||||
|
const payload = json[0]
|
||||||
|
if (!payload || payload.errors?.length || !payload.data?.video?.moments) return []
|
||||||
|
return payload.data.video.moments.edges.map(({ node }) => ({
|
||||||
|
positionSeconds: Math.floor(node.positionMilliseconds / 1000),
|
||||||
|
durationSeconds: Math.floor(node.durationMilliseconds / 1000),
|
||||||
|
gameName: node.details?.game?.name ?? node.description,
|
||||||
|
gameId: node.details?.game?.id ?? null
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ export interface VodInfo {
|
|||||||
thumbnailUrl: string
|
thumbnailUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VodChapter {
|
||||||
|
positionSeconds: number
|
||||||
|
durationSeconds: number
|
||||||
|
gameName: string
|
||||||
|
gameId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
/** Merged type that the renderer receives. */
|
/** Merged type that the renderer receives. */
|
||||||
export interface FollowedChannelInfo {
|
export interface FollowedChannelInfo {
|
||||||
broadcasterId: string
|
broadcasterId: string
|
||||||
|
|||||||
+21
-3
@@ -12,12 +12,17 @@ const IPC = {
|
|||||||
twitchGetVideos: 'twitch:get-videos',
|
twitchGetVideos: 'twitch:get-videos',
|
||||||
twitchGetTopGames: 'twitch:get-top-games',
|
twitchGetTopGames: 'twitch:get-top-games',
|
||||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||||
|
twitchGetVodChapters: 'twitch:get-vod-chapters',
|
||||||
historyGetProgress: 'history:get-progress',
|
historyGetProgress: 'history:get-progress',
|
||||||
|
|
||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
playbackStartVod: 'playback:start-vod',
|
playbackStartVod: 'playback:start-vod',
|
||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
playbackSeek: 'playback:seek',
|
playbackSeek: 'playback:seek',
|
||||||
|
playbackTogglePause: 'playback:toggle-pause',
|
||||||
|
playbackPause: 'playback:pause',
|
||||||
|
playbackResume: 'playback:resume',
|
||||||
|
playbackSeekTo: 'playback:seek-to',
|
||||||
playbackEvent: 'playback:event',
|
playbackEvent: 'playback:event',
|
||||||
|
|
||||||
gamepadInput: 'gamepad-input'
|
gamepadInput: 'gamepad-input'
|
||||||
@@ -81,6 +86,13 @@ export interface VodProgress {
|
|||||||
completed: boolean
|
completed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VodChapter {
|
||||||
|
positionSeconds: number
|
||||||
|
durationSeconds: number
|
||||||
|
gameName: string
|
||||||
|
gameId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||||
auth: {
|
auth: {
|
||||||
@@ -103,7 +115,9 @@ const api = {
|
|||||||
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
|
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
|
||||||
ipcRenderer.invoke(IPC.twitchGetTopGames, cursor),
|
ipcRenderer.invoke(IPC.twitchGetTopGames, cursor),
|
||||||
getTopStreams: (gameId?: string): Promise<FollowedChannelInfo[]> =>
|
getTopStreams: (gameId?: string): Promise<FollowedChannelInfo[]> =>
|
||||||
ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId)
|
ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId),
|
||||||
|
getVodChapters: (vodId: string): Promise<VodChapter[]> =>
|
||||||
|
ipcRenderer.invoke(IPC.twitchGetVodChapters, vodId)
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> =>
|
getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> =>
|
||||||
@@ -112,10 +126,14 @@ const api = {
|
|||||||
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, channelLogin: string, title: string, durationSeconds: number): Promise<void> =>
|
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> =>
|
||||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds),
|
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
|
||||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||||
seek: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeek, seconds),
|
seek: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeek, seconds),
|
||||||
|
togglePause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackTogglePause),
|
||||||
|
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
|
||||||
|
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume),
|
||||||
|
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds),
|
||||||
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)
|
||||||
ipcRenderer.on(IPC.playbackEvent, listener)
|
ipcRenderer.on(IPC.playbackEvent, listener)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { FollowedChannelInfo, PlaybackEvent, VodInfo, VodProgress } from '../types/t4sd'
|
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: FollowedChannelInfo
|
channel: FollowedChannelInfo
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayState = 'idle' | 'starting' | 'playing' | 'error'
|
type PlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
|
||||||
type FocusRegion = 'hero' | 'shelf'
|
type FocusRegion = 'hero' | 'shelf' | 'chapters'
|
||||||
|
|
||||||
const CARD_W = 260
|
const CARD_W = 260
|
||||||
const CARD_GAP = 16
|
const CARD_GAP = 16
|
||||||
@@ -49,6 +49,13 @@ function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string |
|
|||||||
return url?.replace('{width}', String(w)).replace('{height}', String(h))
|
return url?.replace('{width}', String(w)).replace('{height}', String(h))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(s: number): string {
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
const sec = s % 60
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
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('')
|
||||||
@@ -57,7 +64,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
|
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
|
||||||
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
|
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
|
||||||
const [shelfIndex, setShelfIndex] = useState(0)
|
const [shelfIndex, setShelfIndex] = useState(0)
|
||||||
|
const [chapterPanelVod, setChapterPanelVod] = useState<VodInfo | null>(null)
|
||||||
|
const [chapters, setChapters] = useState<VodChapter[]>([])
|
||||||
|
const [chaptersLoading, setChaptersLoading] = useState(false)
|
||||||
|
const [chapterIndex, setChapterIndex] = useState(0)
|
||||||
|
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false)
|
||||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.t4sd.twitch
|
window.t4sd.twitch
|
||||||
@@ -73,7 +86,59 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
const isPlaying = playState === 'playing' || playState === 'starting'
|
const isPlaying = playState === 'playing' || playState === 'starting' || playState === 'paused'
|
||||||
|
|
||||||
|
// Kapitel-Panel hat oberste Priorität (gilt auch während Wiedergabe)
|
||||||
|
if (focusRegion === 'chapters') {
|
||||||
|
const duringPlayback = isPlaying
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (duringPlayback) {
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
setFocusRegion('hero')
|
||||||
|
if (wasPlayingBeforeChapters) {
|
||||||
|
void window.t4sd.playback.resume()
|
||||||
|
setPlayState('playing')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
setFocusRegion('shelf')
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1))
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setChapterIndex((i) => Math.max(0, i - 1))
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
const vod = chapterPanelVod
|
||||||
|
if (!vod) return
|
||||||
|
const chapter = chapters[chapterIndex]
|
||||||
|
if (duringPlayback) {
|
||||||
|
if (chapter) {
|
||||||
|
void window.t4sd.playback.seekTo(chapter.positionSeconds)
|
||||||
|
void window.t4sd.playback.resume()
|
||||||
|
setPlayState('playing')
|
||||||
|
} else {
|
||||||
|
if (wasPlayingBeforeChapters) {
|
||||||
|
void window.t4sd.playback.resume()
|
||||||
|
setPlayState('playing')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
setFocusRegion('hero')
|
||||||
|
} else {
|
||||||
|
if (chapter) {
|
||||||
|
void handleWatchVodFromChapter(vod, chapter.positionSeconds)
|
||||||
|
} else {
|
||||||
|
void handleWatchVod(vod)
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
|
// Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
@@ -96,12 +161,27 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
case ' ':
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
if (playState === 'playing') {
|
||||||
|
void window.t4sd.playback.togglePause()
|
||||||
|
setPlayState('paused')
|
||||||
|
} else if (playState === 'paused') {
|
||||||
|
void window.t4sd.playback.togglePause()
|
||||||
|
setPlayState('playing')
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void window.t4sd.playback.stop()
|
void window.t4sd.playback.stop()
|
||||||
break
|
break
|
||||||
|
case 'y': {
|
||||||
|
e.preventDefault()
|
||||||
|
const vod = vods[shelfIndex]
|
||||||
|
if (vod) openChapterPanel(vod, true)
|
||||||
|
break
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
e.preventDefault() // alle anderen Tasten blockieren
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,7 +202,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// shelf region
|
// shelf region
|
||||||
if (e.key === 'ArrowLeft') {
|
if (e.key === 'y') {
|
||||||
|
e.preventDefault()
|
||||||
|
const vod = vods[shelfIndex]
|
||||||
|
if (vod) openChapterPanel(vod, false)
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShelfIndex((i) => Math.max(0, i - 1))
|
setShelfIndex((i) => Math.max(0, i - 1))
|
||||||
} else if (e.key === 'ArrowRight') {
|
} else if (e.key === 'ArrowRight') {
|
||||||
@@ -144,7 +228,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [focusRegion, playState, vods, shelfIndex, onBack])
|
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
||||||
@@ -152,6 +236,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setPlayState('playing')
|
setPlayState('playing')
|
||||||
} else if (ev.kind === 'stopped') {
|
} else if (ev.kind === 'stopped') {
|
||||||
setPlayState('idle')
|
setPlayState('idle')
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
setFocusRegion('shelf')
|
||||||
// Progress nach Stop neu laden
|
// Progress nach Stop neu laden
|
||||||
if (vods.length > 0) {
|
if (vods.length > 0) {
|
||||||
void window.t4sd.history
|
void window.t4sd.history
|
||||||
@@ -161,6 +247,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
} else if (ev.kind === 'error') {
|
} else if (ev.kind === 'error') {
|
||||||
setPlayState('error')
|
setPlayState('error')
|
||||||
|
setChapterPanelVod(null)
|
||||||
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -171,6 +258,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
watchBtnRef.current?.focus()
|
watchBtnRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chapterListRef.current) return
|
||||||
|
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
|
||||||
|
item?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
|
}, [chapterIndex])
|
||||||
|
|
||||||
const handleWatch = async (): Promise<void> => {
|
const handleWatch = async (): Promise<void> => {
|
||||||
setPlayState('starting')
|
setPlayState('starting')
|
||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
@@ -198,6 +291,41 @@ 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')
|
||||||
|
}
|
||||||
|
window.t4sd.twitch.getVodChapters(vod.id)
|
||||||
|
.then((loaded) => setChapters(loaded))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setChaptersLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWatchVodFromChapter = async (vod: VodInfo, startSeconds: number): Promise<void> => {
|
||||||
|
setPlayState('starting')
|
||||||
|
setErrorMsg('')
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
try {
|
||||||
|
await window.t4sd.playback.startVod(
|
||||||
|
vod.id,
|
||||||
|
channel.broadcasterLogin,
|
||||||
|
vod.title,
|
||||||
|
vod.durationSeconds,
|
||||||
|
startSeconds
|
||||||
|
)
|
||||||
|
} 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()
|
||||||
}
|
}
|
||||||
@@ -273,9 +401,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{playState === 'playing' && (
|
{(playState === 'playing' || playState === 'paused') && (
|
||||||
<>
|
<>
|
||||||
<span className="channel-screen__playing-hint">● Wiedergabe läuft in mpv · ← −30s · → +30s · LT −5min · RT +5min</span>
|
<span className="channel-screen__playing-hint">
|
||||||
|
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · Y Kapitel
|
||||||
|
</span>
|
||||||
<button className="btn" onClick={() => void handleStop()}>
|
<button className="btn" onClick={() => void handleStop()}>
|
||||||
■ Stop (B / Escape)
|
■ Stop (B / Escape)
|
||||||
</button>
|
</button>
|
||||||
@@ -297,7 +427,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
<h3 className="channel-screen__vods-title">
|
<h3 className="channel-screen__vods-title">
|
||||||
Vergangene Streams
|
Vergangene Streams
|
||||||
{focusRegion === 'shelf' && (
|
{focusRegion === 'shelf' && (
|
||||||
<span className="channel-screen__vods-hint"> · ↑ zurück · Enter zum Abspielen</span>
|
<span className="channel-screen__vods-hint"> · ↑ zurück · A abspielen · Y Kapitel</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
||||||
@@ -366,6 +496,39 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{chapterPanelVod && (
|
||||||
|
<div className="chapter-overlay">
|
||||||
|
<div className="chapter-overlay__panel">
|
||||||
|
<div className="chapter-overlay__header">
|
||||||
|
<span className="chapter-overlay__title">Kapitel wählen</span>
|
||||||
|
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="chapter-overlay__hint">↑↓ navigieren · A öffnen · B schließen</p>
|
||||||
|
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
|
||||||
|
{!chaptersLoading && chapters.length === 0 && (
|
||||||
|
<p className="chapter-overlay__msg">
|
||||||
|
Keine Kapitel gefunden.{' '}
|
||||||
|
{(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chapters.length > 0 && (
|
||||||
|
<ul className="chapter-overlay__list" ref={chapterListRef}>
|
||||||
|
{chapters.map((ch, i) => (
|
||||||
|
<li
|
||||||
|
key={ch.positionSeconds}
|
||||||
|
className={`chapter-overlay__item${i === chapterIndex ? ' chapter-overlay__item--focused' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="chapter-overlay__index">{i + 1}</span>
|
||||||
|
<span className="chapter-overlay__game">{ch.gameName}</span>
|
||||||
|
<span className="chapter-overlay__time">{formatTimestamp(ch.positionSeconds)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1138,3 +1138,100 @@ body,
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Chapter Overlay (Modal) ──────────────────────────── */
|
||||||
|
.chapter-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.78);
|
||||||
|
}
|
||||||
|
.chapter-overlay__panel {
|
||||||
|
background: var(--color-bg-elev);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 28px 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
width: 660px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 72vh;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.7);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chapter-overlay__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chapter-overlay__title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chapter-overlay__vod-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chapter-overlay__hint {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chapter-overlay__msg {
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.chapter-overlay__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.chapter-overlay__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.chapter-overlay__item--focused {
|
||||||
|
background: rgba(145,71,255,0.18);
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
}
|
||||||
|
.chapter-overlay__index {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chapter-overlay__game {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chapter-overlay__time {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+13
-1
@@ -58,6 +58,13 @@ export interface VodProgress {
|
|||||||
completed: boolean
|
completed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VodChapter {
|
||||||
|
positionSeconds: number
|
||||||
|
durationSeconds: number
|
||||||
|
gameName: string
|
||||||
|
gameId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface T4sdApi {
|
export interface T4sdApi {
|
||||||
appVersion: string
|
appVersion: string
|
||||||
auth: {
|
auth: {
|
||||||
@@ -73,15 +80,20 @@ export interface T4sdApi {
|
|||||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||||
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
|
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
|
||||||
getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]>
|
getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]>
|
||||||
|
getVodChapters: (vodId: string) => Promise<VodChapter[]>
|
||||||
}
|
}
|
||||||
history: {
|
history: {
|
||||||
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
|
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
|
||||||
}
|
}
|
||||||
playback: {
|
playback: {
|
||||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number) => Promise<void>
|
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => Promise<void>
|
||||||
stop: () => Promise<void>
|
stop: () => Promise<void>
|
||||||
seek: (seconds: number) => Promise<void>
|
seek: (seconds: number) => Promise<void>
|
||||||
|
togglePause: () => Promise<void>
|
||||||
|
pause: () => Promise<void>
|
||||||
|
resume: () => Promise<void>
|
||||||
|
seekTo: (seconds: number) => Promise<void>
|
||||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
gamepad: {
|
gamepad: {
|
||||||
|
|||||||
Reference in New Issue
Block a user