initial commit
This commit is contained in:
413
public/app.js
Normal file
413
public/app.js
Normal file
@@ -0,0 +1,413 @@
|
||||
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'),
|
||||
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,
|
||||
};
|
||||
|
||||
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();
|
||||
startSession(payload);
|
||||
} catch (error) {
|
||||
stopSession();
|
||||
setEntryMessage(error.message);
|
||||
} finally {
|
||||
setFormBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
45
public/index.html
Normal file
45
public/index.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frame Stream Player</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<section id="entry-screen" class="entry-screen" aria-label="Stream URL">
|
||||
<form id="stream-form" class="url-form">
|
||||
<label class="sr-only" for="stream-url">Video stream URL</label>
|
||||
<input
|
||||
id="stream-url"
|
||||
name="url"
|
||||
type="url"
|
||||
placeholder="Video stream URL"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
<button id="next" type="submit">Next</button>
|
||||
</form>
|
||||
<div id="entry-message" class="message" role="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="player-screen" class="player-screen" aria-label="Player" hidden>
|
||||
<div id="video-stage" class="video-stage">
|
||||
<canvas id="screen" width="960" height="540" aria-label="Video frame"></canvas>
|
||||
<div id="loader" class="loader" aria-hidden="true"></div>
|
||||
<div id="player-message" class="player-message" role="status" aria-live="polite"></div>
|
||||
|
||||
<div id="controls" class="controls">
|
||||
<button id="back" type="button" class="control-button">Back</button>
|
||||
<button id="play-pause" type="button" class="control-button primary">Pause</button>
|
||||
<button id="mute" type="button" class="control-button">Mute</button>
|
||||
</div>
|
||||
</div>
|
||||
<audio id="audio" preload="none"></audio>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
253
public/styles.css
Normal file
253
public/styles.css
Normal file
@@ -0,0 +1,253 @@
|
||||
:root {
|
||||
--bg: #050505;
|
||||
--fg: #f6f1e8;
|
||||
--soft: rgba(246, 241, 232, 0.72);
|
||||
--glass: rgba(10, 10, 10, 0.62);
|
||||
--glass-strong: rgba(10, 10, 10, 0.82);
|
||||
--line: rgba(246, 241, 232, 0.18);
|
||||
--accent: #e8a84f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
.app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--fg);
|
||||
font-family: "Avenir Next", "Trebuchet MS", Verdana, sans-serif;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.entry-screen {
|
||||
display: grid;
|
||||
min-height: 100%;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at 24% 22%, rgba(232, 168, 79, 0.28), transparent 25rem),
|
||||
radial-gradient(circle at 78% 74%, rgba(79, 135, 232, 0.20), transparent 28rem),
|
||||
linear-gradient(135deg, #070707 0%, #11100d 48%, #030303 100%);
|
||||
}
|
||||
|
||||
.url-form {
|
||||
display: flex;
|
||||
width: min(760px, 100%);
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 24px 100px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.url-form input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 1rem 1.1rem;
|
||||
color: var(--fg);
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.url-form input::placeholder {
|
||||
color: rgba(246, 241, 232, 0.48);
|
||||
}
|
||||
|
||||
.url-form button,
|
||||
.control-button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--fg);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
.url-form button {
|
||||
min-width: 7rem;
|
||||
padding: 1rem 1.3rem;
|
||||
font-weight: 800;
|
||||
background: var(--fg);
|
||||
color: #070707;
|
||||
}
|
||||
|
||||
.message {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
max-width: min(720px, calc(100% - 2rem));
|
||||
transform: translateX(-50%);
|
||||
color: var(--soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.player-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: absolute;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 3px solid rgba(246, 241, 232, 0.18);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.loader[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.player-message {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
left: 50%;
|
||||
max-width: min(720px, calc(100% - 2rem));
|
||||
padding: 0.7rem 1rem;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--fg);
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.player-message:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
left: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--glass);
|
||||
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.42);
|
||||
backdrop-filter: blur(18px);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.video-stage.controls-hidden .controls {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
|
||||
.control-button {
|
||||
min-width: 6rem;
|
||||
padding: 0.85rem 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.control-button.primary {
|
||||
min-width: 8rem;
|
||||
border-color: transparent;
|
||||
color: #070707;
|
||||
background: var(--fg);
|
||||
}
|
||||
|
||||
audio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.url-form {
|
||||
border-radius: 28px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.url-form input,
|
||||
.url-form button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 0.8rem 0.7rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user