417 lines
11 KiB
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>
|