Let the queue page pin the frame animation
Waiting for the shuffle bag to land on the animation you are working on makes testing one of them tedious. The queue page now carries an Animation row next to Background: Auto keeps the per-track cycle, any other button pins the player to that animation until the mode changes again. The animation names live in config.js alongside their labels, so the server can validate them and the queue page can label its buttons; motion.js keys its registry by the same names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
This commit is contained in:
@@ -7,7 +7,8 @@ A YouTube playlist player for parties. Two browser windows run side by side:
|
|||||||
[Animations](#animations)). Consecutive videos overlap in a 5 second
|
[Animations](#animations)). Consecutive videos overlap in a 5 second
|
||||||
audio/video crossfade, so there is never silence between tracks.
|
audio/video crossfade, so there is never silence between tracks.
|
||||||
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
|
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
|
||||||
reorder, delete, and see how much playtime is left.
|
reorder, delete, see how much playtime is left, and pick the background and
|
||||||
|
animation.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -32,9 +33,9 @@ autoplay, so the first click is what unlocks audio. One click per session.
|
|||||||
whenever a length is still unknown.
|
whenever a length is still unknown.
|
||||||
- The queue is saved to `queue.json` on every change and restored at startup.
|
- The queue is saved to `queue.json` on every change and restored at startup.
|
||||||
- Settings live in `config.js`: overlap length, frame size, drift speed, corner
|
- Settings live in `config.js`: overlap length, frame size, drift speed, corner
|
||||||
screenshot interval and margin, port, and the selectable backgrounds. Anything
|
screenshot interval and margin, port, and the selectable backgrounds and
|
||||||
the browser needs must also be mirrored in the `/config.js` route in
|
animations. Anything the browser needs must also be mirrored in the
|
||||||
`server/index.js`.
|
`/config.js` route in `server/index.js`.
|
||||||
|
|
||||||
## Animations
|
## Animations
|
||||||
|
|
||||||
@@ -45,6 +46,11 @@ The video frame's movement changes with every track. Two animations ship today:
|
|||||||
screenshot of it appears in the next of the three other corners, going
|
screenshot of it appears in the next of the three other corners, going
|
||||||
clockwise. The frame never moves, so a screenshot can never cover it.
|
clockwise. The frame never moves, so a screenshot can never cover it.
|
||||||
|
|
||||||
|
The queue page's **Animation** buttons pin the player to one of them, which is
|
||||||
|
how you look at a single animation without waiting for the cycle to reach it.
|
||||||
|
**Auto** (the default) hands selection back to the shuffle bag. Switching takes
|
||||||
|
effect immediately; while pinned, track changes leave the animation running.
|
||||||
|
|
||||||
**The corners animation asks to share your screen.** The decks are cross-origin
|
**The corners animation asks to share your screen.** The decks are cross-origin
|
||||||
YouTube iframes, so nothing on the page can read their pixels — capturing the
|
YouTube iframes, so nothing on the page can read their pixels — capturing the
|
||||||
tab and cropping out the frame is the only way to photograph what is playing.
|
tab and cropping out the frame is the only way to photograph what is playing.
|
||||||
@@ -64,8 +70,9 @@ An animation is `start(context) => stop()`, where `context` is
|
|||||||
`{ element, capture }` — `capture` may be null, and is read late, so an
|
`{ element, capture }` — `capture` may be null, and is read late, so an
|
||||||
animation must cope with it being absent. `stop()` has to undo everything:
|
animation must cope with it being absent. `stop()` has to undo everything:
|
||||||
timers, listeners, and any DOM it added, because the next animation takes over
|
timers, listeners, and any DOM it added, because the next animation takes over
|
||||||
the same element immediately. Add it to the `ANIMATIONS` array and it joins the
|
the same element immediately. Register it under a new name in the `ANIMATIONS`
|
||||||
rotation.
|
map in `motion.js` **and** in the one in `config.js` (which supplies the label
|
||||||
|
for the queue page's button), and it joins the rotation.
|
||||||
|
|
||||||
Selection uses a shuffle bag (`createPicker`): every animation comes up exactly
|
Selection uses a shuffle bag (`createPicker`): every animation comes up exactly
|
||||||
once per pass, so they stay evenly used, and the same one never runs twice in a
|
once per pass, so they stay evenly used, and the same one never runs twice in a
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ export const CORNER_INTERVAL_SECONDS = 5;
|
|||||||
// Gap between the frame and the screen edge, as a fraction of the short side.
|
// Gap between the frame and the screen edge, as a fraction of the short side.
|
||||||
export const CORNER_MARGIN = 0.025;
|
export const CORNER_MARGIN = 0.025;
|
||||||
|
|
||||||
|
// The frame animations, keyed by the id sent over the socket. The names must
|
||||||
|
// match the registry in public/player/motion.js, which supplies the code.
|
||||||
|
export const ANIMATIONS = {
|
||||||
|
bounce: { label: 'Bounce' },
|
||||||
|
corners: { label: 'Corners' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cycle a fresh random animation on every track change. Anything else pins the
|
||||||
|
// player to that one animation, which is what the queue page's buttons are for.
|
||||||
|
export const AUTO_ANIMATION = 'auto';
|
||||||
|
export const DEFAULT_ANIMATION = AUTO_ANIMATION;
|
||||||
|
|
||||||
// Selectable fullscreen backgrounds, keyed by the id sent over the socket.
|
// Selectable fullscreen backgrounds, keyed by the id sent over the socket.
|
||||||
export const BACKGROUNDS = {
|
export const BACKGROUNDS = {
|
||||||
pink: { label: 'Pink', src: '/assets/PLAKAT_A2_pink_426x600mm.png' },
|
pink: { label: 'Pink', src: '/assets/PLAKAT_A2_pink_426x600mm.png' },
|
||||||
|
|||||||
+36
-14
@@ -11,6 +11,7 @@ import {
|
|||||||
BOUNCE_SPEED,
|
BOUNCE_SPEED,
|
||||||
CORNER_INTERVAL_SECONDS,
|
CORNER_INTERVAL_SECONDS,
|
||||||
CORNER_MARGIN,
|
CORNER_MARGIN,
|
||||||
|
AUTO_ANIMATION,
|
||||||
} from '/config.js';
|
} from '/config.js';
|
||||||
|
|
||||||
const ASPECT = 9 / 16;
|
const ASPECT = 9 / 16;
|
||||||
@@ -261,33 +262,54 @@ export function startCorners(context) {
|
|||||||
|
|
||||||
// --- registry -----------------------------------------------------------
|
// --- registry -----------------------------------------------------------
|
||||||
|
|
||||||
export const ANIMATIONS = [
|
// Keyed by the names in config.js, which is what travels over the socket.
|
||||||
{ name: 'bounce', start: startBounce },
|
export const ANIMATIONS = {
|
||||||
{ name: 'corners', start: startCorners },
|
bounce: startBounce,
|
||||||
];
|
corners: startCorners,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAMES = Object.keys(ANIMATIONS);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one animation at a time, swapping to a freshly picked one on next().
|
* Run one animation at a time. In auto mode next() swaps to a freshly picked
|
||||||
* Returns the handle the player drives from its track changes.
|
* one on every track change; pinned to a name (from the queue page, for
|
||||||
|
* testing) it stays put until the mode changes again.
|
||||||
*/
|
*/
|
||||||
export function startAnimationCycle(context) {
|
export function startAnimationCycle(context, mode = AUTO_ANIMATION) {
|
||||||
const picker = createPicker(ANIMATIONS.length);
|
const picker = createPicker(NAMES.length);
|
||||||
let stop = null;
|
let stop = null;
|
||||||
let current = null;
|
let current = null;
|
||||||
|
|
||||||
function next() {
|
function run(name) {
|
||||||
stop?.();
|
stop?.();
|
||||||
current = ANIMATIONS[picker.next()];
|
current = name;
|
||||||
stop = current.start(context);
|
stop = ANIMATIONS[name](context);
|
||||||
return current.name;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
function next() {
|
||||||
|
// Pinned: the track changed, but the operator asked for this one.
|
||||||
|
if (mode !== AUTO_ANIMATION) return current;
|
||||||
|
return run(NAMES[picker.next()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(value) {
|
||||||
|
mode = ANIMATIONS[value] ? value : AUTO_ANIMATION;
|
||||||
|
// Always restarts, so switching back to auto takes effect immediately
|
||||||
|
// rather than waiting out the current track.
|
||||||
|
return mode === AUTO_ANIMATION ? run(NAMES[picker.next()]) : run(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
next,
|
next,
|
||||||
|
setMode,
|
||||||
|
get mode() {
|
||||||
|
return mode;
|
||||||
|
},
|
||||||
get name() {
|
get name() {
|
||||||
return current?.name ?? null;
|
return current;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { connect } from '/shared/socket.js';
|
import { connect } from '/shared/socket.js';
|
||||||
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } from '/config.js';
|
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS, AUTO_ANIMATION } from '/config.js';
|
||||||
import { createPlayer, loadApi, STATE } from './yt.js';
|
import { createPlayer, loadApi, STATE } from './yt.js';
|
||||||
import { startAnimationCycle } from './motion.js';
|
import { startAnimationCycle } from './motion.js';
|
||||||
import { requestCapture } from './capture.js';
|
import { requestCapture } from './capture.js';
|
||||||
|
|
||||||
const TICK_MS = 250;
|
const TICK_MS = 250;
|
||||||
|
|
||||||
let state = { items: [], currentId: null, background: 'pink' };
|
let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION };
|
||||||
let decks = [];
|
let decks = [];
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let transition = null; // { until, duration, from, to } while crossfading
|
let transition = null; // { until, duration, from, to } while crossfading
|
||||||
@@ -74,7 +74,7 @@ async function start() {
|
|||||||
animationContext.capture = capture;
|
animationContext.capture = capture;
|
||||||
});
|
});
|
||||||
|
|
||||||
animations = startAnimationCycle(animationContext);
|
animations = startAnimationCycle(animationContext, state.animation);
|
||||||
|
|
||||||
decks = await Promise.all([
|
decks = await Promise.all([
|
||||||
makeDeck('deck-a', 0),
|
makeDeck('deck-a', 0),
|
||||||
@@ -325,8 +325,10 @@ initProbe();
|
|||||||
function onMessage(msg) {
|
function onMessage(msg) {
|
||||||
if (msg.type !== 'state') return;
|
if (msg.type !== 'state') return;
|
||||||
|
|
||||||
|
const previousAnimation = state.animation;
|
||||||
state = msg.state;
|
state = msg.state;
|
||||||
applyBackground(state.background);
|
applyBackground(state.background);
|
||||||
|
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
|
||||||
updateGate();
|
updateGate();
|
||||||
pumpProbe();
|
pumpProbe();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,14 @@
|
|||||||
<p id="empty">Nothing queued yet.</p>
|
<p id="empty">Nothing queued yet.</p>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span class="label">Background</span>
|
<div class="switcher">
|
||||||
<div id="backgrounds"></div>
|
<span class="label">Background</span>
|
||||||
|
<div id="backgrounds"></div>
|
||||||
|
</div>
|
||||||
|
<div class="switcher">
|
||||||
|
<span class="label">Animation</span>
|
||||||
|
<div id="animations"></div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script type="module" src="/queue/queue.js"></script>
|
<script type="module" src="/queue/queue.js"></script>
|
||||||
|
|||||||
+11
-3
@@ -241,13 +241,20 @@ footer {
|
|||||||
border-top: 1px solid #331630;
|
border-top: 1px solid #331630;
|
||||||
}
|
}
|
||||||
|
|
||||||
#backgrounds {
|
footer .switcher + .switcher {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backgrounds,
|
||||||
|
#animations {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#backgrounds button {
|
#backgrounds button,
|
||||||
|
#animations button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -257,7 +264,8 @@ footer {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#backgrounds button.active {
|
#backgrounds button.active,
|
||||||
|
#animations button.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
|||||||
+43
-16
@@ -1,7 +1,7 @@
|
|||||||
import { connect, formatDuration } from '/shared/socket.js';
|
import { connect, formatDuration } from '/shared/socket.js';
|
||||||
import { BACKGROUNDS } from '/config.js';
|
import { BACKGROUNDS, ANIMATIONS, AUTO_ANIMATION } from '/config.js';
|
||||||
|
|
||||||
let state = { items: [], currentId: null, background: 'pink' };
|
let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION };
|
||||||
// Last playhead report, kept with its arrival time so the countdown can be
|
// Last playhead report, kept with its arrival time so the countdown can be
|
||||||
// extrapolated smoothly between the 1 Hz updates.
|
// extrapolated smoothly between the 1 Hz updates.
|
||||||
let progress = { id: null, currentTime: 0, at: 0 };
|
let progress = { id: null, currentTime: 0, at: 0 };
|
||||||
@@ -11,6 +11,7 @@ const empty = document.getElementById('empty');
|
|||||||
const errorBox = document.getElementById('error');
|
const errorBox = document.getElementById('error');
|
||||||
const statusBox = document.getElementById('status');
|
const statusBox = document.getElementById('status');
|
||||||
const backgroundBox = document.getElementById('backgrounds');
|
const backgroundBox = document.getElementById('backgrounds');
|
||||||
|
const animationBox = document.getElementById('animations');
|
||||||
|
|
||||||
const socket = connect({ onMessage, onStatus });
|
const socket = connect({ onMessage, onStatus });
|
||||||
|
|
||||||
@@ -183,25 +184,50 @@ function clearDropMarkers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- background switcher ------------------------------------------------
|
// --- background / animation switchers -----------------------------------
|
||||||
|
|
||||||
const backgroundButtons = new Map();
|
/**
|
||||||
|
* A row of buttons that sets one field of the shared state. Returns a render
|
||||||
|
* function that lights up whichever key the server currently reports.
|
||||||
|
*/
|
||||||
|
function buildSwitcher(box, options, type, currentKey) {
|
||||||
|
const buttons = new Map();
|
||||||
|
|
||||||
for (const [key, { label }] of Object.entries(BACKGROUNDS)) {
|
for (const [key, label] of options) {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.textContent = label;
|
button.textContent = label;
|
||||||
button.addEventListener('click', () => socket.send({ type: 'setBackground', key }));
|
button.addEventListener('click', () => socket.send({ type, key }));
|
||||||
backgroundBox.appendChild(button);
|
box.appendChild(button);
|
||||||
backgroundButtons.set(key, button);
|
buttons.set(key, button);
|
||||||
}
|
|
||||||
|
|
||||||
function renderBackground() {
|
|
||||||
for (const [key, button] of backgroundButtons) {
|
|
||||||
button.classList.toggle('active', key === state.background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return function render() {
|
||||||
|
for (const [key, button] of buttons) {
|
||||||
|
button.classList.toggle('active', key === currentKey());
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderBackground = buildSwitcher(
|
||||||
|
backgroundBox,
|
||||||
|
Object.entries(BACKGROUNDS).map(([key, { label }]) => [key, label]),
|
||||||
|
'setBackground',
|
||||||
|
() => state.background,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto leads: it is the normal party setting, the named ones are for testing
|
||||||
|
// a single animation without waiting for the cycle to land on it.
|
||||||
|
const renderAnimation = buildSwitcher(
|
||||||
|
animationBox,
|
||||||
|
[
|
||||||
|
[AUTO_ANIMATION, 'Auto'],
|
||||||
|
...Object.entries(ANIMATIONS).map(([key, { label }]) => [key, label]),
|
||||||
|
],
|
||||||
|
'setAnimation',
|
||||||
|
() => state.animation,
|
||||||
|
);
|
||||||
|
|
||||||
// --- socket -------------------------------------------------------------
|
// --- socket -------------------------------------------------------------
|
||||||
|
|
||||||
function onMessage(msg) {
|
function onMessage(msg) {
|
||||||
@@ -209,6 +235,7 @@ function onMessage(msg) {
|
|||||||
state = msg.state;
|
state = msg.state;
|
||||||
render();
|
render();
|
||||||
renderBackground();
|
renderBackground();
|
||||||
|
renderAnimation();
|
||||||
} else if (msg.type === 'progress') {
|
} else if (msg.type === 'progress') {
|
||||||
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
|
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
|
||||||
renderTotals();
|
renderTotals();
|
||||||
|
|||||||
+11
-1
@@ -16,6 +16,8 @@ import {
|
|||||||
CORNER_INTERVAL_SECONDS,
|
CORNER_INTERVAL_SECONDS,
|
||||||
CORNER_MARGIN,
|
CORNER_MARGIN,
|
||||||
BACKGROUNDS,
|
BACKGROUNDS,
|
||||||
|
ANIMATIONS,
|
||||||
|
AUTO_ANIMATION,
|
||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
import { parseVideoId, fetchTitle } from './youtube.js';
|
import { parseVideoId, fetchTitle } from './youtube.js';
|
||||||
import * as store from './state.js';
|
import * as store from './state.js';
|
||||||
@@ -43,7 +45,9 @@ app.get('/config.js', (_req, res) => {
|
|||||||
`export const BOUNCE_SPEED = ${BOUNCE_SPEED};\n` +
|
`export const BOUNCE_SPEED = ${BOUNCE_SPEED};\n` +
|
||||||
`export const CORNER_INTERVAL_SECONDS = ${CORNER_INTERVAL_SECONDS};\n` +
|
`export const CORNER_INTERVAL_SECONDS = ${CORNER_INTERVAL_SECONDS};\n` +
|
||||||
`export const CORNER_MARGIN = ${CORNER_MARGIN};\n` +
|
`export const CORNER_MARGIN = ${CORNER_MARGIN};\n` +
|
||||||
`export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n`,
|
`export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n` +
|
||||||
|
`export const ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` +
|
||||||
|
`export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,6 +116,12 @@ const handlers = {
|
|||||||
commit();
|
commit();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setAnimation(_ws, msg) {
|
||||||
|
if (!store.isAnimation(msg.key)) return;
|
||||||
|
store.getState().animation = msg.key;
|
||||||
|
commit();
|
||||||
|
},
|
||||||
|
|
||||||
// Relayed straight through: this arrives once a second and must never
|
// Relayed straight through: this arrives once a second and must never
|
||||||
// touch the disk.
|
// touch the disk.
|
||||||
progress(_ws, msg) {
|
progress(_ws, msg) {
|
||||||
|
|||||||
+20
-2
@@ -6,7 +6,13 @@ import { dirname, join } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { DEFAULT_BACKGROUND, BACKGROUNDS } from '../config.js';
|
import {
|
||||||
|
DEFAULT_BACKGROUND,
|
||||||
|
BACKGROUNDS,
|
||||||
|
DEFAULT_ANIMATION,
|
||||||
|
ANIMATIONS,
|
||||||
|
AUTO_ANIMATION,
|
||||||
|
} from '../config.js';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const STORE_PATH = join(ROOT, 'queue.json');
|
const STORE_PATH = join(ROOT, 'queue.json');
|
||||||
@@ -15,12 +21,18 @@ const state = {
|
|||||||
items: [],
|
items: [],
|
||||||
currentId: null,
|
currentId: null,
|
||||||
background: DEFAULT_BACKGROUND,
|
background: DEFAULT_BACKGROUND,
|
||||||
|
animation: DEFAULT_ANIMATION,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getState() {
|
export function getState() {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Is this a name the player can be pinned to (or the auto cycle)? */
|
||||||
|
export function isAnimation(key) {
|
||||||
|
return key === AUTO_ANIMATION || key in ANIMATIONS;
|
||||||
|
}
|
||||||
|
|
||||||
export function load() {
|
export function load() {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(readFileSync(STORE_PATH, 'utf8'));
|
const saved = JSON.parse(readFileSync(STORE_PATH, 'utf8'));
|
||||||
@@ -35,6 +47,7 @@ export function load() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (saved.background in BACKGROUNDS) state.background = saved.background;
|
if (saved.background in BACKGROUNDS) state.background = saved.background;
|
||||||
|
if (isAnimation(saved.animation)) state.animation = saved.animation;
|
||||||
// Resume at the item that was playing, otherwise at the top of the list.
|
// Resume at the item that was playing, otherwise at the top of the list.
|
||||||
state.currentId = state.items.some((i) => i.id === saved.currentId)
|
state.currentId = state.items.some((i) => i.id === saved.currentId)
|
||||||
? saved.currentId
|
? saved.currentId
|
||||||
@@ -52,7 +65,12 @@ export function load() {
|
|||||||
*/
|
*/
|
||||||
export function save() {
|
export function save() {
|
||||||
const payload = JSON.stringify(
|
const payload = JSON.stringify(
|
||||||
{ items: state.items, currentId: state.currentId, background: state.background },
|
{
|
||||||
|
items: state.items,
|
||||||
|
currentId: state.currentId,
|
||||||
|
background: state.background,
|
||||||
|
animation: state.animation,
|
||||||
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user