Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 7 additions & 25 deletions src/components/danmu/DanmuPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@
import { useMatchEngagementStore } from '@/stores/matchEngagement';
import { storeToRefs } from 'pinia';
import ScrollTop from 'primevue/scrolltop';
import Skeleton from 'primevue/skeleton';
import VirtualScroller from 'primevue/virtualscroller';
import { computed } from 'vue';
import { useDanmuStore } from '../../stores/danmu';
import { useDanmuFilterStore } from '../../stores/danmuFilter';
import type { DanmuMessage } from '../../types/api';
import DanmuItem from './DanmuItem.vue';

const danmuStore = useDanmuStore();
Expand All @@ -16,7 +13,7 @@ const matchEngagementStore = useMatchEngagementStore();
const { messages } = storeToRefs(danmuStore);
const { viewerCount } = storeToRefs(matchEngagementStore);

const virtualItems = computed<Array<DanmuMessage | null>>(() => {
const filteredMessages = computed(() => {
return messages.value.filter((message) => danmuFilterStore.matchMessage(message));
});

Expand All @@ -32,29 +29,17 @@ const viewerCountLabel = computed(() => {
<span class="list-meta-value">{{ viewerCountLabel }}</span>
</div>

<VirtualScroller
v-if="virtualItems.length"
:items="virtualItems"
:itemSize="56"
:delay="60"
class="list-body danmu-virtual"
:style="{ height: '100%' }"
>
<template #item="slotProps">
<div v-if="slotProps.item" class="list-row">
<DanmuItem :message="slotProps.item" />
</div>
<div v-else class="list-row">
<Skeleton width="100%" height="2rem" />
</div>
</template>
</VirtualScroller>
<div v-if="filteredMessages.length" class="list-body danmu-scroll">
<div v-for="message in filteredMessages" :key="message.id" class="list-row">
<DanmuItem :message="message" />
</div>
</div>

<div v-else class="list-body list-empty-wrap">
<div class="list-empty">暂无弹幕</div>
</div>

<ScrollTop target=".danmu-virtual" :threshold="120" icon="pi pi-arrow-up" />
<ScrollTop target=".danmu-scroll" :threshold="120" icon="pi pi-arrow-up" />
</section>
</template>

Expand Down Expand Up @@ -118,7 +103,4 @@ const viewerCountLabel = computed(() => {
bottom: 0.75rem;
}

.list-body :deep(.p-virtualscroller-content) {
overflow-x: hidden;
}
</style>
65 changes: 61 additions & 4 deletions src/components/layout/LiveStage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { storeToRefs } from 'pinia';
import { Fieldset } from 'primevue';
import Splitter from 'primevue/splitter';
import SplitterPanel from 'primevue/splitterpanel';
import { computed, defineAsyncComponent } from 'vue';
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import LivePlayer from '../live/LivePlayer.vue';
import type { DanmuMessage } from '../../types/api';

Expand Down Expand Up @@ -39,6 +39,45 @@ const MatchReactionStrip = defineAsyncComponent(() => import('../panels/MatchRea

const hasCurrentMatch = computed(() => Boolean(runningMatchForSelectedZone.value));
const showMatchDependentPlaceholder = computed(() => streamLoading.value || !liveGameInfo.value);
const liveColumnRef = ref<HTMLElement | null>(null);
const desktopSplitterStyle = ref<Record<string, string>>({});

let liveColumnResizeObserver: ResizeObserver | null = null;

function updateDesktopSplitterHeight() {
if (isMobile.value || !danmuEnabledAtLoad || !liveColumnRef.value) {
desktopSplitterStyle.value = {};
return;
}

const height = liveColumnRef.value.getBoundingClientRect().height;
if (height > 0) {
desktopSplitterStyle.value = {
'--live-stage-height': `${Math.ceil(height)}px`,
};
}
}

onMounted(() => {
liveColumnResizeObserver = new ResizeObserver(() => updateDesktopSplitterHeight());
if (liveColumnRef.value) {
liveColumnResizeObserver.observe(liveColumnRef.value);
}

void nextTick(updateDesktopSplitterHeight);
});

onBeforeUnmount(() => {
liveColumnResizeObserver?.disconnect();
liveColumnResizeObserver = null;
});

watch(
[isMobile, pkEnabled, reactionEnabled, hasCurrentMatch, showMatchDependentPlaceholder],
() => {
void nextTick(updateDesktopSplitterHeight);
},
);

function onRetry() {
void dataStore.retryLiveStream();
Expand All @@ -63,9 +102,14 @@ function onDanmuReset() {

<template>
<section class="main-grid">
<Splitter v-if="!isMobile && danmuEnabledAtLoad" layout="horizontal" :style="{ height: '100%' }">
<SplitterPanel :size="75" :minSize="50">
<div class="live-column">
<Splitter
v-if="!isMobile && danmuEnabledAtLoad"
layout="horizontal"
class="desktop-live-splitter"
:style="desktopSplitterStyle"
>
<SplitterPanel :size="75" :minSize="50" class="live-panel-wrap">
<div ref="liveColumnRef" class="live-column">
<MatchFirepowerBar v-if="pkEnabled && hasCurrentMatch" />
<div v-else-if="pkEnabled && showMatchDependentPlaceholder" class="firepower-slot" aria-hidden="true" />
<LivePlayer
Expand Down Expand Up @@ -133,12 +177,22 @@ function onDanmuReset() {
gap: 1rem;
grid-template-columns: 1fr;
min-width: 0;
min-height: 0;
}

.live-column {
min-width: 0;
}

.desktop-live-splitter {
height: var(--live-stage-height, auto);
align-items: stretch;
}

.live-panel-wrap {
min-width: 0;
}

.firepower-slot {
min-height: 2.9rem;
}
Expand All @@ -159,6 +213,9 @@ function onDanmuReset() {
.danmu-panel-wrap {
display: flex;
flex-direction: column;
align-self: stretch;
min-height: 0;
min-width: 0;
overflow: hidden;
}

Expand Down
76 changes: 24 additions & 52 deletions src/components/live/LivePlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ let lastUserGestureAt = 0;
let userPausedPlayback = false;
let retryRemountCount = 0;

const SOFT_RECOVERY_MIN_INTERVAL_MS = 5000;

const danmuFilterStore = useDanmuFilterStore();
const matchEngagementStore = useMatchEngagementStore();
const userInfoStore = useUserInfoStore();
Expand Down Expand Up @@ -257,35 +259,6 @@ function isDocumentVisible() {
return typeof document === 'undefined' || document.visibilityState !== 'hidden';
}

function getLiveEdgeTime(video: HTMLVideoElement) {
const hlsLiveSyncPosition = liveHls?.liveSyncPosition;
if (typeof hlsLiveSyncPosition === 'number' && Number.isFinite(hlsLiveSyncPosition)) {
return hlsLiveSyncPosition;
}

const buffered = video.buffered;
if (!buffered.length) {
return null;
}

return buffered.end(buffered.length - 1);
}

function seekNearLiveEdge(video: HTMLVideoElement) {
const liveEdge = getLiveEdgeTime(video);
if (liveEdge === null) {
return false;
}

const targetTime = Math.max(0, liveEdge - 1.5);
if (Number.isFinite(targetTime) && Math.abs(video.currentTime - targetTime) > 0.8) {
video.currentTime = targetTime;
return true;
}

return false;
}

function tryPlayVideo(video: HTMLVideoElement) {
const playResult = video.play();
if (playResult && typeof playResult.catch === 'function') {
Expand All @@ -301,15 +274,14 @@ function triggerStreamRecovery(video: HTMLVideoElement, forceRemount = false) {
}

const now = Date.now();
if (!forceRemount && now - lastRecoveryAt < 2500) {
if (!forceRemount && now - lastRecoveryAt < SOFT_RECOVERY_MIN_INTERVAL_MS) {
return;
}
lastRecoveryAt = now;

if (!forceRemount) {
try {
liveHls?.startLoad?.();
seekNearLiveEdge(video);
tryPlayVideo(video);
return;
} catch (error) {
Expand Down Expand Up @@ -382,14 +354,14 @@ function checkPlayerHealth() {
}

const readyToPlay = video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA;
const likelyStalled = !video.paused && !video.ended && !progressed && now - lastProgressCheckAt > 6000;
const likelyStalled = !video.paused && !video.ended && !progressed && now - lastProgressCheckAt > 8000;
const passivePause = video.paused && !video.ended && !userPausedPlayback && now - lastUserGestureAt > 1500;

if (passivePause || likelyStalled || !readyToPlay) {
if (!stalledSince) {
stalledSince = now;
}
triggerStreamRecovery(video, now - stalledSince > 14000);
triggerStreamRecovery(video, now - stalledSince > 20000);
}
}

Expand Down Expand Up @@ -943,32 +915,32 @@ async function mountPlayer(url: string) {
const hlsBufferStalledDetail = (HlsCtor as any).ErrorDetails?.BUFFER_STALLED_ERROR ?? 'bufferStalledError';

const hls = new HlsCtor({
// Favor stability over ultra-low latency to avoid frequent stalls on mobile networks.
// Favor continuity over low latency, while leaving timeline control to HLS/video.
lowLatencyMode: false,
liveDurationInfinity: true,
liveSyncMode: 'buffered',
backBufferLength: 20,
maxBufferLength: 20,
maxMaxBufferLength: 40,
maxBufferHole: 0.8,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 8,
maxLiveSyncPlaybackRate: 1.2,
backBufferLength: 30,
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxBufferHole: 1,
liveSyncDurationCount: 2,
liveMaxLatencyDurationCount: 3,
maxLiveSyncPlaybackRate: 1,
liveSyncOnStallIncrease: 1,
nudgeOffset: 0.12,
nudgeMaxRetry: 8,
nudgeOffset: 0.1,
nudgeMaxRetry: 6,
enableWorker: true,
startFragPrefetch: true,
testBandwidth: false,
manifestLoadingMaxRetry: 4,
manifestLoadingRetryDelay: 500,
manifestLoadingMaxRetryTimeout: 4000,
levelLoadingMaxRetry: 5,
levelLoadingRetryDelay: 700,
levelLoadingMaxRetryTimeout: 6000,
fragLoadingMaxRetry: 6,
fragLoadingRetryDelay: 800,
fragLoadingMaxRetryTimeout: 10000,
manifestLoadingMaxRetry: 6,
manifestLoadingRetryDelay: 700,
manifestLoadingMaxRetryTimeout: 6000,
levelLoadingMaxRetry: 6,
levelLoadingRetryDelay: 1000,
levelLoadingMaxRetryTimeout: 8000,
fragLoadingMaxRetry: 8,
fragLoadingRetryDelay: 1000,
fragLoadingMaxRetryTimeout: 12000,
startLevel: -1,
});

Expand Down
26 changes: 21 additions & 5 deletions src/utils/__tests__/rmStreamView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,35 @@ describe('resolveDefaultQualityRes', () => {
expect(selected).toBe('720');
});

it('falls back to first quality when selection is missing', () => {
it('prefers middle quality when selection is missing', () => {
const selected = resolveDefaultQualityRes(
{
zoneName: 'A',
qualities: [
{ label: '1080p', res: '1080', src: 'https://a' },
{ label: '720p', res: '720', src: 'https://b' },
{ label: '1080p', res: 'high', src: 'https://a' },
{ label: '720p', res: 'middle', src: 'https://b' },
{ label: '540p', res: 'low', src: 'https://c' },
],
},
null,
);

expect(selected).toBe('middle');
});

it('falls back to first quality when middle quality is unavailable', () => {
const selected = resolveDefaultQualityRes(
{
zoneName: 'A',
qualities: [
{ label: '1080p', res: 'high', src: 'https://a' },
{ label: '540p', res: 'low', src: 'https://b' },
],
},
'540',
null,
);

expect(selected).toBe('1080');
expect(selected).toBe('high');
});
});

Expand Down
3 changes: 2 additions & 1 deletion src/utils/rmStreamView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export function resolveDefaultQualityRes(
return selectedQualityRes;
}

return zone.qualities[0]?.res ?? null;
const smoothDefault = zone.qualities.find((item) => item.res === 'middle');
return smoothDefault?.res ?? zone.qualities[0]?.res ?? null;
}

export function resolveEffectiveStreamErrorMessage(
Expand Down
5 changes: 4 additions & 1 deletion src/workers/rmData.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ let pendingBootstrapSnapshot = false;
let pendingPatchKeys = new Set<keyof RmDataSnapshot>();
let snapshotVersion = 0;
let lastPostedSnapshot: RmDataSnapshot | null = null;
let hasManualQualitySelection = false;

const SNAPSHOT_KEYS: Array<keyof RmDataSnapshot> = [
'liveGameInfo',
Expand Down Expand Up @@ -278,7 +279,7 @@ function syncSelectionAfterDataChange() {
qualities: selectedPerspective.qualities,
}
: null,
state.selectedQualityRes,
hasManualQualitySelection ? state.selectedQualityRes : null,
);
}

Expand Down Expand Up @@ -820,6 +821,7 @@ function handleInit(payload: RmDataInitPayload) {
state.selectedQualityRes = payload.selectedQualityRes;
state.selectedPerspectiveKey = payload.selectedPerspectiveKey;
state.hasManualZoneSelection = payload.hasManualZoneSelection;
hasManualQualitySelection = false;
state.streamLoading = true;
state.streamErrorMessage = '';
state.visible = true;
Expand Down Expand Up @@ -875,6 +877,7 @@ self.addEventListener('message', (event: MessageEvent<RmDataWorkerIncomingMessag
}

if (data.type === 'USER_SELECT_QUALITY') {
hasManualQualitySelection = true;
state.selectedQualityRes =
data.payload.qualityRes && data.payload.qualityRes.trim() ? data.payload.qualityRes : null;
syncSelectionAfterDataChange();
Expand Down
Loading