mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 3 abgeschlossen
Fix: Streamlink auth header entfernt, mpv-Pfad explizit aufgelöst Livestream wird kann jetzt abgespielt werden
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+11
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user