@moood/render 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/cli.js +34 -0
- package/engine/actions.js +69 -0
- package/engine/captions.js +20 -0
- package/engine/directions.js +44 -0
- package/engine/engine.js +1456 -0
- package/engine/engine.json +1 -0
- package/engine/gl.js +1027 -0
- package/engine/kit.js +142 -0
- package/engine/live.js +23 -0
- package/engine/mic.js +76 -0
- package/engine/music.css +28 -0
- package/engine/music.js +279 -0
- package/engine/nav.css +66 -0
- package/engine/render.html +50 -0
- package/engine/scene.css +124 -0
- package/engine/scenestate.js +220 -0
- package/engine/select.js +301 -0
- package/engine/speech.js +150 -0
- package/engine/strip.js +153 -0
- package/engine/viewer.js +216 -0
- package/engine/volume.js +56 -0
- package/engine/widgets.js +88 -0
- package/index.js +70 -0
- package/package.json +13 -6
- package/scene.js +43 -0
package/engine/kit.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// What a scene's code shares, and the tools for moving things well.
|
|
2
|
+
// anim — the animator's toolkit, for every scene: easing curves (a movement starts and stops as
|
|
3
|
+
// a body does), angles the short way round, smooth noise and steady random (life that doesn't
|
|
4
|
+
// repeat, the same every time), a two-bone joint (an elbow, a knee: the hand or the foot set
|
|
5
|
+
// where it must be), poses mixed by weight.
|
|
6
|
+
// the world's modules — code the world's scenes share (its people, its horses, its things):
|
|
7
|
+
// modules/<name>.js in the world's repository, the body of function (anim, data) that returns
|
|
8
|
+
// what it offers (data: modules/<name>.data.json, if there is one). A scene lists the ones it
|
|
9
|
+
// uses (spec.modules) and gets them as kit.<name>, as they are at the commit it is shown at.
|
|
10
|
+
// the scene's lib — code its own elements and scripts share: spec.lib, the body of
|
|
11
|
+
// function (anim, kit) that returns what it offers.
|
|
12
|
+
// Painting code sees them as e.anim, e.kit, e.lib; scripts as scene.anim, scene.kit, scene.lib.
|
|
13
|
+
// Where a spec's modules come from (the page says, with the spec):
|
|
14
|
+
// {world, refs} — the world's id and the modules by their content's sha ({name: {code, data}},
|
|
15
|
+
// as the scene's payload has them: the commit it shows); a module the refs don't name (a draft
|
|
16
|
+
// that has just listed it) is the world's main's now (/api/worlds/<world>/modules). The files
|
|
17
|
+
// are read by /api/worlds/<world>/blobs/<sha> — the same for good, fetched once.
|
|
18
|
+
// {texts} — the modules themselves, {name: {code, data}} (the renderer is given them).
|
|
19
|
+
const MooodKit = (() => {
|
|
20
|
+
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
|
|
21
|
+
const mix = (a, b, u) => a + (b - a) * u;
|
|
22
|
+
// u (0…1) of the way between a and b that v is, clamped
|
|
23
|
+
const span = (v, a, b) => clamp((v - a) / (b - a));
|
|
24
|
+
// easing: how the 0…1 of a movement's time becomes the 0…1 of its way
|
|
25
|
+
const ease = {
|
|
26
|
+
linear: u => u,
|
|
27
|
+
smooth: u => (1 - Math.cos(Math.PI * u)) / 2, // starts and stops gently (the default)
|
|
28
|
+
in: u => u * u * u, // gathers speed, ends at full speed
|
|
29
|
+
out: u => 1 - Math.pow(1 - u, 3), // sets off at once, settles
|
|
30
|
+
inOut: u => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2),
|
|
31
|
+
back: u => { const c = 1.70158; return 1 + (c + 1) * Math.pow(u - 1, 3) + c * Math.pow(u - 1, 2); }, // a little past, and back
|
|
32
|
+
spring: u => (u >= 1 ? 1 : 1 - Math.exp(-6 * u) * Math.cos(9 * u)), // overshoots, wobbles, rests
|
|
33
|
+
anticipate: u => { const c = 1.4; return u * u * ((c + 1) * u - c); }, // back a little first, then go
|
|
34
|
+
};
|
|
35
|
+
const easeOf = e => (typeof e === 'function' ? e : ease[e] || ease.smooth);
|
|
36
|
+
// angles (radians): the short way round from a to b
|
|
37
|
+
const turn = (a, b, u) => { let d = ((b - a) % (2 * Math.PI) + 3 * Math.PI) % (2 * Math.PI) - Math.PI; return a + d * u; };
|
|
38
|
+
// steady random: the same n gives the same 0…1, however many other calls came before
|
|
39
|
+
const hash = (n, seed = 0) => { let h = Math.imul((n * 374761393 + seed * 668265263) | 0, 1274126177); h = (h ^ (h >>> 13)) >>> 0; h = Math.imul(h, 1103515245) >>> 0; return ((h ^ (h >>> 16)) >>> 0) / 4294967296; };
|
|
40
|
+
// smooth noise along x (−1…1): for breathing, swaying, drifting that never repeats
|
|
41
|
+
const noise = (x, seed = 0) => { const i = Math.floor(x), f = x - i, s = f * f * (3 - 2 * f); return mix(hash(i, seed) * 2 - 1, hash(i + 1, seed) * 2 - 1, s); };
|
|
42
|
+
// a few octaves of it, slower ones stronger
|
|
43
|
+
const drift = (x, seed = 0) => noise(x, seed) * 0.6 + noise(x * 2.1 + 7, seed + 1) * 0.3 + noise(x * 4.3 + 13, seed + 2) * 0.1;
|
|
44
|
+
// a two-bone joint: from the root (x0, y0) to the end (x1, y1) with bones a and b; bend 1 or −1 —
|
|
45
|
+
// which side the joint goes. Returns the joint [x, y] (the end out of reach: the limb straight toward it)
|
|
46
|
+
const joint = (x0, y0, x1, y1, a, b, bend = 1) => {
|
|
47
|
+
const dx = x1 - x0, dy = y1 - y0, d = Math.min(Math.hypot(dx, dy), a + b - 1e-6), ang = Math.atan2(dy, dx);
|
|
48
|
+
const k = clamp((a * a + d * d - b * b) / (2 * a * d || 1), -1, 1), off = Math.acos(k) * bend;
|
|
49
|
+
return [x0 + Math.cos(ang + off) * a, y0 + Math.sin(ang + off) * a];
|
|
50
|
+
};
|
|
51
|
+
// poses mixed: pose objects (numbers, or nested objects/arrays of them) weighted [[pose, weight], …]
|
|
52
|
+
const blend = list => {
|
|
53
|
+
const total = list.reduce((s, [, w]) => s + w, 0) || 1, [first] = list[0];
|
|
54
|
+
const go = (sel) => {
|
|
55
|
+
const v0 = sel(first);
|
|
56
|
+
if (typeof v0 === 'number') return list.reduce((s, [p, w]) => s + (sel(p) ?? v0) * w, 0) / total;
|
|
57
|
+
if (Array.isArray(v0)) return v0.map((_, i) => go(p => sel(p)?.[i]));
|
|
58
|
+
if (v0 && typeof v0 === 'object') return Object.fromEntries(Object.keys(v0).map(k => [k, go(p => sel(p)?.[k])]));
|
|
59
|
+
return v0;
|
|
60
|
+
};
|
|
61
|
+
return go(p => p);
|
|
62
|
+
};
|
|
63
|
+
const anim = Object.freeze({ clamp, mix, span, ease, easeOf, turn, hash, noise, drift, joint, blend });
|
|
64
|
+
|
|
65
|
+
const report = (who, err) => (window.__mooodErrors = window.__mooodErrors || []).push(`${who}: ${err && err.message || err}`);
|
|
66
|
+
// files by their content's sha (the same for good), modules made from them, libs made: once each
|
|
67
|
+
const files = new Map(), made = new Map(), libs = new Map(), mains = new Map();
|
|
68
|
+
const fileOf = (world, sha) => {
|
|
69
|
+
if (!files.has(sha)) files.set(sha, fetch(`/api/worlds/${world}/blobs/${sha}`).then(r => (r.ok ? r.text() : Promise.reject(new Error(`${sha}: ${r.status}`)))));
|
|
70
|
+
return files.get(sha);
|
|
71
|
+
};
|
|
72
|
+
const mainOf = world => {
|
|
73
|
+
if (!mains.has(world)) mains.set(world, fetch(`/api/worlds/${world}/modules`).then(r => (r.ok ? r.json() : {})).catch(() => ({})));
|
|
74
|
+
return mains.get(world);
|
|
75
|
+
};
|
|
76
|
+
function make(name, key, code, data) {
|
|
77
|
+
if (!made.has(key)) {
|
|
78
|
+
let value = {};
|
|
79
|
+
try { value = new Function('anim', 'data', String(code))(anim, data) || {}; } catch (err) { report(`module ${name}`, err); }
|
|
80
|
+
made.set(key, value);
|
|
81
|
+
}
|
|
82
|
+
return made.get(key);
|
|
83
|
+
}
|
|
84
|
+
// a spec's modules, as `source` says: {mods: {name: what it offers}, key: which they are} — or a
|
|
85
|
+
// promise of it
|
|
86
|
+
function modulesOf(spec, source) {
|
|
87
|
+
const names = [...new Set((Array.isArray(spec?.modules) ? spec.modules : []).filter(n => typeof n === 'string'))].sort();
|
|
88
|
+
const out = (mods, keys) => ({ mods, key: keys.join('\n') });
|
|
89
|
+
if (!names.length) return out({}, []);
|
|
90
|
+
if (source?.texts) {
|
|
91
|
+
const mods = {}, keys = [];
|
|
92
|
+
for (const n of names) {
|
|
93
|
+
const m = source.texts[n];
|
|
94
|
+
if (m?.code == null) { report(`module ${n}`, 'not in this world'); continue; }
|
|
95
|
+
const key = `${n} ${m.code} ${JSON.stringify(m.data ?? null)}`;
|
|
96
|
+
mods[n] = make(n, key, m.code, m.data ?? null); keys.push(key);
|
|
97
|
+
}
|
|
98
|
+
return out(mods, keys);
|
|
99
|
+
}
|
|
100
|
+
const world = source?.world;
|
|
101
|
+
if (world == null) { report('modules', 'the page did not say whose they are'); return out({}, []); }
|
|
102
|
+
const keyOf = (n, r) => `${n} ${r.code} ${r.data}`;
|
|
103
|
+
const built = refs => {
|
|
104
|
+
const mods = {}, keys = [];
|
|
105
|
+
for (const n of names) if (refs[n]?.code && made.has(keyOf(n, refs[n]))) { mods[n] = made.get(keyOf(n, refs[n])); keys.push(keyOf(n, refs[n])); }
|
|
106
|
+
return out(mods, keys);
|
|
107
|
+
};
|
|
108
|
+
const refs0 = source.refs || {};
|
|
109
|
+
if (names.every(n => refs0[n]?.code && made.has(keyOf(n, refs0[n])))) return built(refs0);
|
|
110
|
+
return (names.every(n => refs0[n]?.code) ? Promise.resolve(refs0) : mainOf(world).then(now => ({ ...now, ...refs0 }))).then(async refs => {
|
|
111
|
+
await Promise.all(names.map(async n => {
|
|
112
|
+
const r = refs[n];
|
|
113
|
+
if (!r?.code) { report(`module ${n}`, 'not in this world'); return; }
|
|
114
|
+
if (made.has(keyOf(n, r))) return;
|
|
115
|
+
try {
|
|
116
|
+
const [code, data] = await Promise.all([fileOf(world, r.code), r.data ? fileOf(world, r.data).then(JSON.parse) : null]);
|
|
117
|
+
make(n, keyOf(n, r), code, data);
|
|
118
|
+
} catch (err) { report(`module ${n}`, err); }
|
|
119
|
+
}));
|
|
120
|
+
return built(refs);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// what a spec's code shares, once its modules are here: { kit, lib } — or a promise of it (they
|
|
124
|
+
// are still on their way); `source`: where its modules come from (above). The same modules are
|
|
125
|
+
// the same kit, and a lib on them is made once.
|
|
126
|
+
const kits = new Map();
|
|
127
|
+
function shared(spec, source) {
|
|
128
|
+
const done = ({ mods, key }) => {
|
|
129
|
+
if (!kits.has(key)) kits.set(key, Object.freeze(mods));
|
|
130
|
+
const kit = kits.get(key), lib = typeof spec?.lib === 'string' && spec.lib.trim() ? spec.lib : null;
|
|
131
|
+
if (lib && !libs.has(`${key}\n\n${lib}`)) {
|
|
132
|
+
let value = {};
|
|
133
|
+
try { value = new Function('anim', 'kit', lib)(anim, kit) || {}; } catch (err) { report('lib', err); }
|
|
134
|
+
libs.set(`${key}\n\n${lib}`, value);
|
|
135
|
+
}
|
|
136
|
+
return { kit, lib: lib ? libs.get(`${key}\n\n${lib}`) : {} };
|
|
137
|
+
};
|
|
138
|
+
const m = modulesOf(spec, source);
|
|
139
|
+
return m instanceof Promise ? m.then(done) : done(m);
|
|
140
|
+
}
|
|
141
|
+
return { anim, shared };
|
|
142
|
+
})();
|
package/engine/live.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A live channel from the server (a game's, or the artist's work): a WebSocket — unlike a
|
|
2
|
+
// long-lived HTTP request (SSE), it doesn't count against the browser's six connections to a site,
|
|
3
|
+
// which a few open tabs would use up and leave every next page waiting. Each message is an event,
|
|
4
|
+
// most marked (_at) with where it is in the channel; a closed socket is opened again from the last
|
|
5
|
+
// one, so nothing is missed.
|
|
6
|
+
// MooodLive.open(path, since, handle) — handle(event) for every event but pings
|
|
7
|
+
const MooodLive = (() => {
|
|
8
|
+
function open(path, since, handle) {
|
|
9
|
+
let leaving = false;
|
|
10
|
+
addEventListener('pagehide', () => { leaving = true; });
|
|
11
|
+
const connect = () => {
|
|
12
|
+
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}?since=${encodeURIComponent(since)}`);
|
|
13
|
+
ws.onmessage = m => {
|
|
14
|
+
const ev = JSON.parse(m.data);
|
|
15
|
+
if (ev._at) since = ev._at;
|
|
16
|
+
if (ev.type !== 'ping') handle(ev);
|
|
17
|
+
};
|
|
18
|
+
ws.onclose = () => { if (!leaving) setTimeout(connect, 2000); };
|
|
19
|
+
};
|
|
20
|
+
connect();
|
|
21
|
+
}
|
|
22
|
+
return { open };
|
|
23
|
+
})();
|
package/engine/mic.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Speaking. While the player holds the microphone, the page streams it to /listen (the sound
|
|
2
|
+
// service: PCM16, 24 kHz, mono, binary frames), telling what scene it is — its words help hearing
|
|
3
|
+
// names right — and the words come back as they are heard; letting go sends {"type": "done"} and
|
|
4
|
+
// the final text follows.
|
|
5
|
+
const MooodMic = (() => {
|
|
6
|
+
// a tap on the audio graph: the microphone at the context's own rate, handed on at 24 kHz
|
|
7
|
+
// (linear interpolation across blocks), whatever rate the browser runs audio at
|
|
8
|
+
const TAP = `registerProcessor('tap', class extends AudioWorkletProcessor {
|
|
9
|
+
constructor() { super(); this.step = sampleRate / 24000; this.pos = 0; this.prev = 0; }
|
|
10
|
+
process(inputs) {
|
|
11
|
+
const ch = inputs[0][0];
|
|
12
|
+
if (!ch) return true;
|
|
13
|
+
const out = [];
|
|
14
|
+
let pos = this.pos; // where the next sample falls, in this block's samples (-1..0: just before it)
|
|
15
|
+
while (pos < ch.length - 1) {
|
|
16
|
+
const i = Math.floor(pos), f = pos - i, a = i < 0 ? this.prev : ch[i];
|
|
17
|
+
out.push(a + (ch[i + 1] - a) * f);
|
|
18
|
+
pos += this.step;
|
|
19
|
+
}
|
|
20
|
+
this.pos = pos - ch.length; this.prev = ch[ch.length - 1];
|
|
21
|
+
if (out.length) this.port.postMessage(Float32Array.from(out));
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
});`;
|
|
25
|
+
let tapUrl = null;
|
|
26
|
+
|
|
27
|
+
// starts an utterance in the scene `spec`; on: { partial(text), final(text), error(text) }.
|
|
28
|
+
// Returns { done(), cancel() }.
|
|
29
|
+
async function listen(spec, on) {
|
|
30
|
+
const q = new URLSearchParams({ title: spec?.title || '', caption: spec?.caption || '' });
|
|
31
|
+
for (const o of spec?.objects || []) if (o.name) q.append('name', o.name);
|
|
32
|
+
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/listen?${q}`);
|
|
33
|
+
ws.binaryType = 'arraybuffer';
|
|
34
|
+
const early = []; // what was said before the socket opened
|
|
35
|
+
ws.onopen = () => { for (const b of early.splice(0)) ws.send(b); };
|
|
36
|
+
let over = false; // the final text or an error came
|
|
37
|
+
ws.onmessage = e => {
|
|
38
|
+
const m = JSON.parse(e.data);
|
|
39
|
+
if (m.type === 'partial') on.partial(m.text);
|
|
40
|
+
else if (m.type === 'final') { over = true; on.final(m.text); }
|
|
41
|
+
else if (m.type === 'error') { over = true; on.error(m.text); }
|
|
42
|
+
};
|
|
43
|
+
ws.onclose = () => { if (!over) on.error(''); };
|
|
44
|
+
let media, ctx;
|
|
45
|
+
try {
|
|
46
|
+
media = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
47
|
+
ctx = new AudioContext();
|
|
48
|
+
tapUrl = tapUrl || URL.createObjectURL(new Blob([TAP], { type: 'text/javascript' }));
|
|
49
|
+
await ctx.audioWorklet.addModule(tapUrl);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
ws.close();
|
|
52
|
+
media?.getTracks().forEach(t => t.stop());
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
const tap = new AudioWorkletNode(ctx, 'tap'), mute = ctx.createGain();
|
|
56
|
+
mute.gain.value = 0;
|
|
57
|
+
tap.port.onmessage = e => {
|
|
58
|
+
const f = e.data, pcm = new Int16Array(f.length);
|
|
59
|
+
for (let i = 0; i < f.length; i++) pcm[i] = Math.max(-1, Math.min(1, f[i])) * 0x7fff;
|
|
60
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(pcm.buffer); else if (ws.readyState === WebSocket.CONNECTING) early.push(pcm.buffer);
|
|
61
|
+
};
|
|
62
|
+
ctx.createMediaStreamSource(media).connect(tap);
|
|
63
|
+
tap.connect(mute).connect(ctx.destination); // pulled by the graph, heard by no one
|
|
64
|
+
const close = () => { media.getTracks().forEach(t => t.stop()); ctx.close(); };
|
|
65
|
+
return {
|
|
66
|
+
done() {
|
|
67
|
+
close();
|
|
68
|
+
const send = () => ws.send(JSON.stringify({ type: 'done' }));
|
|
69
|
+
if (ws.readyState === WebSocket.OPEN) send(); else ws.addEventListener('open', send);
|
|
70
|
+
},
|
|
71
|
+
cancel() { close(); ws.close(); },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { listen };
|
|
76
|
+
})();
|
package/engine/music.css
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* The world's music panel (music.js): descriptions with weights, settings, save — on the scene
|
|
2
|
+
page (listening) and the author's page (tuning). */
|
|
3
|
+
#music { position: fixed; top: 16px; left: 16px; bottom: 16px; width: 360px; display: flex; flex-direction: column; border-radius: 16px; background: rgba(8, 10, 16, 0.86); overflow: hidden; z-index: 3; font-size: 13px; color: #e6e8ee; font-family: Georgia, "Times New Roman", serif; }
|
|
4
|
+
/* the panel brings its own buttons: it sits on the dark scene page and the light author's page alike */
|
|
5
|
+
#music button { border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.06); color: inherit; font: inherit; font-size: 14px; padding: 4px 12px; border-radius: 999px; cursor: pointer; }
|
|
6
|
+
#music button:hover { background: rgba(255, 255, 255, 0.14); }
|
|
7
|
+
#music[hidden] { display: none; }
|
|
8
|
+
#music .music-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
|
|
9
|
+
#music .music-head .title { flex: 1; font-size: 15px; }
|
|
10
|
+
#music .play { min-width: 38px; }
|
|
11
|
+
#music .status { padding: 6px 12px 0; font-size: 12px; font-style: italic; opacity: 0.6; min-height: 1.4em; }
|
|
12
|
+
#music .prompts { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 8px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
13
|
+
#music .prompts li { padding: 6px 8px; border-radius: 10px; background: rgba(255, 255, 255, 0.03); opacity: 0.6; }
|
|
14
|
+
#music .prompts li.on { opacity: 1; background: rgba(255, 214, 150, 0.08); }
|
|
15
|
+
#music textarea, #music .add input { width: 100%; box-sizing: border-box; resize: none; field-sizing: content; border: 0; outline: none; background: transparent; color: inherit; font: inherit; line-height: 1.35; }
|
|
16
|
+
#music .row, #music .knobs label { display: flex; align-items: center; gap: 8px; }
|
|
17
|
+
#music input[type=range] { flex: 1; accent-color: #e8c48a; }
|
|
18
|
+
#music .val { width: 3.2em; text-align: right; font-variant-numeric: tabular-nums; opacity: 0.75; font-size: 12px; }
|
|
19
|
+
#music .del { padding: 0 7px; font-size: 12px; }
|
|
20
|
+
#music .add { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
|
21
|
+
#music .add input { border-bottom: 1px solid rgba(255, 255, 255, 0.15); }
|
|
22
|
+
#music .knobs { padding: 6px 12px; display: flex; flex-direction: column; gap: 4px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
|
23
|
+
#music .knobs .name { width: 5.5em; opacity: 0.8; }
|
|
24
|
+
#music .knobs label.auto input[type=range] { opacity: 0.35; }
|
|
25
|
+
#music .foot { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 12px 12px; }
|
|
26
|
+
#music.readonly .add, #music.readonly .foot, #music.readonly .del { display: none; }
|
|
27
|
+
#music.readonly textarea, #music.readonly input[type=range] { pointer-events: none; }
|
|
28
|
+
@media (max-width: 700px) { #music { left: 8px; right: 8px; width: auto; top: 8px; bottom: auto; max-height: 50vh; } }
|
package/engine/music.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// A world's music: live instrumental from Lyria RealTime (Gemini API), steered by weighted
|
|
2
|
+
// text descriptions. The artist writes many (most at weight 0); the author tunes them with
|
|
3
|
+
// sliders, adds their own lines, removes the artist's, and saves — a new version of the scene.
|
|
4
|
+
// The stream starts at the first touch of the page (browsers don't let sound start by itself)
|
|
5
|
+
// and follows the open world; changes are sent live while it plays.
|
|
6
|
+
// It comes through our server (music.mjs), so the Gemini key never reaches the browser:
|
|
7
|
+
// GET /api/music/stream — frames [type:1][length:4 LE][payload], 1 = PCM16 48 kHz stereo,
|
|
8
|
+
// 2 = a JSON event; POST /api/music/control — prompts, settings, play / pause / reset.
|
|
9
|
+
// Weight 0 means "not now" and is never sent (the API refuses zero weights).
|
|
10
|
+
const MooodMusic = (() => {
|
|
11
|
+
const RATE = 48000, LEAD = 0.25; // seconds of audio queued ahead
|
|
12
|
+
const store = {
|
|
13
|
+
get: k => { try { return localStorage.getItem(k); } catch { return null; } },
|
|
14
|
+
set: (k, v) => { try { localStorage.setItem(k, v); } catch {} },
|
|
15
|
+
};
|
|
16
|
+
let ui = null, onSave = null;
|
|
17
|
+
let music = null, savedJson = ''; // what the panel shows / what the scene has
|
|
18
|
+
let sid = null, opening = null, ctx = null, out = null, nextT = 0;
|
|
19
|
+
let playing = false, starting = false, sentBpm = null, pushTimer = null;
|
|
20
|
+
let wanted = store.get('moood:music') !== 'off';
|
|
21
|
+
MooodVolume.on((kind, v) => { if (kind === 'music' && playing && out) out.gain.setTargetAtTime(v, ctx.currentTime, 0.1); });
|
|
22
|
+
|
|
23
|
+
const clone = m => JSON.parse(JSON.stringify(m || { prompts: [] }));
|
|
24
|
+
const active = () => (music?.prompts || []).filter(p => p.text.trim() && p.weight > 0);
|
|
25
|
+
|
|
26
|
+
function status(text) { if (ui) ui.status.textContent = text; }
|
|
27
|
+
|
|
28
|
+
// ── the stream ───────────────────────────────────────────────────────────
|
|
29
|
+
function open() {
|
|
30
|
+
if (sid) return Promise.resolve();
|
|
31
|
+
if (opening) return opening;
|
|
32
|
+
opening = new Promise((resolve, reject) => {
|
|
33
|
+
const id = 's-' + Math.random().toString(36).slice(2, 12);
|
|
34
|
+
status('подключаюсь…');
|
|
35
|
+
fetch(`/api/music/stream?sid=${id}`).then(async r => {
|
|
36
|
+
if (!r.ok || !r.body) throw new Error('поток музыки не открылся');
|
|
37
|
+
const reader = r.body.getReader();
|
|
38
|
+
let buf = new Uint8Array(0);
|
|
39
|
+
for (;;) {
|
|
40
|
+
const { value, done } = await reader.read();
|
|
41
|
+
if (done) break;
|
|
42
|
+
const joined = new Uint8Array(buf.length + value.length);
|
|
43
|
+
joined.set(buf); joined.set(value, buf.length);
|
|
44
|
+
buf = joined;
|
|
45
|
+
while (buf.length >= 5) {
|
|
46
|
+
const len = buf[1] | (buf[2] << 8) | (buf[3] << 16) | (buf[4] << 24);
|
|
47
|
+
if (buf.length < 5 + len) break;
|
|
48
|
+
const type = buf[0], payload = buf.slice(5, 5 + len);
|
|
49
|
+
buf = buf.slice(5 + len);
|
|
50
|
+
if (type === 1) enqueue(payload);
|
|
51
|
+
else {
|
|
52
|
+
const ev = JSON.parse(new TextDecoder().decode(payload));
|
|
53
|
+
if (ev.ready) { sid = id; resolve(); }
|
|
54
|
+
if (ev.error) { status('ошибка: ' + ev.error); reject(new Error(ev.error)); }
|
|
55
|
+
if (ev.filtered) status(`не принят: «${ev.filtered}»`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw new Error('поток закрыт');
|
|
60
|
+
}).catch(e => {
|
|
61
|
+
const was = sid === id;
|
|
62
|
+
if (sid === id) sid = null;
|
|
63
|
+
sentBpm = null;
|
|
64
|
+
reject(e);
|
|
65
|
+
if (was && playing) { playing = false; paint(); status(e.message); }
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
return opening.finally(() => { opening = null; });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function control(body) {
|
|
72
|
+
if (!sid) return;
|
|
73
|
+
const r = await fetch('/api/music/control', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sid, ...body }) });
|
|
74
|
+
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'управление не прошло');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function enqueue(bytes) {
|
|
78
|
+
window.__mooodMusicChunks = (window.__mooodMusicChunks || 0) + 1; // for checking the stream
|
|
79
|
+
if (!ctx || !playing) return;
|
|
80
|
+
const n = bytes.length >> 2, buf = ctx.createBuffer(2, n, RATE);
|
|
81
|
+
const L = buf.getChannelData(0), R = buf.getChannelData(1);
|
|
82
|
+
const pcm = new DataView(bytes.buffer, bytes.byteOffset, n * 4);
|
|
83
|
+
for (let i = 0; i < n; i++) {
|
|
84
|
+
L[i] = pcm.getInt16(i * 4, true) / 32768;
|
|
85
|
+
R[i] = pcm.getInt16(i * 4 + 2, true) / 32768;
|
|
86
|
+
}
|
|
87
|
+
const src = ctx.createBufferSource();
|
|
88
|
+
src.buffer = buf;
|
|
89
|
+
src.connect(out);
|
|
90
|
+
const at = Math.max(nextT, ctx.currentTime + LEAD);
|
|
91
|
+
src.start(at);
|
|
92
|
+
nextT = at + buf.duration;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// send what the panel shows: the prompts with weight, and the settings
|
|
96
|
+
async function push() {
|
|
97
|
+
if (!sid || !playing) return;
|
|
98
|
+
const prompts = active();
|
|
99
|
+
if (!prompts.length) { status('все веса на нуле — тишина'); await control({ action: 'pause' }); return; }
|
|
100
|
+
const config = {};
|
|
101
|
+
for (const k of ['density', 'brightness']) if (typeof music[k] === 'number') config[k] = music[k];
|
|
102
|
+
if (typeof music.bpm === 'number') config.bpm = Math.round(music.bpm);
|
|
103
|
+
const reset = config.bpm !== sentBpm && sentBpm !== null; // tempo changes need a fresh start
|
|
104
|
+
sentBpm = config.bpm ?? null;
|
|
105
|
+
await control({ prompts: prompts.map(p => ({ text: p.text.trim(), weight: p.weight })), config });
|
|
106
|
+
if (reset) await control({ action: 'reset' });
|
|
107
|
+
await control({ action: 'play' });
|
|
108
|
+
status('играет');
|
|
109
|
+
}
|
|
110
|
+
function pushSoon() { clearTimeout(pushTimer); pushTimer = setTimeout(() => push().catch(e => status('ошибка: ' + e.message)), 200); }
|
|
111
|
+
|
|
112
|
+
async function start() {
|
|
113
|
+
if (playing || starting) return;
|
|
114
|
+
starting = true;
|
|
115
|
+
paint();
|
|
116
|
+
try {
|
|
117
|
+
ctx = ctx || new AudioContext({ sampleRate: RATE });
|
|
118
|
+
await ctx.resume();
|
|
119
|
+
if (!out) { out = ctx.createGain(); out.connect(ctx.destination); }
|
|
120
|
+
out.gain.setTargetAtTime(MooodVolume.get('music'), ctx.currentTime, 0.3);
|
|
121
|
+
await open();
|
|
122
|
+
if (!wanted) return; // switched off while connecting
|
|
123
|
+
playing = true;
|
|
124
|
+
paint();
|
|
125
|
+
await push();
|
|
126
|
+
} catch (e) { playing = false; status(e.message); } finally { starting = false; paint(); }
|
|
127
|
+
}
|
|
128
|
+
async function stop() {
|
|
129
|
+
playing = false;
|
|
130
|
+
paint();
|
|
131
|
+
status('пауза');
|
|
132
|
+
if (out && ctx) out.gain.setTargetAtTime(0, ctx.currentTime, 0.25);
|
|
133
|
+
nextT = 0;
|
|
134
|
+
try { await control({ action: 'pause' }); } catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── the panel ────────────────────────────────────────────────────────────
|
|
138
|
+
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }
|
|
139
|
+
|
|
140
|
+
function build() {
|
|
141
|
+
const root = el('section');
|
|
142
|
+
root.id = 'music';
|
|
143
|
+
root.hidden = true;
|
|
144
|
+
const head = el('div', 'music-head');
|
|
145
|
+
const play = el('button', 'play');
|
|
146
|
+
play.type = 'button';
|
|
147
|
+
play.onclick = () => { wanted = !(playing || starting); store.set('moood:music', wanted ? 'on' : 'off'); wanted ? start() : stop(); };
|
|
148
|
+
const title = el('span', 'title', 'Музыка');
|
|
149
|
+
const close = el('button', 'close', '×');
|
|
150
|
+
close.type = 'button';
|
|
151
|
+
close.title = 'закрыть';
|
|
152
|
+
close.onclick = () => { root.hidden = true; };
|
|
153
|
+
head.append(play, title, close);
|
|
154
|
+
const st = el('div', 'status');
|
|
155
|
+
const list = el('ol', 'prompts');
|
|
156
|
+
const add = el('form', 'add');
|
|
157
|
+
const addText = el('input');
|
|
158
|
+
addText.placeholder = 'своё описание музыки…';
|
|
159
|
+
const addBtn = el('button', '', '+');
|
|
160
|
+
add.append(addText, addBtn);
|
|
161
|
+
add.onsubmit = e => {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
const text = addText.value.trim();
|
|
164
|
+
if (!text) return;
|
|
165
|
+
music.prompts.push({ text, weight: 1 });
|
|
166
|
+
addText.value = '';
|
|
167
|
+
renderList(); changed();
|
|
168
|
+
};
|
|
169
|
+
const knobs = el('div', 'knobs');
|
|
170
|
+
const save = el('button', 'save', 'сохранить');
|
|
171
|
+
save.type = 'button';
|
|
172
|
+
save.onclick = async () => {
|
|
173
|
+
if (!onSave) return;
|
|
174
|
+
save.disabled = true;
|
|
175
|
+
status('сохраняю…');
|
|
176
|
+
try { await onSave(clone(music)); savedJson = JSON.stringify(music); status(playing ? 'играет' : 'сохранено'); } catch (e) { status('ошибка: ' + e.message); }
|
|
177
|
+
paint();
|
|
178
|
+
};
|
|
179
|
+
const revert = el('button', 'revert', 'как было');
|
|
180
|
+
revert.type = 'button';
|
|
181
|
+
revert.onclick = () => { music = JSON.parse(savedJson); renderList(); renderKnobs(); changed(); };
|
|
182
|
+
const foot = el('div', 'foot');
|
|
183
|
+
foot.append(revert, save);
|
|
184
|
+
root.append(head, st, list, add, knobs, foot);
|
|
185
|
+
document.body.append(root);
|
|
186
|
+
return { root, play, status: st, list, knobs, save, revert };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function slider(min, max, step, value, onInput) {
|
|
190
|
+
const r = el('input');
|
|
191
|
+
r.type = 'range'; r.min = min; r.max = max; r.step = step; r.value = value;
|
|
192
|
+
r.oninput = () => onInput(Number(r.value));
|
|
193
|
+
return r;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function renderList() {
|
|
197
|
+
ui.list.innerHTML = '';
|
|
198
|
+
for (const p of music.prompts) {
|
|
199
|
+
const li = el('li', p.weight > 0 ? 'on' : '');
|
|
200
|
+
const text = el('textarea');
|
|
201
|
+
text.rows = 1;
|
|
202
|
+
text.value = p.text;
|
|
203
|
+
text.oninput = () => { p.text = text.value; changed(); };
|
|
204
|
+
const val = el('span', 'val', p.weight.toFixed(2));
|
|
205
|
+
const w = slider(0, 2, 0.05, p.weight, v => { p.weight = v; val.textContent = v.toFixed(2); li.classList.toggle('on', v > 0); changed(); });
|
|
206
|
+
const del = el('button', 'del', '×');
|
|
207
|
+
del.type = 'button';
|
|
208
|
+
del.title = 'убрать';
|
|
209
|
+
del.onclick = () => { music.prompts.splice(music.prompts.indexOf(p), 1); renderList(); changed(); };
|
|
210
|
+
const row = el('div', 'row');
|
|
211
|
+
row.append(w, val, del);
|
|
212
|
+
li.append(text, row);
|
|
213
|
+
ui.list.append(li);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderKnobs() {
|
|
218
|
+
ui.knobs.innerHTML = '';
|
|
219
|
+
for (const [k, label, min, max, step, def] of [['density', 'плотность', 0, 1, 0.01, 0.5], ['brightness', 'яркость', 0, 1, 0.01, 0.5], ['bpm', 'темп', 60, 200, 1, 90]]) {
|
|
220
|
+
const set = typeof music[k] === 'number';
|
|
221
|
+
const row = el('label', set ? '' : 'auto');
|
|
222
|
+
const val = el('span', 'val', set ? String(music[k]) : 'авто');
|
|
223
|
+
const r = slider(min, max, step, set ? music[k] : def, v => { music[k] = v; val.textContent = String(v); row.classList.remove('auto'); changed(); });
|
|
224
|
+
const reset = el('button', 'del', '↺');
|
|
225
|
+
reset.type = 'button';
|
|
226
|
+
reset.title = 'авто';
|
|
227
|
+
reset.onclick = () => { delete music[k]; renderKnobs(); changed(); };
|
|
228
|
+
row.append(el('span', 'name', label), r, val, reset);
|
|
229
|
+
ui.knobs.append(row);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function paint() {
|
|
234
|
+
if (!ui) return;
|
|
235
|
+
const on = playing || starting;
|
|
236
|
+
ui.play.textContent = on ? '⏸' : '▶';
|
|
237
|
+
ui.play.title = on ? 'пауза' : 'играть';
|
|
238
|
+
const dirty = JSON.stringify(music) !== savedJson;
|
|
239
|
+
ui.save.disabled = !dirty || !onSave;
|
|
240
|
+
ui.revert.disabled = !dirty;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function changed() { paint(); pushSoon(); }
|
|
244
|
+
|
|
245
|
+
// ── the page's side ──────────────────────────────────────────────────────
|
|
246
|
+
// init: once; toggle: a button that opens the panel (or the page calls panel()); save(music) →
|
|
247
|
+
// persists it (author)
|
|
248
|
+
function init({ toggle, save }) {
|
|
249
|
+
ui = build();
|
|
250
|
+
onSave = save;
|
|
251
|
+
if (toggle) toggle.onclick = panel;
|
|
252
|
+
// sound may only start from a gesture: the first touch of the page starts it
|
|
253
|
+
const first = () => { removeEventListener('pointerdown', first, true); removeEventListener('keydown', first, true); if (wanted && music && active().length) start(); };
|
|
254
|
+
addEventListener('pointerdown', first, true);
|
|
255
|
+
addEventListener('keydown', first, true);
|
|
256
|
+
status(wanted ? 'заиграет от первого касания' : 'пауза');
|
|
257
|
+
paint();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// the open world's music (from its spec), or none
|
|
261
|
+
function show(m) {
|
|
262
|
+
music = clone(m);
|
|
263
|
+
music.prompts = (music.prompts || []).map(p => ({ text: String(p.text || ''), weight: Number(p.weight) || 0 }));
|
|
264
|
+
savedJson = JSON.stringify(music);
|
|
265
|
+
if (!ui) return;
|
|
266
|
+
renderList();
|
|
267
|
+
renderKnobs();
|
|
268
|
+
paint();
|
|
269
|
+
if (playing) pushSoon();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// author or player: players hear, only authors tune
|
|
273
|
+
function setEditable(on) { if (ui) ui.root.classList.toggle('readonly', !on); }
|
|
274
|
+
|
|
275
|
+
// opens or closes the panel
|
|
276
|
+
function panel() { ui.root.hidden = !ui.root.hidden; }
|
|
277
|
+
|
|
278
|
+
return { init, show, setEditable, panel };
|
|
279
|
+
})();
|
package/engine/nav.css
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* Finding one's way, the same on every page: the crumbs — where I am: moood / a world / a game / … —
|
|
2
|
+
(templates/_crumbs.html), and the viewer — a scene looked at without leaving the page (viewer.js). */
|
|
3
|
+
|
|
4
|
+
.crumbs { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 7px; min-width: 0; font-family: Georgia, "Times New Roman", serif; }
|
|
5
|
+
.crumbs a { color: inherit; text-decoration: none; }
|
|
6
|
+
.crumbs a:hover { text-decoration: underline; }
|
|
7
|
+
.crumbs .brand { font-style: italic; }
|
|
8
|
+
.crumbs .brand .moon { color: #e8c98a; }
|
|
9
|
+
/* a small moon over the m, as in the sign (drawn, not a letter: what is copied is still "moood.me") */
|
|
10
|
+
.crumbs .brand .m { position: relative; }
|
|
11
|
+
.crumbs .brand .m::after { content: ""; position: absolute; left: 0.46em; top: 0.15em; width: 0.15em; height: 0.15em; border-radius: 50%; background: #e8c98a; pointer-events: none; }
|
|
12
|
+
.crumbs .brand .tld { opacity: 0.55; }
|
|
13
|
+
.crumbs .sep { opacity: 0.35; }
|
|
14
|
+
.crumbs .kind { margin-right: -3px; font-size: 0.78em; font-style: italic; opacity: 0.55; }
|
|
15
|
+
.crumbs form { display: inline; }
|
|
16
|
+
.crumbs button { border: 0; background: none; color: inherit; font: inherit; padding: 0; cursor: pointer; opacity: 0.7; }
|
|
17
|
+
.crumbs button:hover { opacity: 1; text-decoration: underline; }
|
|
18
|
+
.crumbs .volume { margin-left: 4px; font-size: 0.85em; }
|
|
19
|
+
.crumbs .volume:hover { text-decoration: none; }
|
|
20
|
+
/* how loud: the music and the voices (volume.js) */
|
|
21
|
+
#volume { position: fixed; z-index: 50; width: 220px; box-sizing: border-box; padding: 10px 12px; display: grid; gap: 8px;
|
|
22
|
+
border-radius: 10px; background: rgba(14, 17, 22, 0.94); border: 1px solid rgba(255, 255, 255, 0.12); backdrop-filter: blur(6px);
|
|
23
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); color: rgba(236, 232, 224, 0.92); font: 14px/1.3 Georgia, "Times New Roman", serif; }
|
|
24
|
+
#volume[hidden] { display: none; }
|
|
25
|
+
#volume label { display: grid; grid-template-columns: 64px 1fr; align-items: center; gap: 8px; }
|
|
26
|
+
#volume input { width: 100%; accent-color: #e8c98a; }
|
|
27
|
+
/* over a scene full-screen: top-left */
|
|
28
|
+
#crumbs { position: fixed; left: 12px; top: 10px; z-index: 6; max-width: min(720px, calc(100vw - 24px)); padding: 4px 12px;
|
|
29
|
+
border-radius: 999px; background: rgba(10, 12, 18, 0.55); backdrop-filter: blur(6px); font-size: 14px; line-height: 1.4;
|
|
30
|
+
color: rgba(236, 232, 224, 0.92); }
|
|
31
|
+
|
|
32
|
+
/* a full-screen page whose thing is not there (shell.js lost) */
|
|
33
|
+
.lost { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
|
34
|
+
padding: 16px; box-sizing: border-box; background: #0e1116; color: #d6dae1; font: 16px/1.5 Georgia, "Times New Roman", serif; }
|
|
35
|
+
.lost a { color: #e8c98a; }
|
|
36
|
+
.lost .crumbs { margin-bottom: 18px; font-size: 18px; }
|
|
37
|
+
|
|
38
|
+
/* the viewer */
|
|
39
|
+
.viewer-veil { position: fixed; inset: 0; z-index: 40; background: rgba(4, 6, 10, 0.6); opacity: 0; pointer-events: none; transition: opacity 0.32s ease; }
|
|
40
|
+
.viewer-veil.open { opacity: 1; pointer-events: auto; }
|
|
41
|
+
.viewer { position: fixed; z-index: 41; display: none; flex-direction: column; overflow: hidden; border-radius: 12px; background: #07080c;
|
|
42
|
+
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6); border: 1px solid rgba(255, 255, 255, 0.12); }
|
|
43
|
+
.viewer.open { display: flex; }
|
|
44
|
+
.viewer { transform-origin: 0 0; }
|
|
45
|
+
.viewer.moving { transition: transform 0.32s ease, clip-path 0.32s ease; }
|
|
46
|
+
/* the scene's picture, standing in its place until the scene has drawn itself (then fading out),
|
|
47
|
+
and coming back over it as the view shrinks back into it */
|
|
48
|
+
.viewer-still { position: absolute; object-fit: cover; pointer-events: none; }
|
|
49
|
+
.viewer-still.gone { opacity: 0; transition: opacity 0.3s ease; }
|
|
50
|
+
/* waiting for the scene, over its picture: a slow ring in the middle */
|
|
51
|
+
.viewer-wait { position: absolute; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 50%;
|
|
52
|
+
border: 2px solid rgba(255, 255, 255, 0.18); border-top-color: rgba(255, 236, 200, 0.85); opacity: 0; pointer-events: none;
|
|
53
|
+
transition: opacity 0.3s ease; animation: viewer-wait 1s linear infinite; }
|
|
54
|
+
.viewer.waiting .viewer-wait { opacity: 1; }
|
|
55
|
+
@keyframes viewer-wait { to { transform: rotate(1turn); } }
|
|
56
|
+
.viewer.closing .viewer-still { opacity: 1; transition: opacity 0.25s ease; }
|
|
57
|
+
.viewer-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; font: 14px/1.3 Georgia, "Times New Roman", serif;
|
|
58
|
+
color: rgba(236, 232, 224, 0.92); background: rgba(255, 255, 255, 0.04); border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
|
|
59
|
+
.viewer-bar button { border: 1px solid rgba(255, 255, 255, 0.18); background: none; color: inherit; font: inherit; border-radius: 999px;
|
|
60
|
+
padding: 1px 10px; cursor: pointer; }
|
|
61
|
+
.viewer-bar button:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); }
|
|
62
|
+
.viewer-bar button:disabled { opacity: 0.3; cursor: default; }
|
|
63
|
+
.viewer-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; }
|
|
64
|
+
.viewer-page { color: inherit; opacity: 0.75; font-size: 13px; text-decoration: none; }
|
|
65
|
+
.viewer-page:hover { opacity: 1; text-decoration: underline; }
|
|
66
|
+
.viewer-frame { flex: 1; border: 0; width: 100%; background: #07080c; }
|