Files
carplay/public/app.js
2026-05-01 22:08:50 -07:00

474 lines
11 KiB
JavaScript

const elements = {
entryScreen: document.querySelector('#entry-screen'),
playerScreen: document.querySelector('#player-screen'),
form: document.querySelector('#stream-form'),
url: document.querySelector('#stream-url'),
next: document.querySelector('#next'),
entryMessage: document.querySelector('#entry-message'),
recentPanel: document.querySelector('#recent-panel'),
recentList: document.querySelector('#recent-list'),
audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'),
stage: document.querySelector('#video-stage'),
loader: document.querySelector('#loader'),
playerMessage: document.querySelector('#player-message'),
controls: document.querySelector('#controls'),
back: document.querySelector('#back'),
playPause: document.querySelector('#play-pause'),
mute: document.querySelector('#mute'),
};
const context = elements.canvas.getContext('2d', { alpha: false });
const state = {
generation: 0,
session: null,
websocket: null,
frames: [],
currentBitmap: null,
raf: 0,
frameCount: 0,
controlsVisible: true,
hideControlsTimer: 0,
recentUrls: [],
};
void loadRecentUrls();
elements.form.addEventListener('submit', async (event) => {
event.preventDefault();
setEntryMessage('');
setFormBusy(true);
try {
stopSession({ showEntry: false });
const response = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: elements.url.value }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to create stream session.');
}
showPlayer();
void loadRecentUrls();
startSession(payload);
} catch (error) {
stopSession();
setEntryMessage(error.message);
} finally {
setFormBusy(false);
}
});
elements.recentList.addEventListener('click', (event) => {
const button = event.target.closest('[data-recent-index]');
if (!button) {
return;
}
const item = state.recentUrls[Number(button.dataset.recentIndex)];
if (!item) {
return;
}
elements.url.value = item.url;
elements.form.requestSubmit();
});
elements.stage.addEventListener('pointerup', (event) => {
if (!state.session || event.target.closest('.controls')) {
return;
}
setControlsVisible(!state.controlsVisible);
});
elements.back.addEventListener('click', () => {
stopSession();
});
elements.playPause.addEventListener('click', () => {
if (elements.audio.paused) {
void playAudio();
return;
}
elements.audio.pause();
});
elements.mute.addEventListener('click', () => {
elements.audio.muted = !elements.audio.muted;
syncControlLabels();
});
elements.audio.addEventListener('play', () => {
startRenderLoop();
syncControlLabels();
scheduleControlsHide();
});
elements.audio.addEventListener('pause', () => {
syncControlLabels();
setControlsVisible(true);
});
elements.audio.addEventListener('playing', () => {
elements.loader.hidden = state.frameCount > 0;
clearPlayerMessage();
scheduleControlsHide();
});
elements.audio.addEventListener('waiting', () => {
if (state.session) {
elements.loader.hidden = false;
}
});
elements.audio.addEventListener('error', () => {
if (state.session) {
showPlayerMessage('Audio failed');
setControlsVisible(true);
}
});
function startSession(session) {
const generation = state.generation;
state.session = session;
state.frameCount = 0;
clearFrameQueue();
syncControlLabels();
setControlsVisible(true);
elements.loader.hidden = false;
clearPlayerMessage();
const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}`;
const websocket = new WebSocket(websocketUrl);
websocket.binaryType = 'arraybuffer';
state.websocket = websocket;
websocket.addEventListener('message', (event) => {
if (generation !== state.generation) {
return;
}
if (typeof event.data === 'string') {
handleControlMessage(event.data);
return;
}
void handleFramePacket(event.data, generation);
});
websocket.addEventListener('close', () => {
if (state.session?.id === session.id) {
showPlayerMessage('Stream ended');
setControlsVisible(true);
}
});
websocket.addEventListener('error', () => {
if (state.session?.id === session.id) {
showPlayerMessage('Stream failed');
setControlsVisible(true);
}
});
elements.audio.src = `/audio/${session.id}`;
elements.audio.load();
void playAudio();
startRenderLoop();
}
async function playAudio() {
try {
await elements.audio.play();
clearPlayerMessage();
} catch {
showPlayerMessage('Tap play');
setControlsVisible(true);
}
}
async function handleFramePacket(packet, generation) {
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
return;
}
const timestamp = new DataView(packet, 0, 8).getFloat64(0, true);
const blob = new Blob([packet.slice(8)], { type: 'image/jpeg' });
const bitmap = await decodeImage(blob);
if (generation !== state.generation) {
releaseImage(bitmap);
return;
}
state.frames.push({ timestamp, bitmap });
state.frameCount += 1;
trimFrameQueue();
}
function handleControlMessage(rawMessage) {
let message;
try {
message = JSON.parse(rawMessage);
} catch {
return;
}
if (message.type === 'error') {
showPlayerMessage('Stream failed');
setControlsVisible(true);
}
if (message.type === 'end') {
showPlayerMessage('Stream ended');
setControlsVisible(true);
}
}
function startRenderLoop() {
if (state.raf) {
return;
}
const render = () => {
drawReadyFrames();
state.raf = requestAnimationFrame(render);
};
state.raf = requestAnimationFrame(render);
}
function drawReadyFrames() {
if (!state.session) {
return;
}
const frameLeadSeconds = 1 / Math.max(1, state.session.options.fps);
const targetTime = elements.audio.currentTime + frameLeadSeconds;
let drew = false;
while (state.frames.length > 0 && state.frames[0].timestamp <= targetTime) {
const frame = state.frames.shift();
if (state.currentBitmap) {
releaseImage(state.currentBitmap);
}
state.currentBitmap = frame.bitmap;
drawBitmap(frame.bitmap);
drew = true;
}
if (drew) {
elements.loader.hidden = true;
clearPlayerMessage();
}
}
function drawBitmap(bitmap) {
const width = bitmap.width || bitmap.naturalWidth;
const height = bitmap.height || bitmap.naturalHeight;
if (elements.canvas.width !== width || elements.canvas.height !== height) {
elements.canvas.width = width;
elements.canvas.height = height;
}
context.drawImage(bitmap, 0, 0, elements.canvas.width, elements.canvas.height);
}
function trimFrameQueue() {
const fps = state.session?.options.fps ?? 24;
const maxQueuedFrames = Math.max(60, Math.ceil(fps * 8));
const overflow = state.frames.length - maxQueuedFrames;
if (overflow <= 0) {
return;
}
const removed = state.frames.splice(0, overflow);
for (const frame of removed) {
releaseImage(frame.bitmap);
}
}
function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.generation += 1;
state.session = null;
state.frameCount = 0;
clearHideControlsTimer();
if (state.websocket) {
state.websocket.close(1000, 'client stopped');
state.websocket = null;
}
elements.audio.pause();
elements.audio.removeAttribute('src');
elements.audio.load();
if (state.raf) {
cancelAnimationFrame(state.raf);
state.raf = 0;
}
clearFrameQueue();
context.clearRect(0, 0, elements.canvas.width, elements.canvas.height);
elements.loader.hidden = true;
clearPlayerMessage();
setControlsVisible(true);
syncControlLabels();
if (shouldShowEntry) {
showEntry();
}
}
function clearFrameQueue() {
for (const frame of state.frames) {
releaseImage(frame.bitmap);
}
state.frames = [];
if (state.currentBitmap) {
releaseImage(state.currentBitmap);
state.currentBitmap = null;
}
}
async function decodeImage(blob) {
if ('createImageBitmap' in window) {
return createImageBitmap(blob);
}
const url = URL.createObjectURL(blob);
try {
const image = new Image();
image.decoding = 'async';
image.src = url;
await image.decode();
return image;
} finally {
URL.revokeObjectURL(url);
}
}
function releaseImage(image) {
if (typeof image?.close === 'function') {
image.close();
}
}
function setControlsVisible(visible) {
state.controlsVisible = visible;
elements.stage.classList.toggle('controls-hidden', !visible);
elements.controls.setAttribute('aria-hidden', String(!visible));
if (visible && !elements.audio.paused) {
scheduleControlsHide();
} else {
clearHideControlsTimer();
}
}
function scheduleControlsHide() {
clearHideControlsTimer();
state.hideControlsTimer = window.setTimeout(() => {
if (state.session && !elements.audio.paused) {
setControlsVisible(false);
}
}, 2400);
}
function clearHideControlsTimer() {
if (state.hideControlsTimer) {
window.clearTimeout(state.hideControlsTimer);
state.hideControlsTimer = 0;
}
}
function syncControlLabels() {
elements.playPause.textContent = elements.audio.paused ? 'Play' : 'Pause';
elements.mute.textContent = elements.audio.muted ? 'Unmute' : 'Mute';
}
function showEntry() {
elements.playerScreen.hidden = true;
elements.entryScreen.hidden = false;
void loadRecentUrls();
elements.url.focus();
}
function showPlayer() {
elements.entryScreen.hidden = true;
elements.playerScreen.hidden = false;
}
function showPlayerMessage(message) {
elements.playerMessage.textContent = message;
}
function clearPlayerMessage() {
elements.playerMessage.textContent = '';
}
function setEntryMessage(message) {
elements.entryMessage.textContent = message;
}
function setFormBusy(isBusy) {
elements.url.disabled = isBusy;
elements.next.disabled = isBusy;
for (const button of elements.recentList.querySelectorAll('button')) {
button.disabled = isBusy;
}
}
async function loadRecentUrls() {
try {
const response = await fetch('/api/recent-urls', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to load recent URLs.');
}
const payload = await response.json();
state.recentUrls = Array.isArray(payload.urls) ? payload.urls : [];
renderRecentUrls();
} catch {
state.recentUrls = [];
renderRecentUrls();
}
}
function renderRecentUrls() {
elements.recentList.replaceChildren(
...state.recentUrls.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'recent-url';
button.dataset.recentIndex = String(index);
button.textContent = item.displayUrl || item.url;
return button;
}),
);
elements.recentPanel.hidden = state.recentUrls.length === 0;
}