mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+10
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user