frosty/cast_receiver/index.html

417 lines
11 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Glacier Cast Receiver</title>
<style>
html,
body,
cast-media-player {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #000;
}
</style>
</head>
<body>
<cast-media-player></cast-media-player>
<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
<script>
(() => {
const MESSAGE_NAMESPACE = 'urn:x-cast:com.namecallfilter.glacier.cast';
const TARGET_LATENCY_SECONDS = 4.0;
const MAX_LATENCY_SECONDS = 8.0;
const EMERGENCY_LATENCY_SECONDS = 10.0;
const STATUS_INTERVAL_MS = 1000;
const LATENCY_CHECK_INTERVAL_MS = 500;
const STARTUP_LIVE_SEEK_THRESHOLD_SECONDS = TARGET_LATENCY_SECONDS + 0.5;
const MAX_LATENCY_SEEK_COOLDOWN_MS = 8000;
const MAX_LATENCY_RELOAD_COOLDOWN_MS = 15000;
const HIGH_LATENCY_RELOAD_TICKS = 3;
const context = cast.framework.CastReceiverContext.getInstance();
const playerManager = context.getPlayerManager();
const playbackConfig = new cast.framework.PlaybackConfig();
playbackConfig.autoResumeNumberOfSegments = 0;
playbackConfig.enableSmoothLiveRefresh = false;
playbackConfig.segmentRequestRetryLimit = 1;
playbackConfig.shakaConfig = {
streaming: {
lowLatencyMode: true,
bufferingGoal: 3,
rebufferingGoal: 0.75,
bufferBehind: 6,
updateIntervalSeconds: 0.5,
segmentPrefetchLimit: 2,
inaccurateManifestTolerance: 0,
startAtSegmentBoundary: false,
liveSync: {
enabled: true,
targetLatency: TARGET_LATENCY_SECONDS,
targetLatencyTolerance: 0.25,
maxPlaybackRate: 1.1,
minPlaybackRate: 0.98,
panicMode: true,
panicThreshold: MAX_LATENCY_SECONDS,
dynamicTargetLatency: {
enabled: true,
minLatency: TARGET_LATENCY_SECONDS,
maxLatency: MAX_LATENCY_SECONDS,
rebufferIncrement: 0.5,
stabilityThreshold: 8,
maxAttempts: 3,
},
},
retryParameters: {
maxAttempts: 2,
baseDelay: 100,
backoffFactor: 1.2,
fuzzFactor: 0.1,
},
},
manifest: {
defaultPresentationDelay: TARGET_LATENCY_SECONDS,
updatePeriod: 0.2,
retryParameters: {
maxAttempts: 2,
baseDelay: 100,
backoffFactor: 1.2,
fuzzFactor: 0.1,
},
hls: {
ignoreTextStreamFailures: true,
},
},
};
if (typeof playerManager.setPlaybackConfig === 'function') {
playerManager.setPlaybackConfig(playbackConfig);
}
const finiteNumber = (value) =>
typeof value === 'number' && Number.isFinite(value);
let pendingStartupLiveSeek = false;
let lastMaxLatencySeekAtMs = 0;
let lastMaxLatencyReloadAtMs = 0;
let emergencyRecoveryActive = false;
let emergencyTicksAfterSeek = 0;
let lastLoadRequestData = null;
const logReceiverEvent = (action, fields = {}) => {
const details = Object.entries({action, ...fields})
.map(([key, value]) => `${key}=${value == null ? '' : value}`)
.join(' ');
console.log(`[GlacierCastReceiver] ${details}`);
};
const currentPlaybackRate = () => {
if (typeof playerManager.getPlaybackRate === 'function') {
const managerRate = playerManager.getPlaybackRate();
if (finiteNumber(managerRate) && managerRate > 0) {
return managerRate;
}
}
const mediaElement = document.querySelector('video, audio');
if (mediaElement && finiteNumber(mediaElement.playbackRate)) {
return mediaElement.playbackRate;
}
return 1.0;
};
const liveStatus = () => {
const range = playerManager.getLiveSeekableRange();
if (!range || !finiteNumber(range.end)) return null;
const currentTime = playerManager.getCurrentTimeSec();
if (!finiteNumber(currentTime)) return null;
return {
currentTime,
rangeStart: finiteNumber(range.start) ? range.start : null,
rangeEnd: range.end,
latency: Math.max(0, range.end - currentTime),
};
};
const sendCustomMessageToAll = (message) => {
for (const sender of context.getSenders()) {
context.sendCustomMessage(MESSAGE_NAMESPACE, sender.id, message);
}
};
const sendStatus = (extra = {}) => {
const status = liveStatus();
sendCustomMessageToAll({
type: 'status',
latencyMs: status == null ? null : Math.round(status.latency * 1000),
playerState: playerManager.getPlayerState(),
currentTimeSec: status == null ? null : status.currentTime,
rangeStartSec: status == null ? null : status.rangeStart,
rangeEndSec: status == null ? null : status.rangeEnd,
targetLatencySec: TARGET_LATENCY_SECONDS,
maxLatencySec: MAX_LATENCY_SECONDS,
playbackRate: currentPlaybackRate(),
latencyReference: 'receiverLiveSeekableRange',
...extra,
});
};
const seekToLiveLatency = (latencySeconds) => {
const status = liveStatus();
if (!status) return false;
const start = status.rangeStart == null ? 0 : status.rangeStart;
const targetTime = Math.max(
start,
status.rangeEnd - latencySeconds,
);
playerManager.seek({
currentTime: targetTime,
resumeState: cast.framework.messages.ResumeState.PLAYBACK_START,
});
return true;
};
const seekToTargetLive = () => seekToLiveLatency(TARGET_LATENCY_SECONDS);
const jumpToLive = () => {
const ok = seekToTargetLive();
sendStatus({correction: ok ? 'manualJumpToLive' : 'manualJumpFailed'});
return ok;
};
const reloadCurrentMedia = () => {
if (!lastLoadRequestData || typeof playerManager.load !== 'function') {
return false;
}
lastLoadRequestData.autoplay = true;
pendingStartupLiveSeek = true;
try {
const loadResult = playerManager.load(lastLoadRequestData);
if (loadResult && typeof loadResult.catch === 'function') {
loadResult.catch((error) => {
logReceiverEvent('reload_failed', {
reason: error && error.name ? error.name : 'unknown',
});
});
}
return true;
} catch (error) {
logReceiverEvent('reload_failed', {
reason: error && error.name ? error.name : 'unknown',
});
return false;
}
};
const tryStartupLiveSeek = () => {
if (!pendingStartupLiveSeek) return false;
pendingStartupLiveSeek = false;
const status = liveStatus();
if (!status) {
sendStatus({correction: 'startupJumpUnavailable'});
return false;
}
if (status.latency <= STARTUP_LIVE_SEEK_THRESHOLD_SECONDS) return false;
const ok = seekToTargetLive();
if (ok && status.latency > EMERGENCY_LATENCY_SECONDS) {
lastMaxLatencySeekAtMs = Date.now();
emergencyRecoveryActive = true;
emergencyTicksAfterSeek = 0;
}
sendStatus({
correction: ok ? 'startupJumpToLive' : 'startupJumpFailed',
latencyBeforeCorrectionMs: Math.round(status.latency * 1000),
});
return ok;
};
const enforceMaxLatencySeek = (status) => {
if (status.latency <= MAX_LATENCY_SECONDS) {
emergencyRecoveryActive = false;
emergencyTicksAfterSeek = 0;
return;
}
const now = Date.now();
const latencyBeforeCorrectionMs = Math.round(status.latency * 1000);
const emergency = status.latency > EMERGENCY_LATENCY_SECONDS;
logReceiverEvent('max_latency', {
state: playerManager.getPlayerState(),
latency_ms: latencyBeforeCorrectionMs,
max_latency_ms: Math.round(MAX_LATENCY_SECONDS * 1000),
target_latency_ms: Math.round(TARGET_LATENCY_SECONDS * 1000),
current_sec: status.currentTime,
range_start_sec: status.rangeStart,
range_end_sec: status.rangeEnd,
emergency,
});
if (emergency && emergencyRecoveryActive) {
emergencyTicksAfterSeek += 1;
if (emergencyTicksAfterSeek >= HIGH_LATENCY_RELOAD_TICKS) {
const reloadCooldownActive =
lastMaxLatencyReloadAtMs > 0 &&
now - lastMaxLatencyReloadAtMs < MAX_LATENCY_RELOAD_COOLDOWN_MS;
if (!reloadCooldownActive) {
lastMaxLatencyReloadAtMs = now;
emergencyRecoveryActive = false;
emergencyTicksAfterSeek = 0;
const ok = reloadCurrentMedia();
sendStatus({
correction: ok ? 'maxLatencyReload' : 'maxLatencyReloadFailed',
latencyBeforeCorrectionMs,
});
return;
}
}
sendStatus({
correction: 'maxLatencyObserved',
latencyBeforeCorrectionMs,
});
return;
}
const seekCooldownActive =
lastMaxLatencySeekAtMs > 0 &&
now - lastMaxLatencySeekAtMs < MAX_LATENCY_SEEK_COOLDOWN_MS;
if (!emergency && seekCooldownActive) {
sendStatus({
correction: 'maxLatencyObserved',
latencyBeforeCorrectionMs,
});
return;
}
lastMaxLatencySeekAtMs = now;
emergencyRecoveryActive = emergency;
emergencyTicksAfterSeek = 0;
const ok = seekToTargetLive();
sendStatus({
correction: ok ? 'maxLatencyJumpToLive' : 'maxLatencyJumpFailed',
latencyBeforeCorrectionMs: Math.round(status.latency * 1000),
});
};
const enforceMaxLatency = () => {
const state = playerManager.getPlayerState();
const active = state === 'PLAYING' || state === 'BUFFERING';
if (!active) return;
if (tryStartupLiveSeek()) return;
const status = liveStatus();
if (!status) return;
enforceMaxLatencySeek(status);
};
playerManager.setMessageInterceptor(
cast.framework.messages.MessageType.LOAD,
(loadRequestData) => {
const media = loadRequestData.media;
pendingStartupLiveSeek = true;
lastMaxLatencySeekAtMs = 0;
lastMaxLatencyReloadAtMs = 0;
emergencyRecoveryActive = false;
emergencyTicksAfterSeek = 0;
if (media) {
media.entity = media.entity || media.contentId;
media.contentUrl = media.contentUrl || media.contentId;
media.streamType = cast.framework.messages.StreamType.LIVE;
media.contentType = media.contentType || 'application/x-mpegURL';
}
loadRequestData.autoplay = true;
lastLoadRequestData = loadRequestData;
return loadRequestData;
},
);
context.addCustomMessageListener(MESSAGE_NAMESPACE, (event) => {
let message = event.data;
if (typeof message === 'string') {
try {
message = JSON.parse(message);
} catch (_) {
return;
}
}
if (!message || typeof message !== 'object') return;
if (message.type === 'jumpToLive') {
const ok = jumpToLive();
context.sendCustomMessage(MESSAGE_NAMESPACE, event.senderId, {
type: 'jumpToLiveResult',
ok,
});
}
});
setInterval(sendStatus, STATUS_INTERVAL_MS);
setInterval(enforceMaxLatency, LATENCY_CHECK_INTERVAL_MS);
const receiverOptions = new cast.framework.CastReceiverOptions();
receiverOptions.disableIdleTimeout = true;
receiverOptions.playbackConfig = playbackConfig;
receiverOptions.statusText = 'Glacier';
receiverOptions.useShakaForHls = true;
context.start(receiverOptions);
})();
</script>
</body>
</html>