mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Smooth broadcast animation
This commit is contained in:
@@ -9,6 +9,7 @@ canvas.height = canvasSettings.height;
|
|||||||
const assets = new Map();
|
const assets = new Map();
|
||||||
const mediaCache = new Map();
|
const mediaCache = new Map();
|
||||||
const renderStates = new Map();
|
const renderStates = new Map();
|
||||||
|
const visibilityStates = new Map();
|
||||||
const animatedCache = new Map();
|
const animatedCache = new Map();
|
||||||
const blobCache = new Map();
|
const blobCache = new Map();
|
||||||
const animationFailures = new Map();
|
const animationFailures = new Map();
|
||||||
@@ -16,10 +17,12 @@ const audioControllers = new Map();
|
|||||||
const pendingAudioUnlock = new Set();
|
const pendingAudioUnlock = new Set();
|
||||||
const TARGET_FPS = 60;
|
const TARGET_FPS = 60;
|
||||||
const MIN_FRAME_TIME = 1000 / TARGET_FPS;
|
const MIN_FRAME_TIME = 1000 / TARGET_FPS;
|
||||||
|
const VISIBILITY_THRESHOLD = 0.01;
|
||||||
let lastRenderTime = 0;
|
let lastRenderTime = 0;
|
||||||
let frameScheduled = false;
|
let frameScheduled = false;
|
||||||
let pendingDraw = false;
|
let pendingDraw = false;
|
||||||
let renderIntervalId = null;
|
let renderIntervalId = null;
|
||||||
|
const pendingRemovals = new Set();
|
||||||
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
|
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
|
||||||
let layerOrder = [];
|
let layerOrder = [];
|
||||||
|
|
||||||
@@ -71,6 +74,26 @@ function getRenderOrder() {
|
|||||||
return [...getLayerOrder()].reverse().map((id) => assets.get(id)).filter(Boolean);
|
return [...getLayerOrder()].reverse().map((id) => assets.get(id)).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queueRemoval(assetId) {
|
||||||
|
if (assetId) {
|
||||||
|
pendingRemovals.add(assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAsset(assetId) {
|
||||||
|
assets.delete(assetId);
|
||||||
|
layerOrder = layerOrder.filter((id) => id !== assetId);
|
||||||
|
clearMedia(assetId);
|
||||||
|
renderStates.delete(assetId);
|
||||||
|
visibilityStates.delete(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingRemovals() {
|
||||||
|
if (!pendingRemovals.size) return;
|
||||||
|
pendingRemovals.forEach((id) => removeAsset(id));
|
||||||
|
pendingRemovals.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const socket = new SockJS('/ws');
|
const socket = new SockJS('/ws');
|
||||||
const stompClient = Stomp.over(socket);
|
const stompClient = Stomp.over(socket);
|
||||||
@@ -99,8 +122,13 @@ function renderAssets(list) {
|
|||||||
|
|
||||||
function storeAsset(asset, placement = 'keep') {
|
function storeAsset(asset, placement = 'keep') {
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
|
const wasExisting = assets.has(asset.id);
|
||||||
assets.set(asset.id, asset);
|
assets.set(asset.id, asset);
|
||||||
ensureLayerPosition(asset.id, placement);
|
ensureLayerPosition(asset.id, placement);
|
||||||
|
if (!wasExisting && !visibilityStates.has(asset.id)) {
|
||||||
|
const initialAlpha = 0; // Fade in newly discovered assets
|
||||||
|
visibilityStates.set(asset.id, { alpha: initialAlpha, targetHidden: !!asset.hidden });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchCanvasSettings() {
|
function fetchCanvasSettings() {
|
||||||
@@ -141,25 +169,15 @@ function handleEvent(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type === 'DELETED') {
|
if (event.type === 'DELETED') {
|
||||||
assets.delete(assetId);
|
removeAsset(assetId);
|
||||||
layerOrder = layerOrder.filter((id) => id !== assetId);
|
|
||||||
clearMedia(assetId);
|
|
||||||
renderStates.delete(assetId);
|
|
||||||
} else if (event.patch) {
|
} else if (event.patch) {
|
||||||
applyPatch(assetId, event.patch);
|
applyPatch(assetId, event.patch);
|
||||||
if (event.payload) {
|
if (event.payload) {
|
||||||
const payload = normalizePayload(event.payload);
|
const payload = normalizePayload(event.payload);
|
||||||
if (payload.hidden) {
|
if (payload.hidden) {
|
||||||
assets.delete(payload.id);
|
hideAssetWithTransition(payload);
|
||||||
layerOrder = layerOrder.filter((id) => id !== payload.id);
|
|
||||||
clearMedia(payload.id);
|
|
||||||
renderStates.delete(payload.id);
|
|
||||||
} else if (!assets.has(payload.id)) {
|
} else if (!assets.has(payload.id)) {
|
||||||
storeAsset(payload, 'append');
|
upsertVisibleAsset(payload, 'append');
|
||||||
ensureMedia(payload);
|
|
||||||
if (isAudioAsset(payload)) {
|
|
||||||
playAudioImmediately(payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === 'PLAY' && event.payload) {
|
} else if (event.type === 'PLAY' && event.payload) {
|
||||||
@@ -170,16 +188,9 @@ function handleEvent(event) {
|
|||||||
}
|
}
|
||||||
} else if (event.payload && !event.payload.hidden) {
|
} else if (event.payload && !event.payload.hidden) {
|
||||||
const payload = normalizePayload(event.payload);
|
const payload = normalizePayload(event.payload);
|
||||||
storeAsset(payload);
|
upsertVisibleAsset(payload);
|
||||||
ensureMedia(payload);
|
|
||||||
if (isAudioAsset(payload)) {
|
|
||||||
playAudioImmediately(payload);
|
|
||||||
}
|
|
||||||
} else if (event.payload && event.payload.hidden) {
|
} else if (event.payload && event.payload.hidden) {
|
||||||
assets.delete(event.payload.id);
|
hideAssetWithTransition(event.payload);
|
||||||
layerOrder = layerOrder.filter((id) => id !== event.payload.id);
|
|
||||||
clearMedia(event.payload.id);
|
|
||||||
renderStates.delete(event.payload.id);
|
|
||||||
}
|
}
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
@@ -188,27 +199,47 @@ function normalizePayload(payload) {
|
|||||||
return { ...payload };
|
return { ...payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideAssetWithTransition(asset) {
|
||||||
|
const payload = asset ? normalizePayload(asset) : null;
|
||||||
|
if (!payload?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = assets.get(payload.id);
|
||||||
|
if (!existing && (!Number.isFinite(payload.x) || !Number.isFinite(payload.y) || !Number.isFinite(payload.width) || !Number.isFinite(payload.height))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = normalizePayload({ ...(existing || {}), ...payload, hidden: true });
|
||||||
|
storeAsset(merged);
|
||||||
|
stopAudio(payload.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertVisibleAsset(asset, placement = 'keep') {
|
||||||
|
const payload = asset ? normalizePayload(asset) : null;
|
||||||
|
if (!payload?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const placementMode = assets.has(payload.id) ? 'keep' : placement;
|
||||||
|
storeAsset(payload, placementMode);
|
||||||
|
ensureMedia(payload);
|
||||||
|
if (isAudioAsset(payload)) {
|
||||||
|
playAudioImmediately(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleVisibilityEvent(event) {
|
function handleVisibilityEvent(event) {
|
||||||
const payload = event.payload ? normalizePayload(event.payload) : null;
|
const payload = event.payload ? normalizePayload(event.payload) : null;
|
||||||
const patch = event.patch;
|
const patch = event.patch;
|
||||||
const id = payload?.id || patch?.id || event.assetId;
|
const id = payload?.id || patch?.id || event.assetId;
|
||||||
|
|
||||||
if (payload?.hidden || patch?.hidden) {
|
if (payload?.hidden || patch?.hidden) {
|
||||||
assets.delete(id);
|
hideAssetWithTransition({ id, ...payload, ...patch });
|
||||||
layerOrder = layerOrder.filter((assetId) => assetId !== id);
|
|
||||||
clearMedia(id);
|
|
||||||
renderStates.delete(id);
|
|
||||||
draw();
|
draw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const placement = assets.has(payload.id) ? 'keep' : 'append';
|
const placement = assets.has(payload.id) ? 'keep' : 'append';
|
||||||
storeAsset(payload, placement);
|
upsertVisibleAsset(payload, placement);
|
||||||
ensureMedia(payload);
|
|
||||||
if (isAudioAsset(payload)) {
|
|
||||||
playAudioImmediately(payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch && id) {
|
if (patch && id) {
|
||||||
@@ -232,10 +263,7 @@ function applyPatch(assetId, patch) {
|
|||||||
const merged = normalizePayload({ ...existing, ...sanitizedPatch });
|
const merged = normalizePayload({ ...existing, ...sanitizedPatch });
|
||||||
const isAudio = isAudioAsset(merged);
|
const isAudio = isAudioAsset(merged);
|
||||||
if (sanitizedPatch.hidden) {
|
if (sanitizedPatch.hidden) {
|
||||||
assets.delete(assetId);
|
hideAssetWithTransition(merged);
|
||||||
layerOrder = layerOrder.filter((id) => id !== assetId);
|
|
||||||
clearMedia(assetId);
|
|
||||||
renderStates.delete(assetId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetLayer = Number.isFinite(patch.layer)
|
const targetLayer = Number.isFinite(patch.layer)
|
||||||
@@ -249,17 +277,6 @@ function applyPatch(assetId, patch) {
|
|||||||
}
|
}
|
||||||
storeAsset(merged);
|
storeAsset(merged);
|
||||||
ensureMedia(merged);
|
ensureMedia(merged);
|
||||||
renderStates.set(assetId, { ...renderStates.get(assetId), ...pickTransform(merged) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickTransform(asset) {
|
|
||||||
return {
|
|
||||||
x: asset.x,
|
|
||||||
y: asset.y,
|
|
||||||
width: asset.width,
|
|
||||||
height: asset.height,
|
|
||||||
rotation: asset.rotation
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
@@ -289,18 +306,27 @@ function draw() {
|
|||||||
function renderFrame() {
|
function renderFrame() {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
getRenderOrder().forEach(drawAsset);
|
getRenderOrder().forEach(drawAsset);
|
||||||
|
flushPendingRemovals();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAsset(asset) {
|
function drawAsset(asset) {
|
||||||
|
const visibility = getVisibilityState(asset);
|
||||||
|
if (visibility.alpha <= VISIBILITY_THRESHOLD && asset.hidden) {
|
||||||
|
queueRemoval(asset.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const renderState = smoothState(asset);
|
const renderState = smoothState(asset);
|
||||||
const halfWidth = renderState.width / 2;
|
const halfWidth = renderState.width / 2;
|
||||||
const halfHeight = renderState.height / 2;
|
const halfHeight = renderState.height / 2;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
ctx.globalAlpha = Math.max(0, Math.min(1, visibility.alpha));
|
||||||
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
||||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||||
|
|
||||||
if (isAudioAsset(asset)) {
|
if (isAudioAsset(asset)) {
|
||||||
autoStartAudio(asset);
|
if (!asset.hidden) {
|
||||||
|
autoStartAudio(asset);
|
||||||
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -315,6 +341,17 @@ function drawAsset(asset) {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibilityState(asset) {
|
||||||
|
const current = visibilityStates.get(asset.id) || {};
|
||||||
|
const targetAlpha = asset.hidden ? 0 : 1;
|
||||||
|
const startingAlpha = Number.isFinite(current.alpha) ? current.alpha : 0;
|
||||||
|
const factor = asset.hidden ? 0.18 : 0.2;
|
||||||
|
const nextAlpha = lerp(startingAlpha, targetAlpha, factor);
|
||||||
|
const state = { alpha: nextAlpha, targetHidden: !!asset.hidden };
|
||||||
|
visibilityStates.set(asset.id, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
function smoothState(asset) {
|
function smoothState(asset) {
|
||||||
const previous = renderStates.get(asset.id) || { ...asset };
|
const previous = renderStates.get(asset.id) || { ...asset };
|
||||||
const factor = 0.15;
|
const factor = 0.15;
|
||||||
|
|||||||
Reference in New Issue
Block a user