diff --git a/electron/main.ts b/electron/main.ts index c477c2b..9cadaa2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -196,6 +196,49 @@ ipcMain.on('overlay-resize', (event, w, h) => { const CONFIG_PATH = path.join(app.getPath('userData'), 'bili_bot_config.json'); +type LyricsTextAlign = 'left' | 'center' | 'right'; + +interface LyricsWidgetConfig { + Alignment: LyricsTextAlign; + ShowSongInfo: boolean; + ShowTranslation: boolean; + MainColor: string; + TranslationColor: string; + OutlineEnabled: boolean; + OutlineColor: string; + OutlineSize: number; + ShadowEnabled: boolean; + ShadowSize: number; + FontFamily: string; + MainFontSize: number; + TranslationFontSize: number; +} + +const DEFAULT_LYRICS_WIDGET_CONFIG: LyricsWidgetConfig = { + Alignment: 'center', + ShowSongInfo: false, + ShowTranslation: true, + MainColor: '#ffffff', + TranslationColor: '#d1d5db', + OutlineEnabled: true, + OutlineColor: '#000000', + OutlineSize: 2, + ShadowEnabled: true, + ShadowSize: 24, + FontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + MainFontSize: 56, + TranslationFontSize: 30 +}; + +const FALLBACK_FONT_FAMILIES = [ + 'system-ui', + 'sans-serif', + 'serif', + 'monospace' +]; + +let cachedFontFamilies: string[] | null = null; + let appConfig: any = { roomId: 0, myRoomId: 0, @@ -211,6 +254,7 @@ let appConfig: any = { IdleWaitNext: true, ShowAllDanmaku: false, SuperUsers: [], + LyricsWidget: DEFAULT_LYRICS_WIDGET_CONFIG, NcmExePath: "" } }; @@ -222,10 +266,11 @@ function loadConfig() { appConfig = { ...appConfig, ...saved }; if (!appConfig.sysConfig) { - appConfig.sysConfig = { PlayerType: 'NCM', FoliaToken: '', EnableCDP: true, CdpPort: 9222, Cooldowns: { Normal: 0, Captain: 0, Admiral: 0, Governor: 0 }, IdleWaitNext: true, ShowAllDanmaku: false, SuperUsers: appConfig.superUsers || [], NcmExePath: "" }; + appConfig.sysConfig = { PlayerType: 'NCM', FoliaToken: '', EnableCDP: true, CdpPort: 9222, Cooldowns: { Normal: 0, Captain: 0, Admiral: 0, Governor: 0 }, IdleWaitNext: true, ShowAllDanmaku: false, SuperUsers: appConfig.superUsers || [], LyricsWidget: DEFAULT_LYRICS_WIDGET_CONFIG, NcmExePath: "" }; } if (!appConfig.sysConfig.PlayerType) appConfig.sysConfig.PlayerType = appConfig.sysConfig.EnableCDP === false ? 'None' : 'NCM'; if (appConfig.sysConfig.FoliaToken === undefined) appConfig.sysConfig.FoliaToken = ''; + appConfig.sysConfig.LyricsWidget = readLyricsWidgetConfig(appConfig.sysConfig.LyricsWidget); if (appConfig.sysConfig.CooldownMinutes !== undefined && !appConfig.sysConfig.Cooldowns) { const oldSecs = appConfig.sysConfig.CooldownMinutes * 60; @@ -269,6 +314,376 @@ let lastQueueActionTime = 0; // 全局队列操作防抖冷却时间 const userCooldowns = new Map(); let recentRejects: { id: number, user: any, reason: string }[] = []; +interface CurrentLyricLine { + index: number; + time: number; + text: string; + translation: string; +} + +interface CurrentLyricsPayload { + trackId: string; + songName: string; + artistName: string; + playedTime: number | null; + duration: number | null; + progress: number; + lines: CurrentLyricLine[]; + current: CurrentLyricLine | null; + previous: CurrentLyricLine | null; + next: CurrentLyricLine | null; + hasLyrics: boolean; + isLoading: boolean; + isPlaying: boolean; + updatedAt: number; +} + +interface ParsedLyricsLine { + time: number; + text: string; +} + +interface TrackLyrics { + trackId: string; + lines: ParsedLyricsLine[]; + translatedLines: ParsedLyricsLine[]; + fetchedAt: number; +} + +let currentLyrics: CurrentLyricsPayload | null = null; +let latestLyricsSnapshot: CurrentLyricsPayload | null = null; +const fetchedTrackLyrics = new Map(); +const fetchingTrackLyrics = new Map>(); +const failedTrackLyrics = new Map(); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function readFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function readLyricLine(value: unknown): CurrentLyricLine | null { + if (!isRecord(value)) return null; + + const index = readFiniteNumber(value.index); + const time = readFiniteNumber(value.time); + if (index === null || time === null) return null; + + return { + index, + time, + text: readString(value.text), + translation: readString(value.translation) + }; +} + +function readLyricsPayload(value: unknown): CurrentLyricsPayload | null { + if (!isRecord(value)) return null; + + const progress = readFiniteNumber(value.progress); + const updatedAt = readFiniteNumber(value.updatedAt); + if (progress === null || updatedAt === null) return null; + + return { + trackId: readString(value.trackId), + songName: readString(value.songName), + artistName: readString(value.artistName), + playedTime: readFiniteNumber(value.playedTime), + duration: readFiniteNumber(value.duration), + progress: Math.max(0, Math.min(1, progress)), + lines: [], + current: readLyricLine(value.current), + previous: readLyricLine(value.previous), + next: readLyricLine(value.next), + hasLyrics: value.hasLyrics === true, + isLoading: value.isLoading === true, + isPlaying: value.isPlaying === true, + updatedAt + }; +} + +function readBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function readColor(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + return /^#[0-9a-fA-F]{6}$/.test(value) ? value : fallback; +} + +function readBoundedNumber(value: unknown, fallback: number, min: number, max: number): number { + const parsed = readFiniteNumber(value); + if (parsed === null) return fallback; + return Math.max(min, Math.min(max, Math.round(parsed))); +} + +function readLyricsAlignment(value: unknown): LyricsTextAlign { + return value === 'left' || value === 'right' || value === 'center' ? value : DEFAULT_LYRICS_WIDGET_CONFIG.Alignment; +} + +function readLyricsWidgetConfig(value: unknown): LyricsWidgetConfig { + if (!isRecord(value)) return DEFAULT_LYRICS_WIDGET_CONFIG; + + return { + Alignment: readLyricsAlignment(value.Alignment), + ShowSongInfo: readBoolean(value.ShowSongInfo, DEFAULT_LYRICS_WIDGET_CONFIG.ShowSongInfo), + ShowTranslation: readBoolean(value.ShowTranslation, DEFAULT_LYRICS_WIDGET_CONFIG.ShowTranslation), + MainColor: readColor(value.MainColor, DEFAULT_LYRICS_WIDGET_CONFIG.MainColor), + TranslationColor: readColor(value.TranslationColor, DEFAULT_LYRICS_WIDGET_CONFIG.TranslationColor), + OutlineEnabled: readBoolean(value.OutlineEnabled, DEFAULT_LYRICS_WIDGET_CONFIG.OutlineEnabled), + OutlineColor: readColor(value.OutlineColor, DEFAULT_LYRICS_WIDGET_CONFIG.OutlineColor), + OutlineSize: readBoundedNumber(value.OutlineSize, DEFAULT_LYRICS_WIDGET_CONFIG.OutlineSize, 0, 8), + ShadowEnabled: readBoolean(value.ShadowEnabled, DEFAULT_LYRICS_WIDGET_CONFIG.ShadowEnabled), + ShadowSize: readBoundedNumber(value.ShadowSize, DEFAULT_LYRICS_WIDGET_CONFIG.ShadowSize, 0, 80), + FontFamily: readString(value.FontFamily) || DEFAULT_LYRICS_WIDGET_CONFIG.FontFamily, + MainFontSize: readBoundedNumber(value.MainFontSize, DEFAULT_LYRICS_WIDGET_CONFIG.MainFontSize, 24, 96), + TranslationFontSize: readBoundedNumber(value.TranslationFontSize, DEFAULT_LYRICS_WIDGET_CONFIG.TranslationFontSize, 14, 64) + }; +} + +function getLyricsWidgetConfig(): LyricsWidgetConfig { + const config = readLyricsWidgetConfig(appConfig.sysConfig?.LyricsWidget); + if (!appConfig.sysConfig) appConfig.sysConfig = {}; + appConfig.sysConfig.LyricsWidget = config; + return config; +} + +function normalizeFontFamilyName(value: string): string { + return value.trim().replace(/^["']|["']$/g, ''); +} + +function parseFontFamilies(output: string): string[] { + const names = new Set(FALLBACK_FONT_FAMILIES); + for (const line of output.split(/\r?\n/)) { + for (const item of line.split(',')) { + const name = normalizeFontFamilyName(item); + if (name && !name.startsWith('.')) names.add(name); + } + } + return [...names].sort((left, right) => left.localeCompare(right, 'zh-Hans')); +} + +async function getSystemFontFamilies(): Promise { + if (cachedFontFamilies) return cachedFontFamilies; + + try { + const { stdout } = await execAsync('fc-list --format="%{family}\\n"', { timeout: 3000, maxBuffer: 1024 * 1024 }); + cachedFontFamilies = parseFontFamilies(stdout); + } catch { + cachedFontFamilies = [...FALLBACK_FONT_FAMILIES]; + } + return cachedFontFamilies; +} + +function parseLrcTimestamp(tag: string): number | null { + const match = tag.match(/^\[(\d+)[:.'](\d+)(?:[:.'](\d+))?\]$/); + if (!match) return null; + + const minutes = Number.parseInt(match[1], 10); + const seconds = Number.parseInt(match[2], 10); + if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null; + + let time = minutes * 60 + seconds; + const frac = match[3]; + if (frac !== undefined) { + const value = Number.parseInt(frac, 10); + if (!Number.isFinite(value)) return null; + time += frac.length <= 2 ? value / 100 : value / 1000; + } + return time; +} + +function parseLrcLines(lrc: string): ParsedLyricsLine[] { + const entries: ParsedLyricsLine[] = []; + const tagPattern = /\[([^\]]*)\]/g; + + for (const raw of lrc.split('\n')) { + const line = raw.trim(); + if (!line) continue; + + const times: number[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + tagPattern.lastIndex = 0; + + while ((match = tagPattern.exec(line)) !== null && match.index === lastIndex) { + const time = parseLrcTimestamp(match[0]); + if (time !== null) times.push(time); + lastIndex = tagPattern.lastIndex; + } + + const text = line.slice(lastIndex).trim(); + if (times.length === 0 || !text) continue; + for (const time of times) entries.push({ time, text }); + } + + return entries.sort((a, b) => a.time - b.time); +} + +function readLyricText(value: unknown, key: string): string { + if (!isRecord(value)) return ''; + const section = value[key]; + if (!isRecord(section)) return ''; + return readString(section.lyric); +} + +async function fetchTrackLyrics(trackId: string): Promise { + const url = `https://music.163.com/api/song/lyric?os=pc&id=${encodeURIComponent(trackId)}&lv=-1&kv=-1&tv=-1`; + const res = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0', + 'Referer': 'https://music.163.com/', + 'Cookie': 'os=pc; appver=2.9.8;', + ...getChinaBypassHeaders() + } + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const body: unknown = await res.json(); + return { + trackId, + lines: parseLrcLines(readLyricText(body, 'lrc')), + translatedLines: parseLrcLines(readLyricText(body, 'tlyric')), + fetchedAt: Date.now() + }; +} + +function findCurrentLyricIndex(lines: ParsedLyricsLine[], playedTime: number | null): number { + if (playedTime === null) return -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (playedTime >= lines[i].time) return i; + } + return -1; +} + +function findTranslation(translatedLines: ParsedLyricsLine[], time: number): string { + let closest: ParsedLyricsLine | null = null; + let closestDelta = Number.POSITIVE_INFINITY; + + for (const line of translatedLines) { + const delta = Math.abs(line.time - time); + if (delta < closestDelta) { + closest = line; + closestDelta = delta; + } + } + + return closest && closestDelta <= 0.5 ? closest.text : ''; +} + +function buildLyricLine(trackLyrics: TrackLyrics, index: number): CurrentLyricLine | null { + const line = trackLyrics.lines[index]; + if (!line) return null; + + return { + index, + time: line.time, + text: line.text, + translation: findTranslation(trackLyrics.translatedLines, line.time) + }; +} + +function buildLyricsLines(trackLyrics: TrackLyrics): CurrentLyricLine[] { + const lines: CurrentLyricLine[] = []; + for (let index = 0; index < trackLyrics.lines.length; index++) { + const line = buildLyricLine(trackLyrics, index); + if (line) lines.push(line); + } + return lines; +} + +function composeCurrentLyrics(snapshot: CurrentLyricsPayload, trackLyrics: TrackLyrics): CurrentLyricsPayload { + const currentIndex = findCurrentLyricIndex(trackLyrics.lines, snapshot.playedTime); + const current = currentIndex >= 0 ? buildLyricLine(trackLyrics, currentIndex) : null; + const previous = currentIndex > 0 ? buildLyricLine(trackLyrics, currentIndex - 1) : null; + const next = currentIndex >= 0 && currentIndex + 1 < trackLyrics.lines.length ? buildLyricLine(trackLyrics, currentIndex + 1) : null; + const progress = current && next && snapshot.playedTime !== null && next.time > current.time + ? Math.max(0, Math.min(1, (snapshot.playedTime - current.time) / (next.time - current.time))) + : 0; + + return { + ...snapshot, + progress, + lines: buildLyricsLines(trackLyrics), + current, + previous, + next, + hasLyrics: trackLyrics.lines.length > 0, + isLoading: false, + updatedAt: Date.now() + }; +} + +function setLyricsLoading(snapshot: CurrentLyricsPayload): void { + const failedReason = failedTrackLyrics.get(snapshot.trackId); + currentLyrics = { + ...snapshot, + progress: 0, + lines: [], + current: null, + previous: null, + next: null, + hasLyrics: false, + isLoading: !failedReason, + updatedAt: Date.now() + }; +} + +function refreshCurrentLyricsFromCache(): void { + if (!latestLyricsSnapshot) return; + + const trackLyrics = fetchedTrackLyrics.get(latestLyricsSnapshot.trackId); + if (trackLyrics) { + currentLyrics = composeCurrentLyrics(latestLyricsSnapshot, trackLyrics); + return; + } + setLyricsLoading(latestLyricsSnapshot); +} + +function ensureTrackLyrics(trackId: string): void { + if (!/^\d+$/.test(trackId)) return; + if (fetchedTrackLyrics.has(trackId) || fetchingTrackLyrics.has(trackId) || failedTrackLyrics.has(trackId)) return; + + const task = fetchTrackLyrics(trackId) + .then((lyrics) => { + fetchedTrackLyrics.set(trackId, lyrics); + if (latestLyricsSnapshot?.trackId === trackId) refreshCurrentLyricsFromCache(); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + failedTrackLyrics.set(trackId, message); + writeLog(`⚠️ [歌词] 获取网易云歌词失败: ${trackId} (${message})`, 'Yellow'); + if (latestLyricsSnapshot?.trackId === trackId) refreshCurrentLyricsFromCache(); + }) + .finally(() => { + fetchingTrackLyrics.delete(trackId); + }); + + fetchingTrackLyrics.set(trackId, task); +} + +function updateCurrentLyrics(snapshot: CurrentLyricsPayload | null): void { + latestLyricsSnapshot = snapshot; + if (!snapshot || !snapshot.trackId) { + currentLyrics = snapshot; + return; + } + + ensureTrackLyrics(snapshot.trackId); + refreshCurrentLyricsFromCache(); +} + async function addReject(user: any, reason: string) { const avatarUrl = user.avatar || await getBiliAvatar(user.uid); const rejectItem = { id: Date.now() + Math.random(), user: { ...user, avatar: avatarUrl }, reason }; @@ -963,17 +1378,20 @@ async function startCDPRadar() { const radarScript = FiberStoreExtractJs + ` ;(function initRadar() { + const lyricsRadarVersion = 3; if (typeof window.__ncmRadarCallback !== 'function') { setTimeout(initRadar, 500); return; } window.__debug_store_log = []; if (!_ensureStore()) { try { window.__ncmRadarCallback(JSON.stringify({ event: 'RADAR_INIT_RETRYING', reason: 'not_ready', debugLog: window.__debug_store_log.join(' | ') })); } catch {} setTimeout(initRadar, 1500); return; } - if (window.__radarDeployed && window.__radarSubscribeAlive) { + if (window.__radarDeployed && window.__radarSubscribeAlive && window.__lyricsRadarVersion === lyricsRadarVersion && typeof window.__emitLyricsSnapshot === 'function') { + window.__emitLyricsSnapshot(); window.__ncmRadarCallback(JSON.stringify({ event: 'RADAR_ALREADY_DEPLOYED' })); return; } window.__radarDeployed = true; window.__radarSubscribeAlive = true; + window.__lyricsRadarVersion = lyricsRadarVersion; const extractSongInfo = (id, list) => { if (!id) return null; @@ -982,10 +1400,132 @@ async function startCDPRadar() { return { id: String(song.id), name: song.track?.name || '未知歌曲', artist: song.track?.artists?.map(a => a.name).join('/') || '未知歌手' }; }; + const toFiniteNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + }; + + const getLineText = (line) => { + if (!line) return ''; + return typeof line.lyric === 'string' ? line.lyric : ''; + }; + + const getArtistName = (playing) => { + const artists = playing.curTrack?.artists || playing.resourceArtists || []; + if (Array.isArray(artists) && artists.length > 0) { + return artists.map(item => item?.name).filter(Boolean).join('/'); + } + return ''; + }; + + const normalizeLyricLine = (line, translationLine, index) => { + if (!line) return null; + const time = toFiniteNumber(line.time); + return { index, time: time ?? 0, text: getLineText(line), translation: getLineText(translationLine) }; + }; + + const getPlayedTime = (playId) => new Promise((resolve) => { + if (!window.channel || typeof window.channel.call !== 'function') { + resolve(null); + return; + } + + let finished = false; + const finish = (value) => { + if (finished) return; + finished = true; + resolve(value || null); + }; + + try { + window.channel.call('audioplayer.getPlayedTime', finish, playId ? [{ playId }] : []); + setTimeout(() => finish(null), 700); + } catch { + finish(null); + } + }); + + let lyricsInFlight = false; + let lastLyricsSignature = ''; + const emitLyricsSnapshot = async () => { + if (lyricsInFlight) return; + lyricsInFlight = true; + + try { + const snapshotState = window._reduxStore.getState(); + const playing = snapshotState.playing || {}; + const lyricState = snapshotState['async:lyric'] || {}; + const lines = Array.isArray(lyricState.lyricLines) ? lyricState.lyricLines : []; + const translatedLines = Array.isArray(lyricState.tlyricLines) ? lyricState.tlyricLines : []; + const trackId = String(playing.resourceTrackId || playing.onlineResourceId || playing.playId || ''); + const playId = playing.playId || trackId; + const playedResult = await getPlayedTime(playId); + const playedTime = toFiniteNumber(playedResult && (playedResult.playedTime ?? playedResult.playedAudioTime)); + const adjustedTime = playedTime === null ? null : playedTime + (toFiniteNumber(lyricState.offset) || 0); + + let currentIndex = -1; + if (adjustedTime !== null) { + for (let i = lines.length - 1; i >= 0; i--) { + const lineTime = toFiniteNumber(lines[i]?.time); + if (lineTime !== null && adjustedTime >= lineTime) { + currentIndex = i; + break; + } + } + } + + if (currentIndex < 0) { + const fallbackIndex = toFiniteNumber(playing.playingLyricLineNumber); + if (fallbackIndex !== null && fallbackIndex >= 0 && fallbackIndex < lines.length) { + currentIndex = Math.floor(fallbackIndex); + } + } + + const current = currentIndex >= 0 ? normalizeLyricLine(lines[currentIndex], translatedLines[currentIndex], currentIndex) : null; + const previous = currentIndex > 0 ? normalizeLyricLine(lines[currentIndex - 1], translatedLines[currentIndex - 1], currentIndex - 1) : null; + const next = currentIndex >= 0 && currentIndex + 1 < lines.length ? normalizeLyricLine(lines[currentIndex + 1], translatedLines[currentIndex + 1], currentIndex + 1) : null; + const progress = current && next && adjustedTime !== null && next.time > current.time + ? Math.max(0, Math.min(1, (adjustedTime - current.time) / (next.time - current.time))) + : 0; + const rawDuration = toFiniteNumber(playing.curTrack?.duration) || toFiniteNumber(playing.resourceDuration); + const duration = rawDuration === null ? null : rawDuration > 10000 ? rawDuration / 1000 : rawDuration; + + const lyrics = { + trackId, + songName: playing.curTrack?.name || playing.resourceName || '', + artistName: getArtistName(playing), + playedTime, + duration, + progress, + lines: [], + current, + previous, + next, + hasLyrics: lines.length > 0, + isLoading: lines.length === 0 && Boolean(trackId), + isPlaying: playing.playingState === 2, + updatedAt: Date.now() + }; + + const timeBucket = playedTime === null ? 'x' : String(Math.floor(playedTime * 2) / 2); + const signature = [trackId, currentIndex, current?.text || '', timeBucket, lines.length].join('|'); + if (signature === lastLyricsSignature) return; + lastLyricsSignature = signature; + try { window.__ncmRadarCallback(JSON.stringify({ event: 'LYRICS_UPDATE', lyrics })); } catch {} + } catch { + } finally { + lyricsInFlight = false; + } + }; + let state = window._reduxStore.getState(); let localLastId = state.playing?.resourceTrackId || state.playing?.onlineResourceId; + window.__emitLyricsSnapshot = emitLyricsSnapshot; + if (window.__lyricsRadarTimer) clearInterval(window.__lyricsRadarTimer); + window.__lyricsRadarTimer = setInterval(() => { window.__emitLyricsSnapshot(); }, 700); window.__ncmRadarCallback(JSON.stringify({ event: 'RADAR_INIT_OK', currentId: localLastId ? String(localLastId) : null })); + window.__emitLyricsSnapshot(); window._reduxStore.subscribe(() => { try { @@ -1005,6 +1545,7 @@ async function startCDPRadar() { try { window.__ncmRadarCallback(JSON.stringify({ event: 'TRACK_CHANGED', timestamp: Date.now(), previous: prevSong, current: currSong, next: nextSong })); } catch {} localLastId = currentTrackId; } + window.__emitLyricsSnapshot(); } catch { } }); })(); @@ -1039,6 +1580,9 @@ async function startCDPRadar() { lastTrackId = null; } } + else if (payload.event === 'LYRICS_UPDATE') { + updateCurrentLyrics(readLyricsPayload(payload.lyrics)); + } } } catch (e: any) { writeLog(`❌ CDP 解析异常: ${e.message}`, 'Red'); } }, @@ -1403,18 +1947,33 @@ function startBackendServer() { return; } + if (url.pathname === '/api/lyrics') { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ lyrics: currentLyrics, cdpConnected: isCdpConnected, config: getLyricsWidgetConfig(), serverTime: Date.now() })); + return; + } + + if (url.pathname === '/api/system/fonts') { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ fonts: await getSystemFontFamilies() })); + return; + } + if (url.pathname === '/api/config') { if (req.method === 'POST') { const body = JSON.parse(await readRequestBody(req)); if (body.roomId !== undefined) appConfig.roomId = body.roomId; if (body.widgetStyle !== undefined) appConfig.widgetStyle = body.widgetStyle; - if (body.sysConfig !== undefined) appConfig.sysConfig = { ...appConfig.sysConfig, ...body.sysConfig }; + if (body.sysConfig !== undefined) { + appConfig.sysConfig = { ...appConfig.sysConfig, ...body.sysConfig }; + appConfig.sysConfig.LyricsWidget = readLyricsWidgetConfig(appConfig.sysConfig.LyricsWidget); + } saveConfig(); res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ success: true })); return; } res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify({ roomId: appConfig.roomId, myRoomId: appConfig.myRoomId || 0, biliLogin: !!biliCookie, uid: biliUid, currentUser: currentUserInfo, version: app.getVersion(), accepting: isAccepting, playing: isPlaying, widgetStyle: appConfig.widgetStyle, cdpConnected: isCdpConnected, config: appConfig.sysConfig || { EnableCDP: true, CdpPort: 9222, ShowAllDanmaku: false, IdleWaitNext: true, SuperUsers: [], Cooldowns: { Normal: 0, Captain: 0, Admiral: 0, Governor: 0 } } })); + res.end(JSON.stringify({ roomId: appConfig.roomId, myRoomId: appConfig.myRoomId || 0, biliLogin: !!biliCookie, uid: biliUid, currentUser: currentUserInfo, version: app.getVersion(), accepting: isAccepting, playing: isPlaying, widgetStyle: appConfig.widgetStyle, cdpConnected: isCdpConnected, config: appConfig.sysConfig || { EnableCDP: true, CdpPort: 9222, ShowAllDanmaku: false, IdleWaitNext: true, SuperUsers: [], Cooldowns: { Normal: 0, Captain: 0, Admiral: 0, Governor: 0 }, LyricsWidget: DEFAULT_LYRICS_WIDGET_CONFIG } })); return; } @@ -1598,4 +2157,4 @@ app.whenReady().then(() => { if (appConfig.roomId) connectToLiveRoom(appConfig.roomId); }); -app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); \ No newline at end of file +app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); diff --git a/src/App.tsx b/src/App.tsx index 965a185..aa7c438 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -78,6 +78,317 @@ interface QrState { message: string; } +interface LyricLine { + readonly index: number; + readonly time: number; + readonly text: string; + readonly translation: string; +} + +interface LyricsPayload { + readonly trackId: string; + readonly songName: string; + readonly artistName: string; + readonly playedTime: number | null; + readonly duration: number | null; + readonly progress: number; + readonly lines: LyricLine[]; + readonly current: LyricLine | null; + readonly previous: LyricLine | null; + readonly next: LyricLine | null; + readonly hasLyrics: boolean; + readonly isLoading: boolean; + readonly isPlaying: boolean; + readonly updatedAt: number; +} + +interface LyricsWidgetSettings { + readonly Alignment: 'left' | 'center' | 'right'; + readonly ShowSongInfo: boolean; + readonly ShowTranslation: boolean; + readonly MainColor: string; + readonly TranslationColor: string; + readonly OutlineEnabled: boolean; + readonly OutlineColor: string; + readonly OutlineSize: number; + readonly ShadowEnabled: boolean; + readonly ShadowSize: number; + readonly FontFamily: string; + readonly MainFontSize: number; + readonly TranslationFontSize: number; +} + +interface LyricsApiResponse { + readonly lyrics: LyricsPayload | null; + readonly cdpConnected: boolean; + readonly config: LyricsWidgetSettings; +} + +interface LyricsDisplayOptions { + readonly alignment: 'left' | 'center' | 'right'; + readonly showSongInfo: boolean; + readonly showTranslation: boolean; + readonly textColor: string; + readonly translationColor: string; + readonly outlineEnabled: boolean; + readonly outlineColor: string; + readonly outlineSize: number; + readonly shadowEnabled: boolean; + readonly shadowSize: number; + readonly fontFamily: string; + readonly mainFontSize: number; + readonly translationFontSize: number; +} + +interface SystemFontsResponse { + readonly fonts: readonly string[]; +} + +interface AdminConfigState { + readonly config?: Record; + readonly [key: string]: unknown; +} + +type LyricsToggleSettingKey = 'ShowSongInfo' | 'ShowTranslation' | 'OutlineEnabled' | 'ShadowEnabled'; +type LyricsColorSettingKey = 'MainColor' | 'TranslationColor' | 'OutlineColor'; + +const defaultLyricsWidgetSettings: LyricsWidgetSettings = { + Alignment: 'center', + ShowSongInfo: false, + ShowTranslation: true, + MainColor: '#ffffff', + TranslationColor: '#d1d5db', + OutlineEnabled: true, + OutlineColor: '#000000', + OutlineSize: 2, + ShadowEnabled: true, + ShadowSize: 24, + FontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + MainFontSize: 56, + TranslationFontSize: 30 +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function readFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function readBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function readColor(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + return /^#[0-9a-fA-F]{6}$/.test(value) ? value : fallback; +} + +function readBoundedNumber(value: unknown, fallback: number, min: number, max: number): number { + const parsed = readFiniteNumber(value); + if (parsed === null) return fallback; + return Math.max(min, Math.min(max, Math.round(parsed))); +} + +function readLyricsAlignment(value: unknown): LyricsWidgetSettings['Alignment'] { + return value === 'left' || value === 'right' || value === 'center' ? value : defaultLyricsWidgetSettings.Alignment; +} + +function readLyricLine(value: unknown): LyricLine | null { + if (!isRecord(value)) return null; + + const index = readFiniteNumber(value.index); + const time = readFiniteNumber(value.time); + if (index === null || time === null) return null; + + return { + index, + time, + text: readString(value.text), + translation: readString(value.translation) + }; +} + +function readLyricLines(value: unknown): LyricLine[] { + if (!Array.isArray(value)) return []; + + const lines: LyricLine[] = []; + for (const item of value) { + const line = readLyricLine(item); + if (line) lines.push(line); + } + return lines; +} + +function readLyricsWidgetSettings(value: unknown): LyricsWidgetSettings { + if (!isRecord(value)) return defaultLyricsWidgetSettings; + + return { + Alignment: readLyricsAlignment(value.Alignment), + ShowSongInfo: readBoolean(value.ShowSongInfo, defaultLyricsWidgetSettings.ShowSongInfo), + ShowTranslation: readBoolean(value.ShowTranslation, defaultLyricsWidgetSettings.ShowTranslation), + MainColor: readColor(value.MainColor, defaultLyricsWidgetSettings.MainColor), + TranslationColor: readColor(value.TranslationColor, defaultLyricsWidgetSettings.TranslationColor), + OutlineEnabled: readBoolean(value.OutlineEnabled, defaultLyricsWidgetSettings.OutlineEnabled), + OutlineColor: readColor(value.OutlineColor, defaultLyricsWidgetSettings.OutlineColor), + OutlineSize: readBoundedNumber(value.OutlineSize, defaultLyricsWidgetSettings.OutlineSize, 0, 8), + ShadowEnabled: readBoolean(value.ShadowEnabled, defaultLyricsWidgetSettings.ShadowEnabled), + ShadowSize: readBoundedNumber(value.ShadowSize, defaultLyricsWidgetSettings.ShadowSize, 0, 80), + FontFamily: readString(value.FontFamily) || defaultLyricsWidgetSettings.FontFamily, + MainFontSize: readBoundedNumber(value.MainFontSize, defaultLyricsWidgetSettings.MainFontSize, 24, 96), + TranslationFontSize: readBoundedNumber(value.TranslationFontSize, defaultLyricsWidgetSettings.TranslationFontSize, 14, 64) + }; +} + +function toLyricsDisplayOptions(settings: LyricsWidgetSettings): LyricsDisplayOptions { + return { + alignment: settings.Alignment, + showSongInfo: settings.ShowSongInfo, + showTranslation: settings.ShowTranslation, + textColor: settings.MainColor, + translationColor: settings.TranslationColor, + outlineEnabled: settings.OutlineEnabled, + outlineColor: settings.OutlineColor, + outlineSize: settings.OutlineSize, + shadowEnabled: settings.ShadowEnabled, + shadowSize: settings.ShadowSize, + fontFamily: settings.FontFamily, + mainFontSize: settings.MainFontSize, + translationFontSize: settings.TranslationFontSize + }; +} + +function readLyricsPayload(value: unknown): LyricsPayload | null { + if (!isRecord(value)) return null; + + const progress = readFiniteNumber(value.progress); + const updatedAt = readFiniteNumber(value.updatedAt); + if (progress === null || updatedAt === null) return null; + + return { + trackId: readString(value.trackId), + songName: readString(value.songName), + artistName: readString(value.artistName), + playedTime: readFiniteNumber(value.playedTime), + duration: readFiniteNumber(value.duration), + progress: Math.max(0, Math.min(1, progress)), + lines: readLyricLines(value.lines), + current: readLyricLine(value.current), + previous: readLyricLine(value.previous), + next: readLyricLine(value.next), + hasLyrics: value.hasLyrics === true, + isLoading: value.isLoading === true, + isPlaying: value.isPlaying === true, + updatedAt + }; +} + +function readLyricsApiResponse(value: unknown): LyricsApiResponse { + if (!isRecord(value)) return { lyrics: null, cdpConnected: false, config: defaultLyricsWidgetSettings }; + + return { + lyrics: readLyricsPayload(value.lyrics), + cdpConnected: value.cdpConnected === true, + config: readLyricsWidgetSettings(value.config) + }; +} + +function readStringList(value: unknown): readonly string[] { + if (!Array.isArray(value)) return []; + return value.filter(item => typeof item === 'string' && item.trim().length > 0); +} + +function readSystemFontsResponse(value: unknown): SystemFontsResponse { + if (!isRecord(value)) return { fonts: [] }; + return { fonts: readStringList(value.fonts) }; +} + +function estimatePlayedTime(lyrics: LyricsPayload, now: number): number | null { + if (lyrics.playedTime === null) return null; + if (!lyrics.isPlaying) return lyrics.playedTime; + + const elapsedSeconds = Math.max(0, (now - lyrics.updatedAt) / 1000); + const estimated = lyrics.playedTime + elapsedSeconds; + return lyrics.duration === null ? estimated : Math.min(lyrics.duration, estimated); +} + +function findLyricLineIndex(lines: LyricLine[], playedTime: number | null): number { + if (playedTime === null) return -1; + for (let index = lines.length - 1; index >= 0; index--) { + if (playedTime >= lines[index].time) return index; + } + return -1; +} + +function resolveLocalLyrics(lyrics: LyricsPayload | null, now: number): LyricsPayload | null { + if (!lyrics || lyrics.lines.length === 0) return lyrics; + + const playedTime = estimatePlayedTime(lyrics, now); + const index = findLyricLineIndex(lyrics.lines, playedTime); + if (index < 0) return { ...lyrics, playedTime, current: null, previous: null, next: lyrics.lines[0] || null }; + + const current = lyrics.lines[index] || null; + const previous = index > 0 ? lyrics.lines[index - 1] || null : null; + const next = index + 1 < lyrics.lines.length ? lyrics.lines[index + 1] || null : null; + const progress = current && next && playedTime !== null && next.time > current.time + ? Math.max(0, Math.min(1, (playedTime - current.time) / (next.time - current.time))) + : 0; + + return { ...lyrics, playedTime, progress, current, previous, next }; +} + +function getLyricsProbeDelay(lyrics: LyricsPayload | null): number { + if (!lyrics || lyrics.isLoading || !lyrics.hasLyrics) return 250; + + const playedTime = estimatePlayedTime(lyrics, Date.now()); + if (playedTime !== null && lyrics.duration !== null && lyrics.duration - playedTime < 2) return 200; + if (!lyrics.isPlaying) return 1200; + + const localLyrics = resolveLocalLyrics(lyrics, Date.now()); + if (localLyrics?.next && playedTime !== null) { + const nextLineDelay = (localLyrics.next.time - playedTime) * 1000; + if (nextLineDelay < 300) return 120; + if (nextLineDelay < 1500) return 250; + return Math.min(1400, Math.max(500, nextLineDelay - 300)); + } + + return 900; +} + +function buildLyricsTextShadow(options: LyricsDisplayOptions): string | undefined { + if (!options.shadowEnabled || options.shadowSize <= 0) return undefined; + + const offset = Math.max(1, Math.round(options.shadowSize / 4)); + return `0 ${offset}px ${options.shadowSize}px rgba(0, 0, 0, 0.6)`; +} + +function getLyricsLinePlaybackProgress(lyrics: LyricsPayload | null): number { + if (!lyrics?.current || lyrics.playedTime === null) return 0; + + const endTime = lyrics.next?.time ?? lyrics.duration; + if (endTime === null || endTime <= lyrics.current.time) return lyrics.progress; + + return Math.max(0, Math.min(1, (lyrics.playedTime - lyrics.current.time) / (endTime - lyrics.current.time))); +} + +function getLyricsScrollProgress(progress: number): number { + const scrollDurationRatio = 0.8; + if (progress >= scrollDurationRatio) return 1; + return Math.max(0, progress / scrollDurationRatio); +} + +const LYRICS_SCROLL_CLIP_BLEED_PX = 128; + // ========================================== // 2. 全局样式 // ========================================== @@ -932,8 +1243,126 @@ interface AdminWidgetProps { onClose: () => void; } +interface LyricsTextProps { + readonly text: string; + readonly className: string; + readonly style: React.CSSProperties; + readonly color: string; + readonly textShadow: string | undefined; + readonly outlineEnabled: boolean; + readonly outlineColor: string; + readonly outlineSize: number; + readonly scrollProgress?: number; +} + +const LyricsText: React.FC = ({ + text, + className, + style, + color, + textShadow, + outlineEnabled, + outlineColor, + outlineSize, + scrollProgress +}) => { + const containerRef = useRef(null); + const contentRef = useRef(null); + const [overflowWidth, setOverflowWidth] = useState(0); + const canScroll = scrollProgress !== undefined; + + useEffect(() => { + if (!canScroll) { + setOverflowWidth(0); + return; + } + + const measure = () => { + const container = containerRef.current; + const content = contentRef.current; + if (!container || !content) return; + const computedStyle = window.getComputedStyle(container); + const horizontalPadding = parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight); + const contentViewportWidth = Math.max(0, container.clientWidth - horizontalPadding); + setOverflowWidth(Math.max(0, content.scrollWidth - contentViewportWidth)); + }; + + measure(); + window.addEventListener('resize', measure); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(measure); + if (containerRef.current) resizeObserver.observe(containerRef.current); + if (contentRef.current) resizeObserver.observe(contentRef.current); + } + + return () => { + window.removeEventListener('resize', measure); + resizeObserver?.disconnect(); + }; + }, [canScroll, style.fontSize, text]); + + const baseStrokeStyle: React.CSSProperties = { + color: 'transparent', + WebkitTextFillColor: 'transparent', + WebkitTextStroke: `${outlineSize}px ${outlineColor}` + }; + const strokeStyle: React.CSSProperties = { + ...baseStrokeStyle, + boxSizing: 'border-box', + padding: 'inherit' + }; + const scrollOffset = canScroll ? overflowWidth * getLyricsScrollProgress(scrollProgress) : 0; + const scrollContentStyle: React.CSSProperties = { + transform: scrollOffset > 0 ? `translateX(${-scrollOffset}px)` : undefined, + transition: 'transform 80ms linear', + willChange: overflowWidth > 0 ? 'transform' : undefined + }; + + if (canScroll) { + return ( +
0 ? 'left' : undefined, + clipPath: overflowWidth > 0 ? `inset(-${LYRICS_SCROLL_CLIP_BLEED_PX}px 0 -${LYRICS_SCROLL_CLIP_BLEED_PX}px 0)` : undefined + }} + > + + {outlineEnabled && outlineSize > 0 && ( + + )} + + {text} + + +
+ ); + } + + return ( +
+ {outlineEnabled && outlineSize > 0 && ( + + )} + + {text} + +
+ ); +}; + const AdminWidget: React.FC = ({ onClose: _onClose }) => { const [config, setConfig] = useState(null); + const [systemFonts, setSystemFonts] = useState([]); const [activeTab, setActiveTab] = useState('settings'); const [updateInfo, setUpdateInfo] = useState({ checking: false, info: null }); @@ -965,6 +1394,11 @@ const AdminWidget: React.FC = ({ onClose: _onClose }) => { setTimeout(() => setAdminToast(''), 3000); }; + const copyPanelLink = (url: string) => { + void navigator.clipboard.writeText(url); + showAdminToast("✅ 链接复制成功!"); + }; + const activeTabRef = useRef(activeTab); useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]); @@ -1011,6 +1445,23 @@ const AdminWidget: React.FC = ({ onClose: _onClose }) => { return () => clearInterval(timer); }, []); + useEffect(() => { + let disposed = false; + const fetchFonts = async () => { + try { + const res = await fetch('http://localhost:5555/api/system/fonts'); + const body: unknown = await res.json(); + if (!disposed) setSystemFonts(readSystemFontsResponse(body).fonts); + } catch { + if (!disposed) setSystemFonts([]); + } + }; + fetchFonts(); + return () => { + disposed = true; + }; + }, []); + useEffect(() => { if (!config || !config.config) return; @@ -1247,6 +1698,56 @@ const AdminWidget: React.FC = ({ onClose: _onClose }) => { })); }; + const lyricsWidgetSettings = config?.config ? readLyricsWidgetSettings(config.config.LyricsWidget) : defaultLyricsWidgetSettings; + const fontOptions = [ + ...(systemFonts.includes(lyricsWidgetSettings.FontFamily) ? [] : [lyricsWidgetSettings.FontFamily]), + ...systemFonts + ]; + const updateLyricsWidgetSetting = (key: K, value: LyricsWidgetSettings[K]) => { + setConfig((prev: AdminConfigState | null) => { + if (!prev?.config) return prev; + const current = readLyricsWidgetSettings(prev.config.LyricsWidget); + return { + ...prev, + config: { + ...prev.config, + LyricsWidget: { + ...current, + [key]: value + } + } + }; + }); + }; + + const renderLyricsToggle = (key: LyricsToggleSettingKey, label: string) => { + const enabled = lyricsWidgetSettings[key]; + return ( + + ); + }; + + const renderLyricsColorInput = (key: LyricsColorSettingKey, label: string) => ( + + ); + const addSuperUser = () => { if(!superUserInput.trim()) return; const currentSu = config.config.SuperUsers || []; @@ -1382,12 +1883,22 @@ const AdminWidget: React.FC = ({ onClose: _onClose }) => {

运行状态

-
-
+
+
OBS 捕捉地址 / 局域网访问
-
http://localhost:5555/
+
http://localhost:5555/
- +
+ +
+
+
桌面歌词地址
+
http://localhost:5555/lyrics
+
+
@@ -1776,6 +2287,104 @@ const AdminWidget: React.FC = ({ onClose: _onClose }) => {
+
+

歌词窗口样式

+ +
+ {renderLyricsToggle('ShowSongInfo', '显示歌名')} + {renderLyricsToggle('ShowTranslation', '显示翻译')} + {renderLyricsToggle('OutlineEnabled', '文字描边')} + {renderLyricsToggle('ShadowEnabled', '投影阴影')} +
+ +
+
+ + +
+ +
+ + updateLyricsWidgetSetting('MainFontSize', readBoundedNumber(e.target.value, defaultLyricsWidgetSettings.MainFontSize, 24, 96))} + className="w-full bg-black/30 border border-white/10 rounded-lg p-2.5 text-sm text-white outline-none" + /> +
+ +
+ + updateLyricsWidgetSetting('TranslationFontSize', readBoundedNumber(e.target.value, defaultLyricsWidgetSettings.TranslationFontSize, 14, 64))} + className="w-full bg-black/30 border border-white/10 rounded-lg p-2.5 text-sm text-white outline-none" + /> +
+
+ +
+ + +
+ +
+
+ + updateLyricsWidgetSetting('OutlineSize', readBoundedNumber(e.target.value, defaultLyricsWidgetSettings.OutlineSize, 0, 8))} + className="w-full bg-black/30 border border-white/10 rounded-lg p-2.5 text-sm text-white outline-none" + /> +
+ +
+ + updateLyricsWidgetSetting('ShadowSize', readBoundedNumber(e.target.value, defaultLyricsWidgetSettings.ShadowSize, 0, 80))} + className="w-full bg-black/30 border border-white/10 rounded-lg p-2.5 text-sm text-white outline-none" + /> +
+
+ +
+ {renderLyricsColorInput('MainColor', '主歌词颜色')} + {renderLyricsColorInput('TranslationColor', '翻译颜色')} + {renderLyricsColorInput('OutlineColor', '描边颜色')} +
+
+

⏱️ 点歌冷却设置 (秒)

@@ -1989,9 +2598,159 @@ const AdminWidget: React.FC = ({ onClose: _onClose }) => { ); }; +const LyricsWidget: React.FC = () => { + const [lyrics, setLyrics] = useState(null); + const [settings, setSettings] = useState(defaultLyricsWidgetSettings); + const [cdpConnected, setCdpConnected] = useState(false); + const [requestFailed, setRequestFailed] = useState(false); + const [clockNow, setClockNow] = useState(() => Date.now()); + const options = toLyricsDisplayOptions(settings); + const displayedLyrics = resolveLocalLyrics(lyrics, clockNow); + const textShadow = buildLyricsTextShadow(options); + + useEffect(() => { + let disposed = false; + let timer: number | null = null; + + const refreshLyrics = async () => { + try { + const res = await fetch('http://localhost:5555/api/lyrics'); + const body: unknown = await res.json(); + const parsed = readLyricsApiResponse(body); + if (disposed) return; + setLyrics(parsed.lyrics); + setSettings(parsed.config); + setCdpConnected(parsed.cdpConnected); + setRequestFailed(false); + timer = window.setTimeout(refreshLyrics, getLyricsProbeDelay(parsed.lyrics)); + } catch { + if (disposed) return; + setLyrics(null); + setCdpConnected(false); + setRequestFailed(true); + timer = window.setTimeout(refreshLyrics, 300); + } + }; + + refreshLyrics(); + return () => { + disposed = true; + if (timer !== null) window.clearTimeout(timer); + }; + }, []); + + useEffect(() => { + const timer = window.setInterval(() => setClockNow(Date.now()), 80); + return () => window.clearInterval(timer); + }, []); + + const currentLine = displayedLyrics?.current; + const translation = options.showTranslation ? currentLine?.translation : ''; + const statusText = requestFailed + ? '歌词服务未连接' + : !cdpConnected + ? '等待网易云连接' + : displayedLyrics?.isLoading + ? '歌词加载中' + : displayedLyrics && !displayedLyrics.hasLyrics + ? '暂无歌词' + : '等待播放'; + const mainText = currentLine?.text || statusText; + const songLabel = displayedLyrics?.songName + ? `${displayedLyrics.songName}${displayedLyrics.artistName ? ` - ${displayedLyrics.artistName}` : ''}` + : ''; + const alignItems = options.alignment === 'center' ? 'center' : options.alignment === 'right' ? 'flex-end' : 'flex-start'; + const textShadowOffset = options.shadowEnabled && options.shadowSize > 0 ? Math.max(1, Math.round(options.shadowSize / 4)) : 0; + const textBleedX = Math.max(options.outlineEnabled ? options.outlineSize : 0, options.shadowEnabled ? options.shadowSize : 0); + const textBleedY = Math.max(options.outlineEnabled ? options.outlineSize : 0, options.shadowEnabled ? options.shadowSize + textShadowOffset : 0); + const songLabelStyle: React.CSSProperties = textBleedX > 0 || textBleedY > 0 + ? { + margin: `${-textBleedY}px ${-textBleedX}px`, + padding: `${textBleedY}px ${textBleedX}px` + } + : {}; + const scrollingLineBleedX = textBleedX > 0 ? textBleedX + 16 : 0; + const scrollingLineBleedStyle: React.CSSProperties = textBleedX > 0 || textBleedY > 0 + ? { + margin: `${-textBleedY}px 0`, + padding: `${textBleedY}px ${scrollingLineBleedX}px` + } + : {}; + const lineScrollProgress = getLyricsLinePlaybackProgress(displayedLyrics); + const mainTextStyle: React.CSSProperties = { + ...scrollingLineBleedStyle, + fontSize: `clamp(24px, 8vw, ${options.mainFontSize}px)` + }; + const translationStyle: React.CSSProperties = { + ...scrollingLineBleedStyle, + fontSize: `clamp(18px, 5vw, ${options.translationFontSize}px)` + }; + + return ( + <> + +
+
+ {options.showSongInfo && songLabel && ( + + )} + + + + {translation && ( + + )} +
+
+ + ); +}; + const App: React.FC = () => { const params = new URLSearchParams(window.location.search); const isAdmin = params.get('admin') === 'true'; + const isLyrics = window.location.pathname === '/lyrics'; + + if (isLyrics) { + return ; + } if (isAdmin) { return ( @@ -2014,4 +2773,4 @@ const App: React.FC = () => { ); }; -export default App; \ No newline at end of file +export default App;