Phase 3 abgeschlossen

Fix: Streamlink auth header entfernt, mpv-Pfad explizit aufgelöst
Livestream wird kann jetzt abgespielt werden
This commit is contained in:
Andre
2026-04-08 17:44:48 +02:00
parent 60d4b4ace7
commit 3e8e5c2078
15 changed files with 773 additions and 182 deletions
+35 -12
View File
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from 'react'
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
import AccountScreen from './AccountScreen'
import BrowseScreen from './BrowseScreen'
import ChannelScreen from './ChannelScreen'
import FollowingScreen from './FollowingScreen'
import type { FollowedChannelInfo } from '../types/t4sd'
type Region = 'sidebar' | 'main'
@@ -14,6 +16,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
const [tab, setTab] = useState<TabKey>('following')
const [region, setRegion] = useState<Region>('main')
const [sidebarIndex, setSidebarIndex] = useState(0)
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
const requestSidebar = useCallback(() => {
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
@@ -21,6 +24,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setRegion('sidebar')
}, [tab])
const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => {
setSelectedChannel(ch)
}, [])
const handleBack = useCallback(() => {
setSelectedChannel(null)
}, [])
// Key-Handler für die Sidebar (wenn sie den Fokus hat)
useEffect(() => {
if (region !== 'sidebar') return
@@ -41,6 +52,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
case 'Enter':
e.preventDefault()
setTab(SIDEBAR_ITEMS[sidebarIndex].key)
setSelectedChannel(null)
setRegion('main')
break
}
@@ -58,22 +70,33 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
focusedIndex={region === 'sidebar' ? sidebarIndex : -1}
onItemClick={(k) => {
setTab(k)
setSelectedChannel(null)
setRegion('main')
}}
/>
<div className="main-content">
{tab === 'following' && (
<FollowingScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
{tab === 'browse' && (
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
{tab === 'account' && (
<AccountScreen
hasFocus={mainFocus}
onRequestSidebar={requestSidebar}
onLogout={onLogout}
/>
{selectedChannel ? (
<ChannelScreen channel={selectedChannel} onBack={handleBack} />
) : (
<>
{tab === 'following' && (
<FollowingScreen
hasFocus={mainFocus}
onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel}
/>
)}
{tab === 'browse' && (
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
{tab === 'account' && (
<AccountScreen
hasFocus={mainFocus}
onRequestSidebar={requestSidebar}
onLogout={onLogout}
/>
)}
</>
)}
</div>
</div>
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from 'react'
import type { FollowedChannelInfo, PlaybackEvent } from '../types/t4sd'
interface Props {
channel: FollowedChannelInfo
onBack: () => void
}
type PlayState = 'idle' | 'starting' | 'playing' | 'error'
function formatViewers(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mio.`
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
return String(n)
}
function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string | undefined {
return url?.replace('{width}', String(w)).replace('{height}', String(h))
}
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [playState, setPlayState] = useState<PlayState>('idle')
const [errorMsg, setErrorMsg] = useState('')
const watchBtnRef = useRef<HTMLButtonElement>(null)
// Escape / B → zurück (nur wenn nicht gerade Wiedergabe läuft)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.preventDefault()
if (playState === 'playing' || playState === 'starting') {
void window.t4sd.playback.stop()
} else {
onBack()
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [playState, onBack])
// Playback-Events aus dem Main-Prozess
useEffect(() => {
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
if (ev.kind === 'started') {
setPlayState('playing')
} else if (ev.kind === 'stopped') {
setPlayState('idle')
} else if (ev.kind === 'error') {
setPlayState('error')
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
}
})
return unsub
}, [])
// Initial-Fokus auf "Live ansehen"-Button
useEffect(() => {
watchBtnRef.current?.focus()
}, [])
const handleWatch = async (): Promise<void> => {
setPlayState('starting')
setErrorMsg('')
try {
await window.t4sd.playback.startLive(channel.broadcasterLogin)
} catch (e) {
setPlayState('error')
setErrorMsg(String(e))
}
}
const handleStop = async (): Promise<void> => {
await window.t4sd.playback.stop()
}
const thumb = resolveThumbnail(channel.thumbnailUrl)
return (
<div className="channel-screen">
<button className="channel-screen__back btn" onClick={onBack} tabIndex={0}>
← Zurück
</button>
<div className="channel-screen__hero">
<div className="channel-screen__thumb">
{thumb ? (
<img src={thumb} alt="" draggable={false} />
) : (
<div className="channel-screen__thumb-placeholder">
{channel.profileImageUrl && (
<img src={channel.profileImageUrl} alt="" draggable={false} />
)}
</div>
)}
{channel.isLive && <span className="channel-screen__live-badge">LIVE</span>}
</div>
<div className="channel-screen__info">
<div className="channel-screen__name-row">
{channel.profileImageUrl && (
<img
className="channel-screen__avatar"
src={channel.profileImageUrl}
alt=""
draggable={false}
/>
)}
<h2 className="channel-screen__name">{channel.broadcasterName}</h2>
</div>
{channel.streamTitle && (
<p className="channel-screen__title">{channel.streamTitle}</p>
)}
{channel.gameName && (
<p className="channel-screen__game">{channel.gameName}</p>
)}
{channel.viewerCount !== undefined && (
<p className="channel-screen__viewers">
{formatViewers(channel.viewerCount)} Zuschauer
</p>
)}
<div className="channel-screen__actions">
{(playState === 'idle' || playState === 'error') && channel.isLive && (
<button
ref={watchBtnRef}
className="btn btn--primary"
onClick={() => void handleWatch()}
>
▶ Live ansehen
</button>
)}
{playState === 'starting' && (
<button className="btn" disabled>
Starte Wiedergabe…
</button>
)}
{playState === 'playing' && (
<>
<span className="channel-screen__playing-hint">● Wiedergabe läuft in mpv</span>
<button className="btn" onClick={() => void handleStop()}>
■ Stop (B / Escape)
</button>
</>
)}
{!channel.isLive && playState === 'idle' && (
<p className="channel-screen__offline">Kanal ist gerade offline.</p>
)}
{playState === 'error' && (
<p className="channel-screen__error">{errorMsg}</p>
)}
</div>
</div>
</div>
</div>
)
}
+7 -5
View File
@@ -5,11 +5,16 @@ import type { FollowedChannelInfo } from '../types/t4sd'
interface Props {
hasFocus: boolean
onRequestSidebar: () => void
onSelectChannel: (ch: FollowedChannelInfo) => void
}
type LoadState = 'loading' | 'ok' | 'error'
export default function FollowingScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
export default function FollowingScreen({
hasFocus,
onRequestSidebar,
onSelectChannel
}: Props): JSX.Element {
const [channels, setChannels] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0)
@@ -136,10 +141,7 @@ export default function FollowingScreen({ hasFocus, onRequestSidebar }: Props):
channel={ch}
focused={hasFocus && i === focusedIndex}
onFocus={() => setFocusedIndex(i)}
onSelect={() => {
// Phase 3: Channel-Detail-Screen öffnen
console.log('[FollowingScreen] Kanal ausgewählt:', ch.broadcasterLogin)
}}
onSelect={() => onSelectChannel(ch)}
/>
))}
</div>
+141
View File
@@ -463,6 +463,147 @@ body,
font-style: italic;
}
/* ── Channel Detail Screen ────────────────────────── */
.channel-screen {
display: flex;
flex-direction: column;
height: 100%;
padding: 28px 40px 40px;
gap: 24px;
overflow-y: auto;
}
.channel-screen__back {
align-self: flex-start;
min-width: unset;
padding: 10px 24px;
font-size: 18px;
}
.channel-screen__hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: start;
}
.channel-screen__thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: var(--color-bg-elev2);
border-radius: var(--radius);
overflow: hidden;
}
.channel-screen__thumb > img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.channel-screen__thumb-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f1f23 0%, #2a2a31 100%);
}
.channel-screen__thumb-placeholder img {
width: 120px;
height: 120px;
border-radius: 50%;
opacity: 0.75;
}
.channel-screen__live-badge {
position: absolute;
top: 12px;
left: 12px;
background: var(--color-live);
color: #fff;
font-size: 13px;
font-weight: 800;
letter-spacing: 1px;
padding: 5px 12px;
border-radius: 4px;
}
.channel-screen__info {
display: flex;
flex-direction: column;
gap: 16px;
}
.channel-screen__name-row {
display: flex;
align-items: center;
gap: 16px;
}
.channel-screen__avatar {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
background: var(--color-bg-elev2);
flex-shrink: 0;
}
.channel-screen__name {
margin: 0;
font-size: 36px;
font-weight: 700;
}
.channel-screen__title {
margin: 0;
font-size: 20px;
color: var(--color-fg-dim);
line-height: 1.4;
}
.channel-screen__game {
margin: 0;
font-size: 18px;
color: var(--color-accent);
font-weight: 600;
}
.channel-screen__viewers {
margin: 0;
font-size: 18px;
color: var(--color-fg-dim);
}
.channel-screen__actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.channel-screen__playing-hint {
font-size: 20px;
color: var(--color-live);
font-weight: 600;
}
.channel-screen__offline {
margin: 0;
color: var(--color-fg-dim);
font-size: 20px;
}
.channel-screen__error {
margin: 0;
color: #ff6868;
font-size: 18px;
}
/* ── Account Screen ───────────────────────────────── */
.account {
flex: 1;
+11
View File
@@ -28,6 +28,12 @@ export interface FollowedChannelInfo {
startedAt?: string
}
export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error'
channelLogin?: string
message?: string
}
export interface T4sdApi {
appVersion: string
auth: {
@@ -41,6 +47,11 @@ export interface T4sdApi {
twitch: {
getFollowed: () => Promise<FollowedChannelInfo[]>
}
playback: {
startLive: (channelLogin: string, quality?: string) => Promise<void>
stop: () => Promise<void>
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
}
}
declare global {