From d1ea7d9f6a8b8894635f1513cefd6aaa88d161a2 Mon Sep 17 00:00:00 2001 From: wintbit Date: Thu, 14 May 2026 14:11:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20iframe=20=E5=BA=94=E7=94=A8=20URL=20=E5=92=8C?= =?UTF-8?q?=E4=BA=92=E5=8A=A8=E8=81=8A=E5=A4=A9=E5=AE=A4=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++- README.md | 13 ++++---- src/components/live/LivePlayer.vue | 25 ++++++++++++++-- src/iframe-inject.ts | 11 +++++-- src/main.ts | 48 +++++++++++++++++++++++++++++- vite.config.ts | 2 ++ 6 files changed, 92 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 79e05b1..7b7b832 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ VITE_LIVEJSON_PROXY= VITE_IMG_PROXY= +VITE_IFRAME_APP_URL=https://rmlive.scutbot.cn VITE_CHATROOM_APP_ID= VITE_CHATROOM_APP_KEY= +VITE_ENGAGEMENT_CHATROOM_ID= # Only include engagement messages from the last N minutes (default 30) -VITE_ENGAGEMENT_QUERY_WINDOW_MINUTES=30 \ No newline at end of file +VITE_ENGAGEMENT_QUERY_WINDOW_MINUTES=30 diff --git a/README.md b/README.md index 255b636..338eb85 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,16 @@ Learn more about the recommended Project Setup and IDE Support in the [Vue Docs ## Environment -- `VITE_STATIC_PROXY`: optional static proxy base. Example: `https://schedule.scutbot.cn/static` +- `VITE_LIVEJSON_PROXY`: optional live-json proxy base. Example: `https://schedule.scutbot.cn/static` +- `VITE_IMG_PROXY`: optional image proxy base. +- `VITE_IFRAME_APP_URL`: app URL embedded by `pnpm build:iframe`. Defaults to `https://rmlive.scutbot.cn`. -When `VITE_STATIC_PROXY` is set, app requests are forwarded as: +When `VITE_LIVEJSON_PROXY` is set, app requests are forwarded as: - live json: `https://schedule.scutbot.cn/static/https://rm-static.djicdn.com/live_json/*.json` -- team logos/images: `https://schedule.scutbot.cn/static/` -The app does not append `/static` automatically. Please provide the full proxy base in `VITE_STATIC_PROXY`. +When `VITE_IMG_PROXY` is set, app requests are forwarded as: + +- team logos/images: `https://schedule.scutbot.cn/static/` -Live json requests automatically append a timestamp query string to avoid proxy cache. +The app does not append `/static` automatically. Please provide the full proxy base. diff --git a/src/components/live/LivePlayer.vue b/src/components/live/LivePlayer.vue index 53c9b8e..eea0fa6 100644 --- a/src/components/live/LivePlayer.vue +++ b/src/components/live/LivePlayer.vue @@ -422,6 +422,22 @@ function updateQualityControl() { } } +function applyMobileInlineVideoAttrs() { + if (!uiStore.isMobile) { + return; + } + + const video = container.value?.querySelector('video'); + if (!video) { + return; + } + + video.setAttribute('playsinline', 'true'); + video.setAttribute('webkit-playsinline', 'true'); + video.setAttribute('x5-playsinline', 'true'); + video.setAttribute('x5-video-player-type', 'h5-page'); +} + async function mountPlayer(url: string) { if (!container.value) { return; @@ -471,8 +487,8 @@ async function mountPlayer(url: string) { subtitleOffset: false, hotkey: true, pip: !uiStore.isMobile, - fullscreen: true, - fullscreenWeb: !uiStore.isMobile, + fullscreen: !uiStore.isMobile, + fullscreenWeb: true, ...(qualityItems.length > 1 ? { quality: qualityItems } : {}), airplay: true, gesture: true, @@ -482,6 +498,9 @@ async function mountPlayer(url: string) { playsInline: true, autoOrientation: true, lock: true, + moreVideoAttr: { + playsInline: true, + }, settings: danmuEnabledAtLoad ? [ { @@ -524,11 +543,13 @@ async function mountPlayer(url: string) { }; player = new Artplayer(playerOptions); + applyMobileInlineVideoAttrs(); danmukuPlugin = danmuEnabledAtLoad ? (player as any).plugins?.artplayerPluginDanmuku : null; // Some browsers still require an explicit play attempt after source mount. player.on('ready', () => { playerReady = true; + applyMobileInlineVideoAttrs(); danmukuPlugin = danmuEnabledAtLoad ? (player as any).plugins?.artplayerPluginDanmuku : null; updateQualityControl(); flushPendingDanmu(); diff --git a/src/iframe-inject.ts b/src/iframe-inject.ts index 77fc942..17d9ddc 100644 --- a/src/iframe-inject.ts +++ b/src/iframe-inject.ts @@ -1,6 +1,14 @@ import { userInfoRequestEvent, userInfoResponseEvent } from './constants/userInfoEvents'; import { UserInfo } from './types/user'; +declare const __RMLIVE_IFRAME_APP_URL__: string | undefined; + +const fallbackAppUrl = 'https://rmlive.scutbot.cn'; +const configuredAppUrl = + typeof __RMLIVE_IFRAME_APP_URL__ === 'string' && __RMLIVE_IFRAME_APP_URL__.trim() + ? __RMLIVE_IFRAME_APP_URL__.trim() + : fallbackAppUrl; + const pageContent = document.querySelector('.page-content'); const mountPoint = pageContent ?? document.body; @@ -38,8 +46,7 @@ if (existingIframe) { } const iframe = document.createElement('iframe') as HTMLIFrameElement; -// iframe.src = 'https://rmlive.scutbot.cn'; -iframe.src = `http://localhost:5173`; +iframe.src = configuredAppUrl; iframe.id = 'rm-live-iframe'; iframe.allowFullscreen = true; iframe.allow = 'autoplay; fullscreen; picture-in-picture; notifications; permissions; periodic-sync'; diff --git a/src/main.ts b/src/main.ts index 3c8f2d3..e699678 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,11 +9,57 @@ import { registerSW } from 'virtual:pwa-register'; import { createApp } from 'vue'; import App from './App.vue'; +function isFullscreenLikeActive() { + const doc = document as Document & { + webkitFullscreenElement?: Element | null; + mozFullScreenElement?: Element | null; + msFullscreenElement?: Element | null; + }; + + return Boolean( + doc.fullscreenElement || + doc.webkitFullscreenElement || + doc.mozFullScreenElement || + doc.msFullscreenElement || + document.querySelector('.art-fullscreen-web'), + ); +} + +function reloadWhenFullscreenIsIdle() { + if (!isFullscreenLikeActive()) { + window.location.reload(); + return; + } + + const reload = () => { + cleanup(); + window.location.reload(); + }; + const reloadAfterFullscreenExit = () => { + if (!isFullscreenLikeActive()) { + reload(); + } + }; + const cleanup = () => { + document.removeEventListener('fullscreenchange', reloadAfterFullscreenExit); + document.removeEventListener('webkitfullscreenchange', reloadAfterFullscreenExit); + document.removeEventListener('mozfullscreenchange', reloadAfterFullscreenExit); + document.removeEventListener('MSFullscreenChange', reloadAfterFullscreenExit); + window.removeEventListener('pagehide', reload); + }; + + document.addEventListener('fullscreenchange', reloadAfterFullscreenExit); + document.addEventListener('webkitfullscreenchange', reloadAfterFullscreenExit); + document.addEventListener('mozfullscreenchange', reloadAfterFullscreenExit); + document.addEventListener('MSFullscreenChange', reloadAfterFullscreenExit); + window.addEventListener('pagehide', reload, { once: true }); +} + const updateServiceWorker = registerSW({ immediate: true, onNeedRefresh() { void updateServiceWorker(true).then(() => { - window.location.reload(); + reloadWhenFullscreenIsIdle(); }); }, }); diff --git a/vite.config.ts b/vite.config.ts index d87e8fa..24ac72a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig(({ mode }) => { const isAnalyze = mode === 'analyze'; const isBuildIFrame = process.env.VITE_BUILD_IFRAME === 'true' || mode === 'iframe'; const useRmLiveJsonMock = process.env.VITE_RM_MOCK === '1'; + const iframeAppUrl = process.env.VITE_IFRAME_APP_URL ?? 'https://rmlive.scutbot.cn'; if (isBuildIFrame) { // Build iframe-inject.js as a standalone IIFE @@ -18,6 +19,7 @@ export default defineConfig(({ mode }) => { plugins: [], define: { 'process.env.NODE_ENV': JSON.stringify('production'), + __RMLIVE_IFRAME_APP_URL__: JSON.stringify(iframeAppUrl), }, resolve: { alias: { From 2b9b87b1695bdfe410c159b8ea0639418aa21130 Mon Sep 17 00:00:00 2001 From: wintbit Date: Thu, 14 May 2026 14:59:56 +0800 Subject: [PATCH 2/2] feat: integrate stream perspective controls --- index.html | 10 +-- public/rmlive-share.svg | 2 +- src/api/rmApi.ts | 51 +++++++++++- src/components/header/TopToolbar.vue | 2 +- src/components/layout/LiveStage.vue | 19 +++++ src/components/live/LivePlayer.vue | 120 +++++++++++++++++++++++---- src/stores/rmData.ts | 23 ++++- src/types/api.ts | 7 ++ src/utils/rmStreamView.ts | 14 ++++ src/workers/rmData.worker.ts | 68 ++++++++++++++- src/workers/rmDataProtocol.ts | 6 +- 11 files changed, 291 insertions(+), 31 deletions(-) diff --git a/index.html b/index.html index 01f9609..e743a80 100644 --- a/index.html +++ b/index.html @@ -8,21 +8,21 @@ - + - + - + - + - RMLive - Better 直播间 + RMLive - 不一样的直播间
diff --git a/public/rmlive-share.svg b/public/rmlive-share.svg index cab5554..8dfdec0 100644 --- a/public/rmlive-share.svg +++ b/public/rmlive-share.svg @@ -18,7 +18,7 @@ - RMLive - Better 直播间 + RMLive - 不一样的直播间 更清晰的赛事视图,更顺滑的直播体验 diff --git a/src/api/rmApi.ts b/src/api/rmApi.ts index 1c675f7..8f03fa3 100644 --- a/src/api/rmApi.ts +++ b/src/api/rmApi.ts @@ -166,6 +166,13 @@ export interface LiveQualityOption { src: string; } +export interface LivePerspectiveOption { + key: string; + label: string; + headimg: string | null; + qualities: LiveQualityOption[]; +} + export interface LiveZoneOption { zoneId: string; zoneName: string; @@ -175,6 +182,7 @@ export interface LiveZoneOption { endAt: number | null; zoneDates: string[]; qualities: LiveQualityOption[]; + perspectives: LivePerspectiveOption[]; } function toStartAt(value: unknown): number | null { @@ -265,6 +273,40 @@ export function extractLiveZones(data: LiveGameInfo | null): LiveZoneOption[] { const qualities = source .map((item, qualityIndex) => toQualityOption(item, qualityIndex)) .filter((item): item is LiveQualityOption => item !== null); + const perspectives: LivePerspectiveOption[] = [ + { + key: 'main', + label: '主视角', + headimg: null, + qualities, + }, + ]; + + if (Array.isArray(zone.fpvData)) { + zone.fpvData.forEach((item, perspectiveIndex) => { + const perspectiveQualities = Array.isArray(item.sources) + ? item.sources + .map((sourceItem, qualityIndex) => toQualityOption(sourceItem, qualityIndex)) + .filter((quality): quality is LiveQualityOption => quality !== null) + : []; + if (!perspectiveQualities.length) { + return; + } + + const label = + typeof item.role === 'string' && item.role.trim() + ? item.role.trim() + : `第一视角 ${perspectiveIndex + 1}`; + const headimg = typeof item.headimg === 'string' && item.headimg.trim() ? item.headimg.trim() : null; + + perspectives.push({ + key: `fpv-${perspectiveIndex}`, + label, + headimg, + qualities: perspectiveQualities, + }); + }); + } return { zoneId, @@ -275,6 +317,7 @@ export function extractLiveZones(data: LiveGameInfo | null): LiveZoneOption[] { endAt: endAt ?? dateEndAt, zoneDates, qualities, + perspectives, }; }) .sort((a, b) => { @@ -323,6 +366,7 @@ export function resolveLiveStreamUrl( data: LiveGameInfo | null, zoneId: string | null, qualityRes: string | null, + perspectiveKey = 'main', ): string | null { const zones = extractLiveZones(data); if (!zones.length) { @@ -334,7 +378,10 @@ export function resolveLiveStreamUrl( const z = normalizeZoneId(zoneId); const selectedZone = zones.find((item) => normalizeZoneId(item.zoneId) === z) ?? zones[0]; - const selectedQuality = selectedZone.qualities.find((item) => item.res === qualityRes); + const selectedPerspective = + selectedZone.perspectives.find((item) => item.key === perspectiveKey) ?? selectedZone.perspectives[0]; + const qualities = selectedPerspective?.qualities ?? selectedZone.qualities; + const selectedQuality = qualities.find((item) => item.res === qualityRes); - return selectedQuality?.src ?? selectedZone.qualities[0]?.src ?? null; + return selectedQuality?.src ?? qualities[0]?.src ?? null; } diff --git a/src/components/header/TopToolbar.vue b/src/components/header/TopToolbar.vue index 9baa430..7ded3da 100644 --- a/src/components/header/TopToolbar.vue +++ b/src/components/header/TopToolbar.vue @@ -73,7 +73,7 @@ const settingsVisible = ref(false);

- RMLive - Better 直播流 + RMLive - 不一样的直播间 {{ scheduleEventTitle }}

更清晰的赛事视图,更顺滑的直播体验

diff --git a/src/components/layout/LiveStage.vue b/src/components/layout/LiveStage.vue index 6cfeab7..f5935d0 100644 --- a/src/components/layout/LiveStage.vue +++ b/src/components/layout/LiveStage.vue @@ -17,7 +17,9 @@ const { streamLoading, liveGameInfo, effectiveStreamErrorMessage, + playerPerspectiveOptions, playerQualityOptions, + selectedPerspectiveKey, selectedQualityRes, selectedZoneChatRoomId, runningMatchForSelectedZone, @@ -42,6 +44,14 @@ function onRetry() { void dataStore.retryLiveStream(); } +function onPerspectiveChange(value: string) { + dataStore.selectPerspective(value); +} + +function onQualityChange(value: string) { + dataStore.selectQuality(value); +} + function onDanmu(msg: DanmuMessage) { emit('danmu', msg); } @@ -62,10 +72,14 @@ function onDanmuReset() { :stream-url="effectiveStreamUrl" :loading="streamLoading" :error-message="effectiveStreamErrorMessage" + :perspective-options="playerPerspectiveOptions" + :selected-perspective-key="selectedPerspectiveKey" :quality-options="playerQualityOptions" :selected-quality-res="selectedQualityRes" :chat-room-id="selectedZoneChatRoomId" @retry="onRetry" + @perspective-change="onPerspectiveChange" + @quality-change="onQualityChange" @danmu="onDanmu" @danmu-reset="onDanmuReset" /> @@ -90,10 +104,14 @@ function onDanmuReset() { :stream-url="effectiveStreamUrl" :loading="streamLoading" :error-message="effectiveStreamErrorMessage" + :perspective-options="playerPerspectiveOptions" + :selected-perspective-key="selectedPerspectiveKey" :quality-options="playerQualityOptions" :selected-quality-res="selectedQualityRes" :chat-room-id="selectedZoneChatRoomId" @retry="onRetry" + @perspective-change="onPerspectiveChange" + @quality-change="onQualityChange" @danmu="onDanmu" @danmu-reset="onDanmuReset" /> @@ -153,4 +171,5 @@ function onDanmuReset() { height: 13rem; } } + diff --git a/src/components/live/LivePlayer.vue b/src/components/live/LivePlayer.vue index 72b701d..36e87bc 100644 --- a/src/components/live/LivePlayer.vue +++ b/src/components/live/LivePlayer.vue @@ -25,10 +25,17 @@ interface QualityOption { src: string; } +interface PerspectiveOption { + label: string; + value: string; +} + interface Props { streamUrl: string | null; loading: boolean; errorMessage: string; + perspectiveOptions?: PerspectiveOption[]; + selectedPerspectiveKey?: string | null; qualityOptions?: QualityOption[]; selectedQualityRes?: string | null; chatRoomId?: string | null; @@ -43,6 +50,8 @@ const danmuEnabledAtLoad = Boolean(uiStore.danmuEnabled); const emit = defineEmits<{ retry: []; + perspectiveChange: [perspectiveKey: string]; + qualityChange: [qualityRes: string]; danmu: [msg: DanmuMessage]; danmuReset: []; }>(); @@ -490,10 +499,77 @@ function buildQualityItems() { .map((item) => ({ html: item.label, url: item.src, + value: item.value, default: item.value === props.selectedQualityRes, })); } +function buildPlayerSettings() { + const settings: NonNullable = []; + + const perspectiveOptions = props.perspectiveOptions ?? []; + if (perspectiveOptions.length > 1) { + const selectedPerspective = + perspectiveOptions.find((item) => item.value === props.selectedPerspectiveKey) ?? perspectiveOptions[0]; + settings.push({ + name: 'perspective', + html: '视角', + tooltip: selectedPerspective?.label ?? '主视角', + icon: '', + selector: perspectiveOptions.map((item) => ({ + html: item.label, + value: item.value, + default: item.value === selectedPerspective?.value, + })), + onSelect(item) { + const value = typeof item.value === 'string' ? item.value : ''; + if (value) { + emit('perspectiveChange', value); + } + return item.html; + }, + }); + } + + if (danmuEnabledAtLoad) { + settings.push({ + html: filterActive.value ? `过滤 ${activeFilterCount.value}` : '过滤', + tooltip: filterSummary.value, + name: 'danmu-filter', + icon: '', + style: { + color: filterActive.value ? '#ffd04b' : '#fff', + }, + onClick() { + filterDialogVisible.value = true; + }, + }); + } + + return settings; +} + +function updatePerspectiveSetting() { + if (!player || !playerReady) { + return; + } + + const perspectiveSetting = buildPlayerSettings().find((item) => item.name === 'perspective'); + const p = player as Artplayer & { + setting?: { update?: (option: NonNullable[number]) => void; remove?: (name: string) => void }; + }; + + try { + if (perspectiveSetting) { + p.setting?.update?.(perspectiveSetting); + } else { + p.setting?.remove?.('perspective'); + } + } catch { + // Ignore menu refresh races while Artplayer is mounting. + } +} + function updateQualityControl() { if (!player || !playerReady) { return; @@ -511,6 +587,23 @@ function updateQualityControl() { } } +function patchNativeQualityChange() { + const currentPlayer = player as (Artplayer & { switchQuality?: (url: string) => Promise }) | null; + if (!currentPlayer?.switchQuality) { + return; + } + + const originalSwitchQuality = currentPlayer.switchQuality.bind(currentPlayer); + currentPlayer.switchQuality = async (url: string) => { + const matched = (props.qualityOptions ?? []).find((item) => item.src === url); + await originalSwitchQuality(url); + currentAppliedStreamUrl = url; + if (matched?.value) { + emit('qualityChange', matched.value); + } + }; +} + function applyMobileInlineVideoAttrs() { if (!uiStore.isMobile) { return; @@ -553,6 +646,7 @@ async function mountPlayer(url: string) { const artplayerPluginChromecast = chromecastModule?.default; const qualityItems = buildQualityItems(); + const playerSettings = buildPlayerSettings(); const plugins: any[] = []; if (danmuEnabledAtLoad && artplayerPluginDanmuku) { @@ -608,22 +702,7 @@ async function mountPlayer(url: string) { moreVideoAttr: { playsInline: true, }, - settings: danmuEnabledAtLoad - ? [ - { - html: filterActive.value ? `过滤 ${activeFilterCount.value}` : '过滤', - tooltip: filterSummary.value, - name: 'danmu-filter', - icon: '', - style: { - color: filterActive.value ? '#ffd04b' : '#fff', - }, - onClick() { - filterDialogVisible.value = true; - }, - }, - ] - : [], + settings: playerSettings, customType: { m3u8(video: HTMLVideoElement, m3u8Url: string) { destroyAttachedHls(); @@ -726,6 +805,7 @@ async function mountPlayer(url: string) { player = new ArtplayerCtor(playerOptions); currentAppliedStreamUrl = url; + patchNativeQualityChange(); applyMobileInlineVideoAttrs(); danmukuPlugin = danmuEnabledAtLoad ? (player as any).plugins?.artplayerPluginDanmuku : null; @@ -740,6 +820,7 @@ async function mountPlayer(url: string) { markPerformance('rm-player-ready'); danmukuPlugin = danmuEnabledAtLoad ? (player as any).plugins?.artplayerPluginDanmuku : null; updateQualityControl(); + updatePerspectiveSetting(); syncDanmuConnection(); flushPendingDanmu(); try { @@ -877,6 +958,13 @@ watch( }, ); +watch( + () => [props.perspectiveOptions, props.selectedPerspectiveKey] as const, + () => { + updatePerspectiveSetting(); + }, +); + onBeforeUnmount(async () => { if (typeof window !== 'undefined') { delete (window as any).__rmDanmuDebugLocal; diff --git a/src/stores/rmData.ts b/src/stores/rmData.ts index 46e3cbc..8d0306b 100644 --- a/src/stores/rmData.ts +++ b/src/stores/rmData.ts @@ -12,7 +12,7 @@ import type { import type { GroupSection, TeamGroupMeta } from '../utils/groupView'; import type { MatchView } from '../utils/matchView'; import { logInfo, logWarn, markPerformance, measurePerformance } from '../utils/observability'; -import type { PlayerQualityOption } from '../utils/rmStreamView'; +import type { PlayerPerspectiveOption, PlayerQualityOption } from '../utils/rmStreamView'; import { normalizeZoneId, type ZoneOptionItem, type ZoneUiState } from '../utils/zoneView'; import type { RmDataBootstrapPayload, @@ -37,6 +37,7 @@ export const useRmDataStore = defineStore('rm-data', () => { const selectedZoneId = ref(null); const effectiveSelectedZoneId = ref(null); const selectedQualityRes = ref(null); + const selectedPerspectiveKey = ref(null); const selectedZoneName = ref(null); const selectedZoneUiState = ref(null); const streamLoading = ref(true); @@ -48,6 +49,7 @@ export const useRmDataStore = defineStore('rm-data', () => { const groupSections = ref([]); const teamGroupMap = ref>({}); const scheduleEventTitle = ref(''); + const playerPerspectiveOptions = ref([]); const playerQualityOptions = ref([]); const selectedZoneChatRoomId = ref(null); const scheduleMatchRows = ref([]); @@ -73,6 +75,7 @@ export const useRmDataStore = defineStore('rm-data', () => { selectedZoneId.value = snapshot.selectedZoneId; effectiveSelectedZoneId.value = snapshot.effectiveSelectedZoneId; selectedQualityRes.value = snapshot.selectedQualityRes; + selectedPerspectiveKey.value = snapshot.selectedPerspectiveKey; selectedZoneName.value = snapshot.selectedZoneName; selectedZoneUiState.value = snapshot.selectedZoneUiState; streamLoading.value = snapshot.streamLoading; @@ -84,6 +87,7 @@ export const useRmDataStore = defineStore('rm-data', () => { groupSections.value = snapshot.groupSections; teamGroupMap.value = snapshot.teamGroupMap; scheduleEventTitle.value = snapshot.scheduleEventTitle; + playerPerspectiveOptions.value = snapshot.playerPerspectiveOptions; playerQualityOptions.value = snapshot.playerQualityOptions; selectedZoneChatRoomId.value = snapshot.selectedZoneChatRoomId; scheduleMatchRows.value = snapshot.scheduleMatchRows; @@ -123,6 +127,9 @@ export const useRmDataStore = defineStore('rm-data', () => { case 'selectedQualityRes': selectedQualityRes.value = (value as RmDataSnapshot['selectedQualityRes']) ?? null; break; + case 'selectedPerspectiveKey': + selectedPerspectiveKey.value = (value as RmDataSnapshot['selectedPerspectiveKey']) ?? null; + break; case 'selectedZoneName': selectedZoneName.value = (value as RmDataSnapshot['selectedZoneName']) ?? null; break; @@ -156,6 +163,9 @@ export const useRmDataStore = defineStore('rm-data', () => { case 'playerQualityOptions': playerQualityOptions.value = (value as RmDataSnapshot['playerQualityOptions']) ?? []; break; + case 'playerPerspectiveOptions': + playerPerspectiveOptions.value = (value as RmDataSnapshot['playerPerspectiveOptions']) ?? []; + break; case 'selectedZoneChatRoomId': selectedZoneChatRoomId.value = (value as RmDataSnapshot['selectedZoneChatRoomId']) ?? null; break; @@ -187,6 +197,7 @@ export const useRmDataStore = defineStore('rm-data', () => { historySelectedZoneId: historySelectedZoneId.value, selectedZoneId: selectedZoneId.value, selectedQualityRes: selectedQualityRes.value, + selectedPerspectiveKey: selectedPerspectiveKey.value, hasManualZoneSelection, }; } @@ -347,6 +358,11 @@ export const useRmDataStore = defineStore('rm-data', () => { postToWorker({ type: 'USER_SELECT_QUALITY', payload: { qualityRes } }); } + function selectPerspective(perspectiveKey: string | null) { + selectedPerspectiveKey.value = perspectiveKey; + postToWorker({ type: 'USER_SELECT_PERSPECTIVE', payload: { perspectiveKey } }); + } + function startPolling() { stopWorker(); hasManualZoneSelection = false; @@ -380,6 +396,7 @@ export const useRmDataStore = defineStore('rm-data', () => { selectedZoneId, effectiveSelectedZoneId, selectedQualityRes, + selectedPerspectiveKey, selectedZoneName, selectedZoneUiState, streamLoading, @@ -389,11 +406,13 @@ export const useRmDataStore = defineStore('rm-data', () => { groupSections, teamGroupMap, scheduleEventTitle, + playerPerspectiveOptions, playerQualityOptions, selectedZoneChatRoomId, scheduleMatchRows, runningMatchForSelectedZone, selectZone, + selectPerspective, selectQuality, startPolling, stopPolling, @@ -408,11 +427,13 @@ export const useRmDataStore = defineStore('rm-data', () => { 'selectedZoneId', 'effectiveSelectedZoneId', 'selectedQualityRes', + 'selectedPerspectiveKey', 'selectedZoneName', 'selectedZoneUiState', 'zoneOptions', 'effectiveStreamUrl', 'effectiveStreamErrorMessage', + 'playerPerspectiveOptions', 'playerQualityOptions', 'selectedZoneChatRoomId', 'scheduleEventTitle', diff --git a/src/types/api.ts b/src/types/api.ts index 79619f5..9732cd9 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -19,6 +19,12 @@ export interface LiveStreamCandidate extends AnyRecord { src?: string; } +export interface LiveFpvEntry extends AnyRecord { + role?: string; + headimg?: string; + sources?: LiveStreamCandidate[]; +} + export interface ReplayVideoContent extends AnyRecord { title1?: string; main_source_url?: string; @@ -37,6 +43,7 @@ export interface LiveZone extends AnyRecord { liveState?: number; matchState?: number; zoneLiveString?: LiveStreamCandidate[]; + fpvData?: LiveFpvEntry[]; videos?: ReplayVideoEntry[]; } diff --git a/src/utils/rmStreamView.ts b/src/utils/rmStreamView.ts index 4f218e5..9fe47be 100644 --- a/src/utils/rmStreamView.ts +++ b/src/utils/rmStreamView.ts @@ -17,6 +17,11 @@ export interface PlayerQualityOption { src: string; } +export interface PlayerPerspectiveOption { + label: string; + value: string; +} + export function toPlayerQualityOptions(zone: PlayableZoneLike | null): PlayerQualityOption[] { if (!zone) { return []; @@ -29,6 +34,15 @@ export function toPlayerQualityOptions(zone: PlayableZoneLike | null): PlayerQua })); } +export function toPlayerPerspectiveOptions( + perspectives: Array<{ key: string; label: string }> | null | undefined, +): PlayerPerspectiveOption[] { + return (perspectives ?? []).map((item) => ({ + label: item.label, + value: item.key, + })); +} + export function resolveDefaultQualityRes( zone: PlayableZoneLike | null, selectedQualityRes: string | null, diff --git a/src/workers/rmData.worker.ts b/src/workers/rmData.worker.ts index e5317ae..fb1de03 100644 --- a/src/workers/rmData.worker.ts +++ b/src/workers/rmData.worker.ts @@ -27,6 +27,7 @@ import { toErrorSummary } from '../utils/observability'; import { resolveDefaultQualityRes, resolveEffectiveStreamErrorMessage, + toPlayerPerspectiveOptions, toPlayerQualityOptions, } from '../utils/rmStreamView'; import { getNowEpochSeconds } from '../utils/timeNow'; @@ -50,6 +51,7 @@ interface WorkerState { schedule: Schedule | null; selectedZoneId: string | null; selectedQualityRes: string | null; + selectedPerspectiveKey: string | null; historySelectedZoneId: string | null; hasManualZoneSelection: boolean; streamLoading: boolean; @@ -73,6 +75,7 @@ const state: WorkerState = { schedule: null, selectedZoneId: null, selectedQualityRes: null, + selectedPerspectiveKey: null, historySelectedZoneId: null, hasManualZoneSelection: false, streamLoading: true, @@ -99,6 +102,7 @@ const SNAPSHOT_KEYS: Array = [ 'selectedZoneId', 'effectiveSelectedZoneId', 'selectedQualityRes', + 'selectedPerspectiveKey', 'selectedZoneName', 'selectedZoneUiState', 'streamLoading', @@ -109,6 +113,7 @@ const SNAPSHOT_KEYS: Array = [ 'groupSections', 'teamGroupMap', 'scheduleEventTitle', + 'playerPerspectiveOptions', 'playerQualityOptions', 'selectedZoneChatRoomId', 'scheduleMatchRows', @@ -120,6 +125,7 @@ const STREAM_DOMAIN_KEYS: Array = [ 'selectedZoneId', 'effectiveSelectedZoneId', 'selectedQualityRes', + 'selectedPerspectiveKey', 'selectedZoneName', 'selectedZoneUiState', 'streamLoading', @@ -127,6 +133,7 @@ const STREAM_DOMAIN_KEYS: Array = [ 'zoneOptions', 'effectiveStreamUrl', 'effectiveStreamErrorMessage', + 'playerPerspectiveOptions', 'playerQualityOptions', 'selectedZoneChatRoomId', 'groupSections', @@ -149,6 +156,7 @@ const STREAM_STATUS_KEYS: Array = [ 'effectiveStreamUrl', 'effectiveStreamErrorMessage', 'selectedZoneUiState', + 'playerPerspectiveOptions', 'playerQualityOptions', 'selectedZoneChatRoomId', ]; @@ -168,6 +176,7 @@ const SCHEDULE_DOMAIN_KEYS: Array = [ 'groupSections', 'teamGroupMap', 'playerQualityOptions', + 'playerPerspectiveOptions', 'selectedZoneChatRoomId', ]; @@ -224,10 +233,19 @@ function getSelectedLiveZone(liveZones: LiveZoneOption[]): LiveZoneOption | null return liveZones.find((zone) => normalizeZoneId(zone.zoneId) === targetId) ?? liveZones[0] ?? null; } +function getSelectedPerspective(zone: LiveZoneOption | null) { + if (!zone?.perspectives.length) { + return null; + } + + return zone.perspectives.find((item) => item.key === state.selectedPerspectiveKey) ?? zone.perspectives[0] ?? null; +} + function syncSelectionAfterDataChange() { const liveZones = getLiveZoneOptions(); if (!liveZones.length) { state.selectedZoneId = null; + state.selectedPerspectiveKey = null; state.selectedQualityRes = null; return; } @@ -251,7 +269,17 @@ function syncSelectionAfterDataChange() { const selectedZone = getSelectedLiveZone(liveZones); state.selectedZoneId = selectedZone ? normalizeZoneId(selectedZone.zoneId) || null : null; - state.selectedQualityRes = resolveDefaultQualityRes(selectedZone, state.selectedQualityRes); + const selectedPerspective = getSelectedPerspective(selectedZone); + state.selectedPerspectiveKey = selectedPerspective?.key ?? null; + state.selectedQualityRes = resolveDefaultQualityRes( + selectedPerspective + ? { + zoneName: selectedPerspective.label, + qualities: selectedPerspective.qualities, + } + : null, + state.selectedQualityRes, + ); } function buildSnapshot(): RmDataSnapshot { @@ -263,7 +291,13 @@ function buildSnapshot(): RmDataSnapshot { const selectedZoneId = selectedZone ? normalizeZoneId(selectedZone.zoneId) || null : null; const selectedZoneName = selectedZone?.zoneName ?? null; const effectiveSelectedZoneId = normalizeZoneId(state.selectedZoneId) || selectedZoneId || null; - const resolvedStreamUrl = resolveLiveStreamUrl(state.liveGameInfo, effectiveSelectedZoneId, state.selectedQualityRes); + const selectedPerspective = getSelectedPerspective(selectedZone); + const resolvedStreamUrl = resolveLiveStreamUrl( + state.liveGameInfo, + effectiveSelectedZoneId, + state.selectedQualityRes, + selectedPerspective?.key ?? undefined, + ); const canPlaySelectedZone = Boolean(selectedZone && resolvedStreamUrl && !state.streamErrorMessage.trim()); const effectiveStreamUrl = canPlaySelectedZone ? resolvedStreamUrl : null; const effectiveStreamErrorMessage = resolveEffectiveStreamErrorMessage( @@ -275,7 +309,14 @@ function buildSnapshot(): RmDataSnapshot { const groupSections = extractGroupSections(state.groupsOrder, effectiveSelectedZoneId, selectedZoneName); const teamGroupMap = buildTeamGroupMap(groupSections); const scheduleEventTitle = getScheduleEventTitle(state.schedule); - const playerQualityOptions = toPlayerQualityOptions(selectedZone); + const playerPerspectiveOptions = toPlayerPerspectiveOptions(selectedZone?.perspectives); + const selectedPlayableStream = selectedPerspective + ? { + zoneName: selectedPerspective.label, + qualities: selectedPerspective.qualities, + } + : null; + const playerQualityOptions = toPlayerQualityOptions(selectedPlayableStream); const selectedZoneChatRoomId = resolveZoneChatRoomId(state.liveGameInfo, effectiveSelectedZoneId, selectedZoneName); const scheduleMatchRows = getScheduleRows(state.schedule, state.liveGameInfo); const runningMatchForSelectedZone = getRunningMatch(scheduleMatchRows, effectiveSelectedZoneId); @@ -290,6 +331,7 @@ function buildSnapshot(): RmDataSnapshot { selectedZoneId: effectiveSelectedZoneId, effectiveSelectedZoneId, selectedQualityRes: state.selectedQualityRes, + selectedPerspectiveKey: selectedPerspective?.key ?? null, selectedZoneName, selectedZoneUiState, streamLoading: state.streamLoading, @@ -300,6 +342,7 @@ function buildSnapshot(): RmDataSnapshot { groupSections, teamGroupMap, scheduleEventTitle, + playerPerspectiveOptions, playerQualityOptions, selectedZoneChatRoomId, scheduleMatchRows, @@ -535,7 +578,13 @@ async function probeSelectedStreamAvailability(options: { showLoading: boolean } const selectedZone = getSelectedLiveZone(liveZones); const effectiveSelectedZoneId = normalizeZoneId(state.selectedZoneId) || normalizeZoneId(selectedZone?.zoneId) || null; - const streamUrl = resolveLiveStreamUrl(state.liveGameInfo, effectiveSelectedZoneId, state.selectedQualityRes); + const selectedPerspective = getSelectedPerspective(selectedZone); + const streamUrl = resolveLiveStreamUrl( + state.liveGameInfo, + effectiveSelectedZoneId, + state.selectedQualityRes, + selectedPerspective?.key ?? undefined, + ); if (options.showLoading) { state.streamLoading = true; @@ -769,6 +818,7 @@ function handleInit(payload: RmDataInitPayload) { state.historySelectedZoneId = payload.historySelectedZoneId; state.selectedZoneId = payload.selectedZoneId; state.selectedQualityRes = payload.selectedQualityRes; + state.selectedPerspectiveKey = payload.selectedPerspectiveKey; state.hasManualZoneSelection = payload.hasManualZoneSelection; state.streamLoading = true; state.streamErrorMessage = ''; @@ -827,6 +877,16 @@ self.addEventListener('message', (event: MessageEvent; scheduleEventTitle: string; + playerPerspectiveOptions: PlayerPerspectiveOption[]; playerQualityOptions: PlayerQualityOption[]; selectedZoneChatRoomId: string | null; scheduleMatchRows: MatchView[]; @@ -59,6 +62,7 @@ export interface RmDataPatchPayload { export type RmDataWorkerIncomingMessage = | { type: 'INIT'; payload: RmDataInitPayload } | { type: 'USER_SELECT_ZONE'; payload: { zoneId: string | null } } + | { type: 'USER_SELECT_PERSPECTIVE'; payload: { perspectiveKey: string | null } } | { type: 'USER_SELECT_QUALITY'; payload: { qualityRes: string | null } } | { type: 'RETRY_STREAM' } | { type: 'VISIBILITY_CHANGED'; payload: { hidden: boolean } }