- Twitch Helix-Client + Followed-Channels-Home-Screen
- Navigation über Sidebar
- Karten-Design überarbeitet
This commit is contained in:
Andre
2026-04-07 20:57:25 +02:00
parent 7d19ccf652
commit 1f5d1254db
18 changed files with 1294 additions and 95 deletions
+3 -1
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'node:path'
import { AuthService } from './auth/authService'
import { HelixClient } from './twitch/helixClient'
import { registerIpcHandlers } from './ipc/handlers'
const isDev = !app.isPackaged
@@ -42,7 +43,8 @@ app.whenReady().then(async () => {
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
const auth = new AuthService(clientId)
await auth.init()
registerIpcHandlers(auth)
const helix = new HelixClient(clientId, () => auth.getValidAccessToken())
registerIpcHandlers(auth, helix)
if (!clientId) {
console.warn(
+7 -2
View File
@@ -1,5 +1,6 @@
import { BrowserWindow, ipcMain } from 'electron'
import type { AuthEvent, AuthService } from '../auth/authService'
import type { HelixClient } from '../twitch/helixClient'
export const IPC = {
authStatus: 'auth:get-status',
@@ -8,10 +9,12 @@ export const IPC = {
authLogout: 'auth:logout',
authConfigured: 'auth:is-configured',
/** main → renderer */
authEvent: 'auth:event'
authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed'
} as const
export function registerIpcHandlers(auth: AuthService): void {
export function registerIpcHandlers(auth: AuthService, helix: HelixClient): void {
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
@@ -25,4 +28,6 @@ export function registerIpcHandlers(auth: AuthService): void {
win.webContents.send(IPC.authEvent, event)
}
})
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
}
+136
View File
@@ -0,0 +1,136 @@
import type {
FollowedChannelInfo,
HelixFollowedChannel,
HelixPaginatedResponse,
HelixStream,
HelixUser
} from './types'
const BASE = 'https://api.twitch.tv/helix'
export class HelixClient {
private cachedUserId: string | null = null
constructor(
private readonly clientId: string,
private readonly getToken: () => Promise<string>
) {}
private async authHeaders(): Promise<Record<string, string>> {
const token = await this.getToken()
return {
'Client-Id': this.clientId,
Authorization: `Bearer ${token}`
}
}
private async get<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${BASE}${path}`)
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
}
const res = await fetch(url.toString(), { headers: await this.authHeaders() })
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Helix ${path} → ${res.status}: ${body}`)
}
return res.json() as Promise<T>
}
async getOwnUser(): Promise<HelixUser> {
const data = await this.get<{ data: HelixUser[] }>('/users')
const user = data.data[0]
if (!user) throw new Error('Keine Nutzer-Daten von /users erhalten')
this.cachedUserId = user.id
return user
}
private async getOwnUserId(): Promise<string> {
if (this.cachedUserId) return this.cachedUserId
const user = await this.getOwnUser()
return user.id
}
private async getAllFollowed(userId: string): Promise<HelixFollowedChannel[]> {
const all: HelixFollowedChannel[] = []
let cursor: string | undefined
do {
const params: Record<string, string> = { user_id: userId, first: '100' }
if (cursor) params.after = cursor
const page = await this.get<HelixPaginatedResponse<HelixFollowedChannel>>(
'/channels/followed',
params
)
all.push(...page.data)
cursor = page.pagination?.cursor
} while (cursor && all.length < 500)
return all
}
private async getLiveStreams(userIds: string[]): Promise<Map<string, HelixStream>> {
const map = new Map<string, HelixStream>()
for (let i = 0; i < userIds.length; i += 100) {
const chunk = userIds.slice(i, i + 100)
const url = new URL(`${BASE}/streams`)
for (const id of chunk) url.searchParams.append('user_id', id)
url.searchParams.set('first', '100')
const res = await fetch(url.toString(), { headers: await this.authHeaders() })
if (!res.ok) continue
const data = (await res.json()) as { data: HelixStream[] }
for (const stream of data.data) map.set(stream.user_id, stream)
}
return map
}
private async getProfileImages(userIds: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>()
for (let i = 0; i < userIds.length; i += 100) {
const chunk = userIds.slice(i, i + 100)
const url = new URL(`${BASE}/users`)
for (const id of chunk) url.searchParams.append('id', id)
const res = await fetch(url.toString(), { headers: await this.authHeaders() })
if (!res.ok) continue
const data = (await res.json()) as { data: HelixUser[] }
for (const user of data.data) map.set(user.id, user.profile_image_url)
}
return map
}
async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> {
const userId = await this.getOwnUserId()
const followed = await this.getAllFollowed(userId)
if (followed.length === 0) return []
const ids = followed.map((f) => f.broadcaster_id)
const [liveMap, avatarMap] = await Promise.all([
this.getLiveStreams(ids),
this.getProfileImages(ids)
])
const channels: FollowedChannelInfo[] = followed.map((f) => {
const stream = liveMap.get(f.broadcaster_id)
return {
broadcasterId: f.broadcaster_id,
broadcasterLogin: f.broadcaster_login,
broadcasterName: f.broadcaster_name,
profileImageUrl: avatarMap.get(f.broadcaster_id) ?? '',
isLive: !!stream,
streamTitle: stream?.title,
gameName: stream?.game_name,
viewerCount: stream?.viewer_count,
thumbnailUrl: stream
? stream.thumbnail_url
.replace('{width}', '440')
.replace('{height}', '248')
: undefined,
startedAt: stream?.started_at
}
})
// Live-Kanäle zuerst, dann alphabetisch
return channels.sort((a, b) => {
if (a.isLive !== b.isLive) return a.isLive ? -1 : 1
return a.broadcasterName.localeCompare(b.broadcasterName)
})
}
}
+44
View File
@@ -0,0 +1,44 @@
export interface HelixUser {
id: string
login: string
display_name: string
profile_image_url: string
}
export interface HelixFollowedChannel {
broadcaster_id: string
broadcaster_login: string
broadcaster_name: string
followed_at: string
}
export interface HelixStream {
user_id: string
user_login: string
user_name: string
game_name: string
title: string
viewer_count: number
thumbnail_url: string
started_at: string
}
export interface HelixPaginatedResponse<T> {
data: T[]
pagination?: { cursor?: string }
}
/** Merged type that the renderer receives. */
export interface FollowedChannelInfo {
broadcasterId: string
broadcasterLogin: string
broadcasterName: string
profileImageUrl: string
isLive: boolean
streamTitle?: string
gameName?: string
viewerCount?: number
/** 440×248 thumbnail URL (only set when live) */
thumbnailUrl?: string
startedAt?: string
}