mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e6c61b2c7 | ||
|
|
f365170ded | ||
|
|
eeddcb9dff |
@@ -1,9 +1,30 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
interface PackageJson {
|
||||
version?: string
|
||||
}
|
||||
|
||||
function getAppVersion(): string {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf8')) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
} catch {
|
||||
// ignore malformed package.json and use final fallback below
|
||||
}
|
||||
|
||||
return '0.0.0-dev'
|
||||
}
|
||||
|
||||
const appVersion = getAppVersion()
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
define: {
|
||||
'process.env.APP_VERSION': JSON.stringify(appVersion)
|
||||
},
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@@ -12,6 +33,9 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
define: {
|
||||
'process.env.APP_VERSION': JSON.stringify(appVersion)
|
||||
},
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@@ -21,6 +45,9 @@ export default defineConfig({
|
||||
},
|
||||
renderer: {
|
||||
root: resolve(__dirname, 'src/renderer'),
|
||||
define: {
|
||||
'process.env.APP_VERSION': JSON.stringify(appVersion)
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch4steamdeck",
|
||||
"version": "0.0.1",
|
||||
"version": "0.3.3",
|
||||
"description": "Werbefreier Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
||||
"main": "out/main/index.js",
|
||||
"author": "twitch4steamdeck",
|
||||
|
||||
+12
-4
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { app, BrowserWindow, shell, screen } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { AuthService } from './auth/authService'
|
||||
import { HelixClient } from './twitch/helixClient'
|
||||
@@ -9,9 +9,14 @@ import { startGamepadReader } from './input/gamepadReader'
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const isLinux = process.platform === 'linux'
|
||||
const { width, height } = isLinux
|
||||
? screen.getPrimaryDisplay().workAreaSize
|
||||
: { width: 1280, height: 800 }
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
width,
|
||||
height,
|
||||
minWidth: 1024,
|
||||
minHeight: 640,
|
||||
show: false,
|
||||
@@ -25,7 +30,10 @@ function createWindow(): BrowserWindow {
|
||||
}
|
||||
})
|
||||
|
||||
win.once('ready-to-show', () => win.show())
|
||||
win.once('ready-to-show', () => {
|
||||
if (isLinux) win.maximize()
|
||||
win.show()
|
||||
})
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import type { AuthEvent, AuthService } from '../auth/authService'
|
||||
import type { HelixClient } from '../twitch/helixClient'
|
||||
import type { PlaybackService } from '../playback/playbackService'
|
||||
@@ -6,6 +6,7 @@ import type { PlaybackEvent } from '../playback/types'
|
||||
import { getProgressMap } from '../store/historyRepo'
|
||||
|
||||
export const IPC = {
|
||||
appQuit: 'app:quit',
|
||||
authStatus: 'auth:get-status',
|
||||
authStart: 'auth:start-device-flow',
|
||||
authCancel: 'auth:cancel',
|
||||
@@ -15,6 +16,7 @@ export const IPC = {
|
||||
authEvent: 'auth:event',
|
||||
|
||||
twitchGetFollowed: 'twitch:get-followed',
|
||||
twitchGetOwnUser: 'twitch:get-own-user',
|
||||
twitchGetVideos: 'twitch:get-videos',
|
||||
twitchGetTopGames: 'twitch:get-top-games',
|
||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||
@@ -41,6 +43,10 @@ export function registerIpcHandlers(
|
||||
helix: HelixClient,
|
||||
playback: PlaybackService
|
||||
): void {
|
||||
ipcMain.handle(IPC.appQuit, () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||
@@ -56,6 +62,7 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
|
||||
ipcMain.handle(IPC.twitchGetOwnUser, () => helix.getOwnUserInfo())
|
||||
ipcMain.handle(
|
||||
IPC.twitchGetTopGames,
|
||||
(_e, cursor?: string) => helix.getTopGames(40, cursor)
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
HelixGame,
|
||||
HelixPaginatedResponse,
|
||||
HelixStream,
|
||||
OwnUserInfo,
|
||||
TopStreamsResult,
|
||||
HelixUser,
|
||||
HelixVideo,
|
||||
@@ -62,6 +63,16 @@ export class HelixClient {
|
||||
return user
|
||||
}
|
||||
|
||||
async getOwnUserInfo(): Promise<OwnUserInfo> {
|
||||
const user = await this.getOwnUser()
|
||||
return {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
displayName: user.display_name,
|
||||
profileImageUrl: user.profile_image_url
|
||||
}
|
||||
}
|
||||
|
||||
private async getOwnUserId(): Promise<string> {
|
||||
if (this.cachedUserId) return this.cachedUserId
|
||||
const user = await this.getOwnUser()
|
||||
|
||||
@@ -5,6 +5,13 @@ export interface HelixUser {
|
||||
profile_image_url: string
|
||||
}
|
||||
|
||||
export interface OwnUserInfo {
|
||||
id: string
|
||||
login: string
|
||||
displayName: string
|
||||
profileImageUrl: string
|
||||
}
|
||||
|
||||
export interface HelixFollowedChannel {
|
||||
broadcaster_id: string
|
||||
broadcaster_login: string
|
||||
|
||||
+15
-1
@@ -1,6 +1,7 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
|
||||
const IPC = {
|
||||
appQuit: 'app:quit',
|
||||
authStatus: 'auth:get-status',
|
||||
authStart: 'auth:start-device-flow',
|
||||
authCancel: 'auth:cancel',
|
||||
@@ -9,6 +10,7 @@ const IPC = {
|
||||
authEvent: 'auth:event',
|
||||
|
||||
twitchGetFollowed: 'twitch:get-followed',
|
||||
twitchGetOwnUser: 'twitch:get-own-user',
|
||||
twitchGetVideos: 'twitch:get-videos',
|
||||
twitchGetTopGames: 'twitch:get-top-games',
|
||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||
@@ -61,6 +63,13 @@ export interface FollowedChannelInfo {
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface OwnUserInfo {
|
||||
id: string
|
||||
login: string
|
||||
displayName: string
|
||||
profileImageUrl: string
|
||||
}
|
||||
|
||||
export interface PlaybackEvent {
|
||||
kind: 'started' | 'stopped' | 'error'
|
||||
channelLogin?: string
|
||||
@@ -102,7 +111,10 @@ export interface TopStreamsResult {
|
||||
}
|
||||
|
||||
const api = {
|
||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||
appVersion: process.env.APP_VERSION ?? process.env.npm_package_version ?? '0.0.0-dev',
|
||||
app: {
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit)
|
||||
},
|
||||
auth: {
|
||||
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
|
||||
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
|
||||
@@ -118,6 +130,8 @@ const api = {
|
||||
twitch: {
|
||||
getFollowed: (): Promise<FollowedChannelInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetFollowed),
|
||||
getOwnUser: (): Promise<OwnUserInfo> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetOwnUser),
|
||||
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId),
|
||||
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type GamepadPromptKey =
|
||||
| 'a'
|
||||
| 'b'
|
||||
| 'x'
|
||||
| 'y'
|
||||
| 'lb'
|
||||
| 'rb'
|
||||
| 'lt'
|
||||
| 'rt'
|
||||
| 'dpad-up'
|
||||
| 'dpad-down'
|
||||
| 'dpad-left'
|
||||
| 'dpad-right'
|
||||
|
||||
interface GamepadPromptProps {
|
||||
prompt: GamepadPromptKey
|
||||
}
|
||||
|
||||
interface GamepadHintItemProps {
|
||||
prompt: GamepadPromptKey | readonly GamepadPromptKey[]
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const DPAD_LABELS: Record<Extract<GamepadPromptKey, `dpad-${string}`>, string> = {
|
||||
'dpad-up': '↑',
|
||||
'dpad-down': '↓',
|
||||
'dpad-left': '←',
|
||||
'dpad-right': '→'
|
||||
}
|
||||
|
||||
const SHOULDER_LABELS: Record<'lb' | 'rb' | 'lt' | 'rt', string> = {
|
||||
lb: 'LB',
|
||||
rb: 'RB',
|
||||
lt: 'LT',
|
||||
rt: 'RT'
|
||||
}
|
||||
|
||||
export function GamepadPrompt({ prompt }: GamepadPromptProps): JSX.Element {
|
||||
if (prompt === 'a' || prompt === 'b' || prompt === 'x' || prompt === 'y') {
|
||||
return (
|
||||
<span className={`gamepad-prompt gamepad-prompt--face gamepad-prompt--${prompt}`}>
|
||||
{prompt.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (prompt === 'lb' || prompt === 'rb' || prompt === 'lt' || prompt === 'rt') {
|
||||
return (
|
||||
<span className={`gamepad-prompt gamepad-prompt--shoulder gamepad-prompt--${prompt}`}>
|
||||
{SHOULDER_LABELS[prompt]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="gamepad-prompt gamepad-prompt--dpad">
|
||||
{DPAD_LABELS[prompt]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function GamepadHintItem({ prompt, children }: GamepadHintItemProps): JSX.Element {
|
||||
const prompts = Array.isArray(prompt) ? prompt : [prompt]
|
||||
|
||||
return (
|
||||
<span className="gamepad-hint-item">
|
||||
<span className="gamepad-hint-item__prompts">
|
||||
{prompts.map((key) => (
|
||||
<GamepadPrompt key={key} prompt={key} />
|
||||
))}
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { UserIcon } from '../components/Icons'
|
||||
import type { OwnUserInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
@@ -9,11 +10,40 @@ interface Props {
|
||||
|
||||
export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element {
|
||||
const logoutBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const [user, setUser] = useState<OwnUserInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFocus) logoutBtnRef.current?.focus({ preventScroll: true })
|
||||
}, [hasFocus])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
void window.t4sd.twitch
|
||||
.getOwnUser()
|
||||
.then((nextUser) => {
|
||||
if (!cancelled) setUser(nextUser)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setUser(null)
|
||||
setError(err instanceof Error ? err.message : 'Accountdaten konnten nicht geladen werden.')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFocus) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
@@ -31,6 +61,8 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
|
||||
onLogout()
|
||||
}
|
||||
|
||||
const showLogin = user && user.login.toLowerCase() !== user.displayName.toLowerCase()
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<header className="screen__header">
|
||||
@@ -38,9 +70,25 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
|
||||
</header>
|
||||
<div className="account">
|
||||
<div className="account__avatar">
|
||||
{user?.profileImageUrl ? (
|
||||
<img src={user.profileImageUrl} alt="" draggable={false} />
|
||||
) : (
|
||||
<UserIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="account__identity">
|
||||
<p className="account__hint">Eingeloggt bei Twitch</p>
|
||||
{isLoading ? (
|
||||
<p className="account__name">Lade Account…</p>
|
||||
) : user ? (
|
||||
<>
|
||||
<p className="account__name">{user.displayName}</p>
|
||||
{showLogin && <p className="account__login">@{user.login}</p>}
|
||||
</>
|
||||
) : (
|
||||
<p className="account__error">{error || 'Accountdaten konnten nicht geladen werden.'}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="account__version">App-Version {window.t4sd.appVersion}</p>
|
||||
<button ref={logoutBtnRef} className="btn" onClick={handleLogout}>
|
||||
Abmelden
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
||||
import AccountScreen from './AccountScreen'
|
||||
import BrowseScreen from './BrowseScreen'
|
||||
@@ -10,6 +11,7 @@ import StreamListScreen from './StreamListScreen'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
type Region = 'sidebar' | 'main'
|
||||
type QuitChoice = 'yes' | 'no'
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void
|
||||
@@ -21,6 +23,8 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
const [sidebarIndex, setSidebarIndex] = useState(0)
|
||||
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
|
||||
const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null)
|
||||
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
|
||||
const [quitDialogChoice, setQuitDialogChoice] = useState<QuitChoice>('no')
|
||||
|
||||
const requestSidebar = useCallback(() => {
|
||||
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
||||
@@ -28,6 +32,24 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
setRegion('sidebar')
|
||||
}, [tab])
|
||||
|
||||
const openQuitDialog = useCallback(() => {
|
||||
setQuitDialogChoice('no')
|
||||
setQuitDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeQuitDialog = useCallback(() => {
|
||||
setQuitDialogChoice('no')
|
||||
setQuitDialogOpen(false)
|
||||
}, [])
|
||||
|
||||
const confirmQuitDialog = useCallback(() => {
|
||||
if (quitDialogChoice === 'yes') {
|
||||
void window.t4sd.app.quit()
|
||||
return
|
||||
}
|
||||
closeQuitDialog()
|
||||
}, [closeQuitDialog, quitDialogChoice])
|
||||
|
||||
const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => {
|
||||
setSelectedChannel(ch)
|
||||
}, [])
|
||||
@@ -44,6 +66,31 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (region !== 'sidebar') return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (quitDialogOpen) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setQuitDialogChoice('yes')
|
||||
break
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setQuitDialogChoice('no')
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
confirmQuitDialog()
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
closeQuitDialog()
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
@@ -64,11 +111,15 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
setSelectedCategory(null)
|
||||
setRegion('main')
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
openQuitDialog()
|
||||
break
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [region, sidebarIndex])
|
||||
}, [region, sidebarIndex, quitDialogOpen, openQuitDialog, closeQuitDialog, confirmQuitDialog])
|
||||
|
||||
const mainFocus = region === 'main'
|
||||
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
|
||||
@@ -133,6 +184,40 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{quitDialogOpen && (
|
||||
<div className="quit-dialog-overlay">
|
||||
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title">
|
||||
<h2 id="quit-dialog-title" className="quit-dialog__title">App beenden?</h2>
|
||||
<p className="quit-dialog__text">Möchtest du Twitch4SteamDeck wirklich schließen?</p>
|
||||
<p className="quit-dialog__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>Auswahl</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Bestätigen</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="b">Abbrechen</GamepadHintItem>
|
||||
</p>
|
||||
<div className="quit-dialog__actions">
|
||||
<button
|
||||
className={`btn quit-dialog__button${quitDialogChoice === 'yes' ? ' quit-dialog__button--focused' : ''}`}
|
||||
onClick={() => {
|
||||
setQuitDialogChoice('yes')
|
||||
void window.t4sd.app.quit()
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
className={`btn quit-dialog__button${quitDialogChoice === 'no' ? ' quit-dialog__button--focused' : ''}`}
|
||||
onClick={closeQuitDialog}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import LanguageBadge from '../components/LanguageBadge'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
@@ -220,7 +221,11 @@ export default function BrowseScreen({
|
||||
<h2 className="screen__title">Durchsuchen</h2>
|
||||
{loadState === 'ok' && (
|
||||
<div className="screen__meta">
|
||||
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span>
|
||||
<span className="screen__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
@@ -247,8 +252,13 @@ export default function BrowseScreen({
|
||||
<h3 className="browse-section__title">
|
||||
Top Live-Streams
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="browse-section__hint">
|
||||
{' '}· Enter Live · X Kanalseite · ↓ Kategorien
|
||||
<span className="browse-section__hint gamepad-hint-line">
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Live</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-down">Kategorien</GamepadHintItem>
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
@@ -287,8 +297,11 @@ export default function BrowseScreen({
|
||||
<h3 className="browse-section__title">
|
||||
Kategorien
|
||||
{focusRegion === 'grid' && (
|
||||
<span className="browse-section__hint">
|
||||
{' '}· Enter öffnen · ↑ zurück zu Streams
|
||||
<span className="browse-section__hint gamepad-hint-line">
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-up">Zurück zu Streams</GamepadHintItem>
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
|
||||
@@ -128,8 +129,12 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
<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 className="category-screen__meta gamepad-hint-line">
|
||||
<span>{streams.length} Streams</span>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
@@ -459,11 +460,29 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
|
||||
{(playState === 'playing' || playState === 'paused') && (
|
||||
<>
|
||||
<span className="channel-screen__playing-hint">
|
||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · LB Kapitel zurück · RB Kapitel vor · Y Kapitelmenü
|
||||
<span className="channel-screen__playing-hint gamepad-hint-line">
|
||||
<span>● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}</span>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Pause/Resume</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-right">+30s</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="lt">−5min</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="lb">Kapitel zurück</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="rb">Kapitel vor</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
|
||||
</span>
|
||||
<button className="btn" onClick={() => void handleStop()}>
|
||||
■ Stop (B / Escape)
|
||||
■ Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -483,7 +502,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
<h3 className="channel-screen__vods-title">
|
||||
Vergangene Streams
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="channel-screen__vods-hint"> · ↑ zurück · A abspielen · Y Kapitel</span>
|
||||
<span className="channel-screen__vods-hint gamepad-hint-line">
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-up">Zurück</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Abspielen</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="y">Kapitel</GamepadHintItem>
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
||||
@@ -560,12 +586,28 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
<span className="chapter-overlay__title">Kapitel wählen</span>
|
||||
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
|
||||
</div>
|
||||
<p className="chapter-overlay__hint">↑↓ navigieren · A öffnen · B schließen</p>
|
||||
<p className="chapter-overlay__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>Navigieren</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="b">Schließen</GamepadHintItem>
|
||||
</p>
|
||||
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
|
||||
{!chaptersLoading && chapters.length === 0 && (
|
||||
<p className="chapter-overlay__msg">
|
||||
Keine Kapitel gefunden.{' '}
|
||||
{(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'}
|
||||
{(playState === 'playing' || playState === 'paused') ? (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Fortsetzen.</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Abspielen.</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{chapters.length > 0 && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
@@ -117,7 +118,9 @@ export default function FollowingScreen({
|
||||
<div className="screen__meta">
|
||||
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>}
|
||||
<span>{channels.length} Kanäle</span>
|
||||
<span className="screen__hint">[Y] Aktualisieren</span>
|
||||
<span className="screen__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { SettingsIcon } from '../components/Icons'
|
||||
import {
|
||||
type StreamBadgeMode,
|
||||
@@ -164,7 +165,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
<h3 className="settings-section__title">Sidebar-Breite</h3>
|
||||
<p className="settings-section__hint">
|
||||
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px).
|
||||
Mit Links/Rechts verschieben, Enter setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.
|
||||
{' '}
|
||||
<span className="gamepad-hint-line">
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.</span>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -193,7 +202,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3>
|
||||
<p className="settings-section__hint">
|
||||
Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px).
|
||||
Mit Links/Rechts verschieben, Enter setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.
|
||||
{' '}
|
||||
<span className="gamepad-hint-line">
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.</span>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
@@ -161,7 +162,11 @@ export default function StreamListScreen({
|
||||
{loadState === 'ok' && streams.length > 0 && (
|
||||
<div className="screen__meta">
|
||||
<span>{streams.length} Streams</span>
|
||||
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span>
|
||||
<span className="screen__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -308,6 +308,96 @@ body,
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.gamepad-hint-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gamepad-hint-separator {
|
||||
color: var(--color-fg-dim);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.gamepad-hint-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gamepad-hint-item__prompts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gamepad-inline-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gamepad-prompt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 1.4em;
|
||||
height: 1.4em;
|
||||
padding: 0 0.45em;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
font-size: 0.72em;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08), 0 2px 6px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.gamepad-prompt--face {
|
||||
width: 1.4em;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.gamepad-prompt--a {
|
||||
background: linear-gradient(180deg, #2fc95e 0%, #1f9d45 100%);
|
||||
border-color: rgba(166, 255, 195, 0.4);
|
||||
}
|
||||
|
||||
.gamepad-prompt--b {
|
||||
background: linear-gradient(180deg, #ff6a5f 0%, #d94339 100%);
|
||||
border-color: rgba(255, 194, 189, 0.4);
|
||||
}
|
||||
|
||||
.gamepad-prompt--x {
|
||||
background: linear-gradient(180deg, #4da7ff 0%, #2f74d8 100%);
|
||||
border-color: rgba(192, 224, 255, 0.4);
|
||||
}
|
||||
|
||||
.gamepad-prompt--y {
|
||||
background: linear-gradient(180deg, #f8d14d 0%, #d6aa1f 100%);
|
||||
border-color: rgba(255, 241, 189, 0.5);
|
||||
color: #1b1b1d;
|
||||
}
|
||||
|
||||
.gamepad-prompt--shoulder {
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #2f3138 0%, #23252b 100%);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
min-width: 2.4em;
|
||||
}
|
||||
|
||||
.gamepad-prompt--dpad {
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #343741 0%, #24262d 100%);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
width: 1.45em;
|
||||
padding: 0;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.screen__state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -1233,6 +1323,13 @@ body,
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.account__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.account__avatar svg {
|
||||
@@ -1241,12 +1338,33 @@ body,
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
.account__identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.account__hint {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.account__name {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.account__login {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
.account__version {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
@@ -1254,6 +1372,14 @@ body,
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.account__error {
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
font-size: 18px;
|
||||
color: #ff6868;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Chapter Overlay (Modal) ──────────────────────────── */
|
||||
.chapter-overlay {
|
||||
position: fixed;
|
||||
@@ -1350,3 +1476,68 @@ body,
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Quit Dialog ─────────────────────────────────── */
|
||||
.quit-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 250;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.quit-dialog {
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 48px);
|
||||
padding: 32px 36px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: linear-gradient(180deg, rgba(31, 31, 35, 0.98) 0%, rgba(24, 24, 27, 0.98) 100%);
|
||||
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.quit-dialog__title {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quit-dialog__text {
|
||||
margin: 0;
|
||||
color: var(--color-fg-dim);
|
||||
font-size: 19px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.quit-dialog__hint {
|
||||
margin: 0;
|
||||
color: var(--color-fg-dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.quit-dialog__actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.quit-dialog__button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
transition: transform 0.12s, background 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.quit-dialog__button--focused {
|
||||
background: rgba(145, 71, 255, 0.22);
|
||||
border-color: var(--color-focus);
|
||||
box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.22), 0 14px 28px rgba(0, 0, 0, 0.32);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
Vendored
+11
@@ -30,6 +30,13 @@ export interface FollowedChannelInfo {
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface OwnUserInfo {
|
||||
id: string
|
||||
login: string
|
||||
displayName: string
|
||||
profileImageUrl: string
|
||||
}
|
||||
|
||||
export interface GameInfo {
|
||||
id: string
|
||||
name: string
|
||||
@@ -72,6 +79,9 @@ export interface TopStreamsResult {
|
||||
|
||||
export interface T4sdApi {
|
||||
appVersion: string
|
||||
app: {
|
||||
quit: () => Promise<void>
|
||||
}
|
||||
auth: {
|
||||
isConfigured: () => Promise<boolean>
|
||||
getStatus: () => Promise<AuthStatus>
|
||||
@@ -82,6 +92,7 @@ export interface T4sdApi {
|
||||
}
|
||||
twitch: {
|
||||
getFollowed: () => Promise<FollowedChannelInfo[]>
|
||||
getOwnUser: () => Promise<OwnUserInfo>
|
||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
|
||||
getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => Promise<TopStreamsResult>
|
||||
|
||||
Reference in New Issue
Block a user