mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 5.5 abgeschlossen
- Durchsuchen Menu - Top Live Streams - Top Kategorie - Anzeige Zuschaueranzahl getTopGames(), technisch bedingt mit einer gewissen unschärfe zu den tatsächliche Zuschauer der Kategorie
This commit is contained in:
@@ -22,6 +22,8 @@ interface ButtonState {
|
||||
const BUTTON_MAP: Record<number, string> = {
|
||||
0: 'Enter', // A
|
||||
1: 'Escape', // B
|
||||
2: 'x', // X
|
||||
3: 'y', // Y
|
||||
12: 'ArrowUp',
|
||||
13: 'ArrowDown',
|
||||
14: 'ArrowLeft',
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
||||
import AccountScreen from './AccountScreen'
|
||||
import BrowseScreen from './BrowseScreen'
|
||||
import CategoryScreen from './CategoryScreen'
|
||||
import ChannelScreen from './ChannelScreen'
|
||||
import FollowingScreen from './FollowingScreen'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
type Region = 'sidebar' | 'main'
|
||||
|
||||
@@ -17,6 +18,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
const [region, setRegion] = useState<Region>('main')
|
||||
const [sidebarIndex, setSidebarIndex] = useState(0)
|
||||
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
|
||||
const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null)
|
||||
|
||||
const requestSidebar = useCallback(() => {
|
||||
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
||||
@@ -32,6 +34,10 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
setSelectedChannel(null)
|
||||
}, [])
|
||||
|
||||
const handleSelectCategory = useCallback((game: GameInfo) => {
|
||||
setSelectedCategory(game)
|
||||
}, [])
|
||||
|
||||
// Key-Handler für die Sidebar (wenn sie den Fokus hat)
|
||||
useEffect(() => {
|
||||
if (region !== 'sidebar') return
|
||||
@@ -53,6 +59,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
e.preventDefault()
|
||||
setTab(SIDEBAR_ITEMS[sidebarIndex].key)
|
||||
setSelectedChannel(null)
|
||||
setSelectedCategory(null)
|
||||
setRegion('main')
|
||||
break
|
||||
}
|
||||
@@ -71,12 +78,19 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
onItemClick={(k) => {
|
||||
setTab(k)
|
||||
setSelectedChannel(null)
|
||||
setSelectedCategory(null)
|
||||
setRegion('main')
|
||||
}}
|
||||
/>
|
||||
<div className="main-content">
|
||||
{selectedChannel ? (
|
||||
<ChannelScreen channel={selectedChannel} onBack={handleBack} />
|
||||
) : selectedCategory && tab === 'browse' ? (
|
||||
<CategoryScreen
|
||||
game={selectedCategory}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onBack={() => setSelectedCategory(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{tab === 'following' && (
|
||||
@@ -87,7 +101,12 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
{tab === 'browse' && (
|
||||
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
|
||||
<BrowseScreen
|
||||
hasFocus={mainFocus}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
)}
|
||||
{tab === 'account' && (
|
||||
<AccountScreen
|
||||
|
||||
@@ -1,33 +1,296 @@
|
||||
import { useEffect } from 'react'
|
||||
import { CompassIcon } from '../components/Icons'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
onRequestSidebar: () => void
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
onSelectCategory: (game: GameInfo) => void
|
||||
}
|
||||
|
||||
export default function BrowseScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
|
||||
type LoadState = 'loading' | 'ok' | 'error'
|
||||
type FocusRegion = 'shelf' | 'grid'
|
||||
|
||||
const STREAM_W = 220
|
||||
const STREAM_GAP = 14
|
||||
|
||||
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 formatViewersFull(n: number): string {
|
||||
return n.toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
export default function BrowseScreen({
|
||||
hasFocus,
|
||||
onRequestSidebar,
|
||||
onSelectChannel,
|
||||
onSelectCategory
|
||||
}: Props): JSX.Element {
|
||||
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
|
||||
const [topGames, setTopGames] = useState<GameInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusRegion, setFocusRegion] = useState<FocusRegion>('shelf')
|
||||
const [shelfIndex, setShelfIndex] = useState(0)
|
||||
const [gridIndex, setGridIndex] = useState(0)
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadState('loading')
|
||||
setNextCursor(undefined)
|
||||
try {
|
||||
const [streams, gamesResult] = await Promise.all([
|
||||
window.t4sd.twitch.getTopStreams(),
|
||||
window.t4sd.twitch.getTopGames()
|
||||
])
|
||||
setTopStreams(streams)
|
||||
setTopGames(gamesResult.games)
|
||||
setNextCursor(gamesResult.cursor)
|
||||
setShelfIndex(0)
|
||||
setGridIndex(0)
|
||||
setFocusRegion('shelf')
|
||||
setLoadState('ok')
|
||||
} catch (err) {
|
||||
console.error('[BrowseScreen] Laden fehlgeschlagen:', err)
|
||||
setLoadState('error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || isLoadingMore) return
|
||||
setIsLoadingMore(true)
|
||||
try {
|
||||
const result = await window.t4sd.twitch.getTopGames(nextCursor)
|
||||
setTopGames((prev) => [...prev, ...result.games])
|
||||
setNextCursor(result.cursor)
|
||||
} catch (err) {
|
||||
console.error('[BrowseScreen] Nachladen fehlgeschlagen:', err)
|
||||
} finally {
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
}, [nextCursor, isLoadingMore])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird
|
||||
useEffect(() => {
|
||||
if (focusRegion !== 'grid' || loadState !== 'ok') return
|
||||
if (gridIndex === topGames.length - 1 && nextCursor && !isLoadingMore) {
|
||||
void loadMore()
|
||||
}
|
||||
}, [gridIndex, focusRegion, loadState, topGames.length, nextCursor, isLoadingMore, loadMore])
|
||||
|
||||
const getGridColumns = useCallback((): number => {
|
||||
const grid = gridRef.current
|
||||
if (!grid) return 5
|
||||
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length
|
||||
return Math.max(1, cols)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFocus) return
|
||||
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
onRequestSidebar()
|
||||
if (e.key === 'y' || e.key === 'Y') {
|
||||
void load()
|
||||
return
|
||||
}
|
||||
|
||||
if (loadState !== 'ok') {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); onRequestSidebar() }
|
||||
return
|
||||
}
|
||||
|
||||
if (focusRegion === 'shelf') {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
if (shelfIndex === 0) onRequestSidebar()
|
||||
else setShelfIndex((i) => i - 1)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
setShelfIndex((i) => Math.min(topStreams.length - 1, i + 1))
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
if (topGames.length > 0) setFocusRegion('grid')
|
||||
break
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const ch = topStreams[shelfIndex]
|
||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
case 'X': {
|
||||
e.preventDefault()
|
||||
const ch = topStreams[shelfIndex]
|
||||
if (ch) onSelectChannel(ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// grid region
|
||||
const cols = getGridColumns()
|
||||
const total = topGames.length
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
setGridIndex((i) => Math.min(i + 1, total - 1))
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
if (gridIndex % cols === 0) onRequestSidebar()
|
||||
else setGridIndex((i) => i - 1)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setGridIndex((i) => Math.min(i + cols, total - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
if (gridIndex < cols) {
|
||||
setFocusRegion('shelf')
|
||||
} else {
|
||||
setGridIndex((i) => i - cols)
|
||||
}
|
||||
break
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const game = topGames[gridIndex]
|
||||
if (game) onSelectCategory(game)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [hasFocus, onRequestSidebar])
|
||||
}, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onSelectCategory, load])
|
||||
|
||||
// Grid-Karte in den Viewport scrollen wenn fokussiert
|
||||
useEffect(() => {
|
||||
if (focusRegion !== 'grid') return
|
||||
const grid = gridRef.current
|
||||
if (!grid) return
|
||||
const card = grid.children[gridIndex] as HTMLElement | undefined
|
||||
card?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}, [gridIndex, focusRegion])
|
||||
|
||||
const shelfOffset = shelfIndex * (STREAM_W + STREAM_GAP)
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<header className="screen__header">
|
||||
<div className="browse-screen">
|
||||
<header className="browse-screen__header">
|
||||
<h2 className="screen__title">Durchsuchen</h2>
|
||||
{loadState === 'ok' && (
|
||||
<div className="screen__meta">
|
||||
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="screen__state">
|
||||
<CompassIcon style={{ width: 72, height: 72, opacity: 0.35 }} />
|
||||
<p>Kategorien und Top-Streams folgen in einer späteren Phase.</p>
|
||||
</div>
|
||||
|
||||
{loadState === 'loading' && (
|
||||
<div className="screen__state">
|
||||
<p>Lade…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'error' && (
|
||||
<div className="screen__state">
|
||||
<p>Fehler beim Laden.</p>
|
||||
<button className="btn" onClick={() => void load()}>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'ok' && (
|
||||
<>
|
||||
{/* Top Live-Streams Shelf */}
|
||||
<div className="browse-shelf">
|
||||
<h3 className="browse-section__title">
|
||||
Top Live-Streams
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="browse-section__hint">
|
||||
{' '}· Enter Live · X Kanalseite · ↓ Kategorien
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="browse-shelf__viewport">
|
||||
<div
|
||||
className="browse-shelf__track"
|
||||
style={{ transform: `translateX(-${shelfOffset}px)` }}
|
||||
>
|
||||
{topStreams.map((ch, i) => (
|
||||
<button
|
||||
key={ch.broadcasterId}
|
||||
className={`browse-stream-card${focusRegion === 'shelf' && shelfIndex === i ? ' browse-stream-card--focused' : ''}`}
|
||||
onClick={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="browse-stream-card__thumb">
|
||||
{ch.thumbnailUrl && <img src={ch.thumbnailUrl} alt="" draggable={false} />}
|
||||
<span className="card__live-badge">LIVE</span>
|
||||
{ch.viewerCount !== undefined && (
|
||||
<span className="card__viewers">{formatViewers(ch.viewerCount)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="browse-stream-card__name">{ch.broadcasterName}</span>
|
||||
{ch.gameName && (
|
||||
<span className="browse-stream-card__game">{ch.gameName}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kategorien Grid */}
|
||||
<div className="browse-games">
|
||||
<h3 className="browse-section__title">
|
||||
Kategorien
|
||||
{focusRegion === 'grid' && (
|
||||
<span className="browse-section__hint">
|
||||
{' '}· Enter öffnen · ↑ zurück zu Streams
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="game-grid" ref={gridRef}>
|
||||
{topGames.map((game, i) => (
|
||||
<button
|
||||
key={game.id}
|
||||
className={`game-card${focusRegion === 'grid' && gridIndex === i ? ' game-card--focused' : ''}`}
|
||||
onClick={() => onSelectCategory(game)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="game-card__art">
|
||||
<img src={game.boxArtUrl} alt="" draggable={false} />
|
||||
</div>
|
||||
<span className="game-card__name">{game.name}</span>
|
||||
{game.viewerCount !== undefined && (
|
||||
<span className="game-card__viewers">
|
||||
{formatViewersFull(game.viewerCount)} Zuschauer
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
<div className="game-grid__loading-more">Lade weitere…</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
|
||||
interface Props {
|
||||
game: GameInfo
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ok' | 'error'
|
||||
|
||||
export default function CategoryScreen({ game, onSelectChannel, onBack }: Props): JSX.Element {
|
||||
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadState('loading')
|
||||
try {
|
||||
const data = await window.t4sd.twitch.getTopStreams(game.id)
|
||||
setStreams(data)
|
||||
setFocusedIndex(0)
|
||||
setLoadState('ok')
|
||||
} catch (err) {
|
||||
console.error('[CategoryScreen] Laden fehlgeschlagen:', err)
|
||||
setLoadState('error')
|
||||
}
|
||||
}, [game.id])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const getColumns = useCallback((): number => {
|
||||
const grid = gridRef.current
|
||||
if (!grid) return 4
|
||||
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length
|
||||
return Math.max(1, cols)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'y' || e.key === 'Y') {
|
||||
void load()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onBack()
|
||||
return
|
||||
}
|
||||
|
||||
if (loadState !== 'ok' || streams.length === 0) return
|
||||
|
||||
const cols = getColumns()
|
||||
const total = streams.length
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
setFocusedIndex((i) => Math.min(i + 1, total - 1))
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
if (focusedIndex % cols === 0) onBack()
|
||||
else setFocusedIndex((i) => i - 1)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setFocusedIndex((i) => Math.min(i + cols, total - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setFocusedIndex((i) => Math.max(i - cols, 0))
|
||||
break
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const ch = streams[focusedIndex]
|
||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
case 'X': {
|
||||
e.preventDefault()
|
||||
const ch = streams[focusedIndex]
|
||||
if (ch) onSelectChannel(ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [loadState, streams, focusedIndex, getColumns, onBack, onSelectChannel, load])
|
||||
|
||||
return (
|
||||
<div className="category-screen">
|
||||
<header className="category-screen__header">
|
||||
<button className="btn category-screen__back" onClick={onBack}>
|
||||
← Zurück
|
||||
</button>
|
||||
<img
|
||||
className="category-screen__art"
|
||||
src={game.boxArtUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="category-screen__info">
|
||||
<h2 className="category-screen__title">{game.name}</h2>
|
||||
{loadState === 'ok' && (
|
||||
<p className="category-screen__meta">
|
||||
{streams.length} Streams · [Y] Aktualisieren · [X] Kanalseite
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loadState === 'loading' && (
|
||||
<div className="screen__state">
|
||||
<p>Lade Streams…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'error' && (
|
||||
<div className="screen__state">
|
||||
<p>Fehler beim Laden der Streams.</p>
|
||||
<button className="btn" onClick={() => void load()}>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'ok' && streams.length === 0 && (
|
||||
<div className="screen__state">
|
||||
<p>Keine Live-Streams in dieser Kategorie.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'ok' && streams.length > 0 && (
|
||||
<div className="card-grid" ref={gridRef}>
|
||||
{streams.map((ch, i) => (
|
||||
<FocusableCard
|
||||
key={ch.broadcasterId}
|
||||
channel={ch}
|
||||
focused={i === focusedIndex}
|
||||
onFocus={() => setFocusedIndex(i)}
|
||||
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -743,6 +743,252 @@ body,
|
||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.6));
|
||||
}
|
||||
|
||||
/* ── Browse Screen ────────────────────────────────── */
|
||||
.browse-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 28px 40px 32px;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browse-screen__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.browse-section__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-fg-dim);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.browse-section__hint {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Stream shelf */
|
||||
.browse-shelf {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.browse-shelf__viewport {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browse-shelf__track {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
transition: transform 0.22s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.browse-stream-card {
|
||||
flex: 0 0 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
padding: 6px;
|
||||
border: 2px solid transparent;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: var(--color-fg);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.browse-stream-card--focused {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-elev2);
|
||||
}
|
||||
|
||||
.browse-stream-card__thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--color-bg-elev2);
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browse-stream-card__thumb > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.browse-stream-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.browse-stream-card__game {
|
||||
font-size: 13px;
|
||||
color: var(--color-fg-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Game grid */
|
||||
.browse-games {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
padding-bottom: 16px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.game-grid::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.game-grid::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.game-grid::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.game-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
padding: 6px;
|
||||
border: 2px solid transparent;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: var(--color-fg);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.game-card--focused {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-elev2);
|
||||
}
|
||||
|
||||
.game-card__art {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
background: var(--color-bg-elev2);
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-card__art > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-card__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.game-card__viewers {
|
||||
font-size: 12px;
|
||||
color: var(--color-fg-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.game-grid__loading-more {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
color: var(--color-fg-dim);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Category Screen */
|
||||
.category-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 28px 40px 32px;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-screen__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.category-screen__art {
|
||||
width: 60px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-elev2);
|
||||
}
|
||||
|
||||
.category-screen__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-screen__title {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.category-screen__meta {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
/* ── Account Screen ───────────────────────────────── */
|
||||
.account {
|
||||
flex: 1;
|
||||
|
||||
Vendored
+10
@@ -22,12 +22,20 @@ export interface FollowedChannelInfo {
|
||||
profileImageUrl: string
|
||||
isLive: boolean
|
||||
streamTitle?: string
|
||||
gameId?: string
|
||||
gameName?: string
|
||||
viewerCount?: number
|
||||
thumbnailUrl?: string
|
||||
startedAt?: string
|
||||
}
|
||||
|
||||
export interface GameInfo {
|
||||
id: string
|
||||
name: string
|
||||
boxArtUrl: string
|
||||
viewerCount?: number
|
||||
}
|
||||
|
||||
export interface PlaybackEvent {
|
||||
kind: 'started' | 'stopped' | 'error'
|
||||
channelLogin?: string
|
||||
@@ -62,6 +70,8 @@ export interface T4sdApi {
|
||||
twitch: {
|
||||
getFollowed: () => Promise<FollowedChannelInfo[]>
|
||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
|
||||
getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]>
|
||||
}
|
||||
history: {
|
||||
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
|
||||
|
||||
Reference in New Issue
Block a user