Phase 5 abgeschlossen

- SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migration)
- Lokaler VOD-Verlauf (Resume)
- mpv `time-pos`-Observer schreibt alle ~5s in DB
- Auto-Resume: beim VOD-Start Position aus DB lesen → `--start=<s>`
 -Resume-Indikator auf VOD-Karte
 - Completed-Indikator (dunkles Overlay + Checkmark)
This commit is contained in:
Andre
2026-04-08 20:05:24 +02:00
parent ed1f710866
commit 2cd5e2fb0c
15 changed files with 1602 additions and 531 deletions
+8 -1
View File
@@ -3,6 +3,7 @@ import type { AuthEvent, AuthService } from '../auth/authService'
import type { HelixClient } from '../twitch/helixClient'
import type { PlaybackService } from '../playback/playbackService'
import type { PlaybackEvent } from '../playback/types'
import { getProgressMap } from '../store/historyRepo'
export const IPC = {
authStatus: 'auth:get-status',
@@ -15,6 +16,7 @@ export const IPC = {
twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos',
historyGetProgress: 'history:get-progress',
playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod',
@@ -47,6 +49,10 @@ export function registerIpcHandlers(
IPC.twitchGetVideos,
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
)
ipcMain.handle(
IPC.historyGetProgress,
(_e, vodIds: string[]) => getProgressMap(vodIds)
)
ipcMain.handle(
IPC.playbackStartLive,
@@ -54,7 +60,8 @@ export function registerIpcHandlers(
)
ipcMain.handle(
IPC.playbackStartVod,
(_e, vodId: string, startSeconds?: number) => playback.startVod(vodId, startSeconds)
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number) =>
playback.startVod(vodId, channelLogin, title, durationSeconds)
)
ipcMain.handle(IPC.playbackStop, () => playback.stop())
+42 -7
View File
@@ -11,6 +11,7 @@ export function getMpvIpcPath(): string {
export class MpvController {
private socket: net.Socket | null = null
private buf = ''
constructor(private ipcPath: string) {}
@@ -33,22 +34,51 @@ export class MpvController {
const sock = net.createConnection({ path: this.ipcPath })
sock.once('connect', () => {
this.socket = sock
sock.on('error', () => {
/* ignore socket errors after connect */
})
sock.on('error', () => { /* ignore socket errors after connect */ })
resolve()
})
sock.once('error', reject)
})
}
/** Beobachtet time-pos; ruft cb mit Sekunden auf. Throttling liegt beim Aufrufer. */
observeTimePos(cb: (seconds: number) => void): void {
if (!this.socket?.writable) return
// Observer registrieren
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 */
}
}
})
}
quit(): void {
if (this.socket?.writable) {
try {
this.socket.write(JSON.stringify({ command: ['quit'] }) + '\n')
} catch {
/* ignore */
}
this._send({ command: ['quit'] })
} catch { /* ignore */ }
}
this.socket?.destroy()
this.socket = null
@@ -57,5 +87,10 @@ export class MpvController {
disconnect(): void {
this.socket?.destroy()
this.socket = null
this.buf = ''
}
private _send(msg: object): void {
this.socket?.write(JSON.stringify(msg) + '\n')
}
}
+77 -75
View File
@@ -1,11 +1,14 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { spawnStreamlink } from './streamlink'
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
import { getMpvIpcPath, MpvController } from './mpvController'
import type { PlaybackEvent } from './types'
import * as history from '../store/historyRepo'
const POSITION_WRITE_INTERVAL_MS = 5_000
interface CurrentPlayback {
channelLogin: string
streamlink: ChildProcess
process: ChildProcess
mpv: MpvController
}
@@ -18,117 +21,116 @@ export class PlaybackService extends EventEmitter {
}
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
if (this.current) {
await this.stop()
}
if (this.current) await this.stop()
const sl = spawnStreamlink({
url: `twitch.tv/${channelLogin}`,
quality,
mpvIpcPath: this.ipcPath
})
const proc = spawnStreamlink(`twitch.tv/${channelLogin}`, quality)
this._attachLogs(proc)
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
proc.on('error', (e: Error) => {
if (this.current?.process === proc) 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)
}
proc.on('exit', (code) => {
if (this.current?.process === proc) this.current = null
if (code !== 0 && code !== null) console.warn('[streamlink] exit code', code)
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
})
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
const mpv = new MpvController(this.ipcPath)
this.current = { channelLogin, streamlink: sl, mpv }
this.emit('playback-event', {
kind: 'started',
channelLogin
} satisfies PlaybackEvent)
// IPC-Verbindung zu mpv im Hintergrund (nicht blockierend, nicht kritisch)
mpv.connect().catch(() => {})
this.current = { process: proc, mpv }
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
}
async startVod(vodId: string, startSeconds = 0): Promise<void> {
if (this.current) {
await this.stop()
}
async startVod(
vodId: string,
channelLogin: string,
title: string,
durationSeconds: number
): Promise<void> {
if (this.current) await this.stop()
const sl = spawnStreamlink({
url: `https://www.twitch.tv/videos/${vodId}`,
quality: 'best',
mpvIpcPath: this.ipcPath,
startSeconds
const resumePos = history.getPosition(vodId)
history.upsertVod({
vod_id: vodId,
channel_login: channelLogin,
title,
duration_seconds: durationSeconds,
watched_at: Date.now()
})
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
let hlsUrl: string
try {
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, 'best')
} catch (e) {
this.emit('playback-event', {
kind: 'error',
message: `streamlink Fehler: ${e.message}`
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
} satisfies PlaybackEvent)
return
}
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, startSeconds: resumePos })
this._attachLogs(proc)
proc.on('error', (e: Error) => {
if (this.current?.process === proc) this.current = null
this.emit('playback-event', {
kind: 'error',
message: `mpv 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)
}
proc.on('exit', (code) => {
if (this.current?.process === proc) this.current = null
if (code !== 0 && code !== null) console.warn('[mpv] 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.current = { process: proc, mpv }
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
mpv.connect().catch(() => {})
// IPC verbinden und Position tracken
mpv.connect().then(() => {
let lastWrite = 0
mpv.observeTimePos((seconds) => {
const now = Date.now()
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
lastWrite = now
const pos = Math.floor(seconds)
history.updatePosition(vodId, pos)
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
history.markCompleted(vodId)
}
})
}).catch(() => {
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
})
}
async stop(): Promise<void> {
if (!this.current) return
const { streamlink: sl, mpv } = this.current
const { process: proc, mpv } = this.current
this.current = null
try {
mpv.quit()
} catch {
/* ignore */
}
try { mpv.quit() } catch { /* ignore */ }
mpv.disconnect()
await new Promise<void>((r) => setTimeout(r, 400))
if (!sl.killed) {
sl.kill()
}
if (!proc.killed) proc.kill()
}
stopAll(): void {
this.stop().catch(() => {})
}
private _attachLogs(proc: ChildProcess): void {
proc.stdout?.on('data', (d: Buffer) => console.log('[player stdout]', d.toString().trim()))
proc.stderr?.on('data', (d: Buffer) => console.log('[player stderr]', d.toString().trim()))
}
}
+41 -22
View File
@@ -33,33 +33,52 @@ function resolveMpvBin(): string {
const STREAMLINK_BIN = resolveStreamlinkBin()
const MPV_BIN = resolveMpvBin()
export interface StreamlinkOptions {
url: string
quality: string
mpvIpcPath: string
/** Startet streamlink so dass es mpv als Player verwendet (für Live-Streams). */
export function spawnStreamlink(url: string, quality: string): ChildProcess {
return spawn(STREAMLINK_BIN, ['--player', MPV_BIN, '--player-args', '--fullscreen', url, quality], {
stdio: ['ignore', 'pipe', 'pipe']
})
}
/**
* Fragt streamlink nach der direkten HLS-URL für einen VOD.
* mpv kann diese URL dann nativ laden und darin seeked.
*/
export function getStreamUrl(url: string, quality: string): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(STREAMLINK_BIN, ['--stream-url', url, quality], {
stdio: ['ignore', 'pipe', 'pipe']
})
let out = ''
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
proc.on('error', reject)
proc.on('exit', (code) => {
const hlsUrl = out.trim()
if (code === 0 && hlsUrl) {
resolve(hlsUrl)
} else {
reject(new Error(`streamlink --stream-url exit ${code}`))
}
})
})
}
export interface MpvOptions {
ipcPath: string
startSeconds?: number
}
export function spawnStreamlink({
url,
quality,
mpvIpcPath,
startSeconds
}: StreamlinkOptions): ChildProcess {
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */
export function spawnMpv(url: string, { ipcPath, startSeconds }: MpvOptions): ChildProcess {
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
let playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
if (startSeconds && startSeconds > 0) {
playerArgs += ` --start=${startSeconds}`
}
const args = [
'--player',
MPV_BIN,
'--player-args',
playerArgs,
url,
quality
`--input-ipc-server=${ipcPath}`,
'--fullscreen',
`--hwdec=${hwdec}`
]
return spawn(STREAMLINK_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
if (startSeconds && startSeconds > 0) {
args.push(`--start=${startSeconds}`)
}
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
}
+30
View File
@@ -0,0 +1,30 @@
import Database from 'better-sqlite3'
import { app } from 'electron'
import { join } from 'node:path'
let _db: Database.Database | null = null
export function getDb(): Database.Database {
if (!_db) {
const dbPath = join(app.getPath('userData'), 'history.db')
_db = new Database(dbPath)
_db.pragma('journal_mode = WAL')
migrate(_db)
}
return _db
}
function migrate(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS vod_history (
vod_id TEXT PRIMARY KEY,
channel_login TEXT NOT NULL,
title TEXT,
duration_seconds INTEGER,
resume_position_seconds INTEGER NOT NULL DEFAULT 0,
watched_at INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_history_watched ON vod_history(watched_at DESC);
`)
}
+80
View File
@@ -0,0 +1,80 @@
import { getDb } from './db'
export interface VodHistoryEntry {
vod_id: string
channel_login: string
title: string | null
duration_seconds: number | null
resume_position_seconds: number
watched_at: number
completed: number
}
export function upsertVod(entry: Omit<VodHistoryEntry, 'resume_position_seconds' | 'completed'>): void {
const db = getDb()
db.prepare(`
INSERT INTO vod_history (vod_id, channel_login, title, duration_seconds, watched_at)
VALUES (@vod_id, @channel_login, @title, @duration_seconds, @watched_at)
ON CONFLICT(vod_id) DO UPDATE SET
watched_at = excluded.watched_at,
title = excluded.title,
duration_seconds = excluded.duration_seconds
`).run(entry)
}
export function updatePosition(vodId: string, positionSeconds: number): void {
getDb()
.prepare(
`UPDATE vod_history SET resume_position_seconds = ? WHERE vod_id = ?`
)
.run(positionSeconds, vodId)
}
export function markCompleted(vodId: string): void {
getDb()
.prepare(`UPDATE vod_history SET completed = 1 WHERE vod_id = ?`)
.run(vodId)
}
export interface VodProgress {
resumePositionSeconds: number
watchedAt: number
completed: boolean
}
/** Gibt für alle übergebenen vodIds vorhandene History-Einträge zurück. */
export function getProgressMap(vodIds: string[]): Record<string, VodProgress> {
if (vodIds.length === 0) return {}
const placeholders = vodIds.map(() => '?').join(',')
const rows = getDb()
.prepare(
`SELECT vod_id, resume_position_seconds, watched_at, completed
FROM vod_history WHERE vod_id IN (${placeholders})`
)
.all(...vodIds) as {
vod_id: string
resume_position_seconds: number
watched_at: number
completed: number
}[]
const map: Record<string, VodProgress> = {}
for (const r of rows) {
map[r.vod_id] = {
resumePositionSeconds: r.resume_position_seconds,
watchedAt: r.watched_at,
completed: r.completed === 1
}
}
return map
}
export function getPosition(vodId: string): number {
const row = getDb()
.prepare(
`SELECT resume_position_seconds, completed FROM vod_history WHERE vod_id = ?`
)
.get(vodId) as { resume_position_seconds: number; completed: number } | undefined
// Completed VODs: start from beginning
if (!row || row.completed) return 0
return row.resume_position_seconds
}
+13 -2
View File
@@ -10,6 +10,7 @@ const IPC = {
twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos',
historyGetProgress: 'history:get-progress',
playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod',
@@ -60,6 +61,12 @@ export interface VodInfo {
thumbnailUrl: string
}
export interface VodProgress {
resumePositionSeconds: number
watchedAt: number
completed: boolean
}
const api = {
appVersion: process.env.npm_package_version ?? '0.0.0',
auth: {
@@ -80,11 +87,15 @@ const api = {
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId)
},
history: {
getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> =>
ipcRenderer.invoke(IPC.historyGetProgress, vodIds)
},
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),
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds),
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
+86 -26
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { FollowedChannelInfo, PlaybackEvent, VodInfo } from '../types/t4sd'
import type { FollowedChannelInfo, PlaybackEvent, VodInfo, VodProgress } from '../types/t4sd'
interface Props {
channel: FollowedChannelInfo
@@ -21,9 +21,7 @@ function formatViewers(n: number): string {
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')}`
return `${h}:${String(m).padStart(2, '0')}`
}
function formatDate(iso: string): string {
@@ -34,6 +32,19 @@ function formatDate(iso: string): string {
})
}
function formatWatchedAt(ms: number): string {
const diff = Date.now() - ms
const mins = Math.floor(diff / 60_000)
const hours = Math.floor(diff / 3_600_000)
const days = Math.floor(diff / 86_400_000)
if (mins < 2) return 'Gerade eben'
if (mins < 60) return `Vor ${mins} Min.`
if (hours < 24) return `Vor ${hours} Std.`
if (days === 1) return 'Gestern'
if (days < 7) return `Vor ${days} Tagen`
return new Date(ms).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))
}
@@ -43,6 +54,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [errorMsg, setErrorMsg] = useState('')
const [vods, setVods] = useState<VodInfo[]>([])
const [vodsLoading, setVodsLoading] = useState(true)
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
const [shelfIndex, setShelfIndex] = useState(0)
const watchBtnRef = useRef<HTMLButtonElement>(null)
@@ -50,7 +62,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
useEffect(() => {
window.t4sd.twitch
.getVideos(channel.broadcasterId)
.then(setVods)
.then((loaded) => {
setVods(loaded)
const ids = loaded.map((v) => v.id)
return window.t4sd.history.getProgress(ids).then(setProgressMap)
})
.catch(() => {})
.finally(() => setVodsLoading(false))
}, [channel.broadcasterId])
@@ -106,13 +122,20 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setPlayState('playing')
} else if (ev.kind === 'stopped') {
setPlayState('idle')
// Progress nach Stop neu laden
if (vods.length > 0) {
void window.t4sd.history
.getProgress(vods.map((v) => v.id))
.then(setProgressMap)
.catch(() => {})
}
} else if (ev.kind === 'error') {
setPlayState('error')
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
}
})
return unsub
}, [])
}, [vods])
useEffect(() => {
watchBtnRef.current?.focus()
@@ -133,7 +156,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setPlayState('starting')
setErrorMsg('')
try {
await window.t4sd.playback.startVod(vod.id)
await window.t4sd.playback.startVod(
vod.id,
channel.broadcasterLogin,
vod.title,
vod.durationSeconds
)
} catch (e) {
setPlayState('error')
setErrorMsg(String(e))
@@ -252,26 +280,58 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
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>
{vods.map((vod, i) => {
const prog = progressMap[vod.id]
const pct =
prog && !prog.completed && vod.durationSeconds > 0
? Math.min(100, (prog.resumePositionSeconds / vod.durationSeconds) * 100)
: 0
return (
<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">
{prog && !prog.completed && prog.resumePositionSeconds > 0
? `${formatDuration(prog.resumePositionSeconds)} von ${formatDuration(vod.durationSeconds)}`
: formatDuration(vod.durationSeconds)}
</span>
{pct > 0 && (
<div className="channel-screen__vod-progress">
<div
className="channel-screen__vod-progress-bar"
style={{ width: `${pct}%` }}
/>
</div>
)}
{prog?.completed && (
<div className="channel-screen__vod-completed">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="7 13 10.5 16.5 17 8" />
</svg>
</div>
)}
</div>
<p className="channel-screen__vod-title">{vod.title}</p>
<p className="channel-screen__vod-meta">
{formatDate(vod.createdAt)}
{prog && !prog.completed && (
<span className="channel-screen__vod-watched">
{' · '}{formatWatchedAt(prog.watchedAt)}
</span>
)}
</p>
</div>
<p className="channel-screen__vod-title">{vod.title}</p>
<p className="channel-screen__vod-meta">{formatDate(vod.createdAt)}</p>
</div>
))}
)
})}
</div>
</div>
)}
+35
View File
@@ -691,6 +691,21 @@ body,
border-radius: 4px;
}
.channel-screen__vod-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(255, 255, 255, 0.2);
}
.channel-screen__vod-progress-bar {
height: 100%;
background: var(--color-accent);
border-radius: 0 2px 2px 0;
}
.channel-screen__vod-title {
margin: 0;
font-size: 15px;
@@ -708,6 +723,26 @@ body,
color: var(--color-fg-dim);
}
.channel-screen__vod-watched {
color: var(--color-accent);
}
.channel-screen__vod-completed {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.channel-screen__vod-completed svg {
width: 48px;
height: 48px;
color: #fff;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.6));
}
/* ── Account Screen ───────────────────────────────── */
.account {
flex: 1;
+10 -1
View File
@@ -43,6 +43,12 @@ export interface VodInfo {
thumbnailUrl: string
}
export interface VodProgress {
resumePositionSeconds: number
watchedAt: number
completed: boolean
}
export interface T4sdApi {
appVersion: string
auth: {
@@ -57,9 +63,12 @@ export interface T4sdApi {
getFollowed: () => Promise<FollowedChannelInfo[]>
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
}
history: {
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
}
playback: {
startLive: (channelLogin: string, quality?: string) => Promise<void>
startVod: (vodId: string, startSeconds?: number) => Promise<void>
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number) => Promise<void>
stop: () => Promise<void>
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
}