Ambient in der Player-Leiste; weniger Aussetzer bei den Klangwelten auf Telefonen; sw v80
Some checks failed
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Has been cancelled

- app.js: __dioraAmbientInfo (spielt, zuletzt gestartet, Klangwelt), meldet Moduswechsel
  an die Leiste; Radio/Folge übernimmt sie wieder
- Drift (Telefone): Musik-Bett, Delay und Hall werden vom Ausgang getrennt, wenn eine
  Klangwelt ohne sie läuft (Braunes Rauschen …); Zugfahrt und Herzschlag planen 2,5 s voraus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-04 00:18:06 +02:00
parent 2c64342c53
commit 6e28312947
4 changed files with 98 additions and 9 deletions

View file

@ -296,7 +296,8 @@ function buildGraph() {
nB.connect(sl).connect(sMod).connect(g.seaGain).connect(g.noiseBus); nB.connect(sl).connect(sMod).connect(g.seaGain).connect(g.noiseBus);
nA.start(); nB.start(0, 3.1); nA.start(); nB.start(0, 3.1);
g.noiseBus.connect(g.mix); g.noiseBus.connect(g.mix);
const nSend = ctx.createGain(); nSend.gain.value = .3; g.noiseBus.connect(nSend).connect(g.conv); const nSend = g.nSend = ctx.createGain(); nSend.gain.value = .3; g.noiseBus.connect(nSend).connect(g.conv);
S.musicLinked = true; S.revLinked = true; // siehe applyRev (Telefone)
// Granular: Ringpuffer der Stimmen, daraus werden Körner geschnitten. Der ScriptProcessor läuft im // Granular: Ringpuffer der Stimmen, daraus werden Körner geschnitten. Der ScriptProcessor läuft im
// Hauptthread und hält bei Last den ganzen Graphen auf – auf Telefonen gibt es daher keine Körner. // Hauptthread und hält bei Last den ganzen Graphen auf – auf Telefonen gibt es daher keine Körner.
@ -917,7 +918,11 @@ function buildWomb(K) {
o.connect(og).connect(tl); o.start(t); o.stop(t + .3); o.connect(og).connect(tl); o.start(t); o.stop(t + .3);
}; };
const per = () => 60 / (55 + K.a * 60); const per = () => 60 / (55 + K.a * 60);
K.every(() => { const t = T() + .05; thump(t, 64, .9); thump(t + .27, 72, .6); }, per, () => per() * 1.01, .3); // Schläge der nächsten Sekunden vorplanen: ein stockender Hauptthread lässt so keinen Schlag aus
K.nextBeat = T() + .3;
K.every(() => {
while (K.nextBeat < T() + 2.5) { const t = K.nextBeat; thump(t, 64, .9); thump(t + .27, 72, .6); K.nextBeat += per() * R(1, 1.01); }
}, .25, .25, .05);
K.set = (a, b) => { sm(tl.frequency, 100 + 300 * b); sm(lp.frequency, 300 + 500 * b); }; K.set = (a, b) => { sm(tl.frequency, 100 + 300 * b); sm(lp.frequency, 300 + 500 * b); };
} }
@ -953,7 +958,7 @@ function buildTrain(K) {
}; };
K.every(() => { K.every(() => {
const now = T(); const now = T();
while (K.nextJoint < now + .5) { // alle Achsen eines Schienenstoßes vorplanen while (K.nextJoint < now + 2.5) { // alle Achsen der Schienenstöße der nächsten Sekunden vorplanen (ein stockender Hauptthread reißt so keine Lücke)
const v = K.v, t0 = K.nextJoint, amp = .8 * (.85 + Math.random() * .3); const v = K.v, t0 = K.nextJoint, amp = .8 * (.85 + Math.random() * .3);
[0, 2.5 / v, 12.5 / v, 15 / v].forEach((o, i) => railClack(K, t0 + o, amp * (i % 2 ? .8 : 1))); [0, 2.5 / v, 12.5 / v, 15 / v].forEach((o, i) => railClack(K, t0 + o, amp * (i % 2 ? .8 : 1)));
K.nextJoint += 25 / v; K.nextJoint += 25 / v;
@ -1004,6 +1009,25 @@ function applyRev() {
if (!S.g) return; if (!S.g) return;
const M = MODES[S.mode]; const M = MODES[S.mode];
S.g.modeRev.gain.setTargetAtTime(M.music ? 0 : (M.rev || 0) * S.verb * 1.2, S.ctx.currentTime, .3); S.g.modeRev.gain.setTargetAtTime(M.music ? 0 : (M.rev || 0) * S.verb * 1.2, S.ctx.currentTime, .3);
if (!LITE) return;
// Telefone: Was gerade nicht klingt, vom Ausgang trennen – der Browser rechnet es dann gar nicht
// (Musik-Bett, Delay, Hall ohne Hall-Modus). Musik geht nach dem Ausblenden weg, damit nichts knackt.
const music = !!M.music;
if (music) linkMusic(true);
else setTimeout(() => { if (!MODES[S.mode].music) linkMusic(false); }, 1600);
const rev = music || !!M.rev;
if (S.revLinked !== rev) {
S.revLinked = rev;
try { rev ? S.g.conv.connect(S.g.mix) : S.g.conv.disconnect(S.g.mix); } catch (e) {}
}
}
function linkMusic(on) {
if (S.musicLinked === on) return;
S.musicLinked = on;
const G = S.g;
for (const [a, b] of [[G.dry, G.mix], [G.send, G.conv], [G.noiseBus, G.mix], [G.nSend, G.conv], [G.dlp, G.conv], [G.dlp, G.mix]]) {
try { on ? a.connect(b) : a.disconnect(b); } catch (e) {}
}
} }
function startSoundscape() { function startSoundscape() {
const M = MODES[S.mode], K = newKit(M.gain || .7); const M = MODES[S.mode], K = newKit(M.gain || .7);

View file

@ -1246,6 +1246,7 @@ function showTab(name) {
// {type: 'ambient', state: 'playing'|'stopped'} from it. // {type: 'ambient', state: 'playing'|'stopped'} from it.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let _ambientPlaying = false; let _ambientPlaying = false;
let _ambientLast = false; // Ambient was started last: the player bar shows it (native-podcast.js)
function _ambientFrame() { function _ambientFrame() {
return $('ambient-frame'); return $('ambient-frame');
@ -1255,8 +1256,14 @@ function _ambientLoad() {
const f = _ambientFrame(); const f = _ambientFrame();
if (!f) return; if (!f) return;
if (!f.getAttribute('src')) { if (!f.getAttribute('src')) {
// Drift takes the page's volume before its first sound. // Drift takes the page's volume before its first sound; the bar follows its sound world.
f.addEventListener('load', () => _ambientVolume()); f.addEventListener('load', () => {
_ambientVolume();
try {
f.contentDocument.getElementById('mode').addEventListener('change', () =>
window.__dioraAmbientChanged && window.__dioraAmbientChanged());
} catch (e) {}
});
f.src = f.dataset.src; f.src = f.dataset.src;
} }
requestAnimationFrame(_ambientSize); requestAnimationFrame(_ambientSize);
@ -1283,12 +1290,27 @@ window.__dioraAmbientCmd = function (cmd, value) {
if (f && f.contentWindow && f.getAttribute('src')) f.contentWindow.postMessage({type: 'ambient', cmd, value}, '*'); if (f && f.contentWindow && f.getAttribute('src')) f.contentWindow.postMessage({type: 'ambient', cmd, value}, '*');
}; };
// The radio or a podcast starts: Ambient stops. // The radio or a podcast starts: Ambient stops, and the player bar is theirs again.
window.__dioraAmbientStop = function () { window.__dioraAmbientStop = function () {
if (_ambientPlaying) window.__dioraAmbientCmd('stop'); if (_ambientPlaying) window.__dioraAmbientCmd('stop');
if (_ambientLast) {
_ambientLast = false;
if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
}
}; };
window.__dioraAmbientPlaying = () => _ambientPlaying; window.__dioraAmbientPlaying = () => _ambientPlaying;
// For the player bar: playing, started last, the sound world's name (the frame is ours).
window.__dioraAmbientInfo = function () {
const f = _ambientFrame();
let mode = '';
try {
const sel = f && f.contentDocument && f.contentDocument.getElementById('mode');
if (sel && sel.selectedOptions[0]) mode = sel.selectedOptions[0].text;
} catch (e) {}
return {playing: _ambientPlaying, last: _ambientLast, mode};
};
// The page's volume (0–255, the bar's slider) is Drift's too; its own slider hides when // The page's volume (0–255, the bar's slider) is Drift's too; its own slider hides when
// embedded. Not in the Android app: there the volume keys set it, as for everything. // embedded. Not in the Android app: there the volume keys set it, as for everything.
function _ambientVolume(val) { function _ambientVolume(val) {
@ -1305,6 +1327,8 @@ window.addEventListener('message', e => {
const d = e.data; const d = e.data;
if (!f || e.source !== f.contentWindow || !d || d.type !== 'ambient' || !d.state) return; if (!f || e.source !== f.contentWindow || !d || d.type !== 'ambient' || !d.state) return;
_ambientPlaying = d.state === 'playing'; _ambientPlaying = d.state === 'playing';
if (_ambientPlaying) _ambientLast = true;
if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
// The app keeps it going with the screen off (AmbientService); the browser's sleep timer counts it. // The app keeps it going with the screen off (AmbientService); the browser's sleep timer counts it.
if (window.DioraNative && DioraNative.ambientState) DioraNative.ambientState(_ambientPlaying); if (window.DioraNative && DioraNative.ambientState) DioraNative.ambientState(_ambientPlaying);
if (window.__dioraOnAmbient) window.__dioraOnAmbient(_ambientPlaying); if (window.__dioraOnAmbient) window.__dioraOnAmbient(_ambientPlaying);

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v79'; const CACHE = 'diora-v80';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/static/js/app.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android 67a915e 2026-10-03: web-native/static/native-podcast.js // Copied from diora-android 8ee6c2b 2026-10-04: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js. // and copy again. The browser host is static/js/podcasts-web.js.
@ -2798,7 +2798,8 @@
const text = el('button', 'nui-mini-text'); const text = el('button', 'nui-mini-text');
text.type = 'button'; text.type = 'button';
text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track')); text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track'));
text.addEventListener('click', () => (miniKind === 'radio' ? openRadioPlayer() : openPlayer())); text.addEventListener('click', () => (miniKind === 'ambient' ? showTab('ambient')
: miniKind === 'radio' ? openRadioPlayer() : openPlayer()));
const devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices'); const devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices');
const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here'); const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here');
devices.hidden = here.hidden = true; devices.hidden = here.hidden = true;
@ -2808,6 +2809,7 @@
button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'), button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'),
devices, here, devices, here,
button('▶', 'Abspielen', () => (miniKind === 'remote' ? remoteToggle() button('▶', 'Abspielen', () => (miniKind === 'remote' ? remoteToggle()
: miniKind === 'ambient' ? ambientToggle()
: miniKind === 'radio' ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle')); : miniKind === 'radio' ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle'));
dock.prepend(mini); dock.prepend(mini);
// One player for podcasts and radio: the radio's own bar steps aside. // One player for podcasts and radio: the radio's own bar steps aside.
@ -2841,6 +2843,42 @@
b.classList.toggle('on', !!sleepLabel(true)); b.classList.toggle('on', !!sleepLabel(true));
} }
// Ambient (app.js) started last: the bar is its — the sound world's name, ▶/■, a tap opens
// its tab — until the radio or an episode starts.
function ambientInfo() {
return window.__dioraAmbientInfo ? window.__dioraAmbientInfo() : null;
}
function ambientToggle() {
const a = ambientInfo();
if (a && window.__dioraAmbientCmd) window.__dioraAmbientCmd(a.playing ? 'stop' : 'start');
}
window.__dioraAmbientChanged = () => renderMini();
function renderAmbientMini() {
const a = ambientInfo();
if (!mini || !a || !a.last) return false;
miniKind = 'ambient';
document.documentElement.classList.remove('nui-pod-playing');
document.documentElement.classList.add('nui-pod-mini-open');
mini.hidden = false;
mini.classList.add('nui-mini-radio');
mini.classList.remove('nui-mini-remote');
for (const c of ['.nui-pod-skip', '.nui-mini-save', '.nui-mini-devices', '.nui-mini-here']) mini.querySelector(c).hidden = true;
mini.querySelector('.nui-mini-station').textContent = 'Ambient';
const z = sleepLabel(true);
mini.querySelector('.nui-mini-track').textContent = (a.mode || 'Drift') + (a.playing ? '' : ' · angehalten') + (z ? ' · ' + z : '');
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = a.playing ? '■' : '▶';
t.title = a.playing ? 'Ambient stoppen' : 'Ambient abspielen';
const box = mini.querySelector('.nui-pod-art');
setArt(box, '');
if (!box.querySelector('.nui-amb-art')) { box.textContent = ''; box.append(el('span', 'nui-pod-initial nui-amb-art', '∿')); }
mini.querySelector('.nui-pod-mini-bar span').style.width = a.playing ? '100%' : '0';
return true;
}
// The bar names a running sleep timer: "… · ☾ 12 min". // The bar names a running sleep timer: "… · ☾ 12 min".
function sleepSuffix() { function sleepSuffix() {
const l = sleepLabel(); const l = sleepLabel();
@ -2871,10 +2909,13 @@
function renderMini() { function renderMini() {
renderAmbientSleep(); renderAmbientSleep();
if (renderAmbientMini()) return;
const r = remote(); const r = remote();
const isPod = player.kind === 'podcast'; const isPod = player.kind === 'podcast';
document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r); document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r);
if (!mini) return; if (!mini) return;
const ambArt = mini.querySelector('.nui-amb-art');
if (ambArt) ambArt.remove();
const radio = isPod || r ? null : radioNow(); const radio = isPod || r ? null : radioNow();
miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : ''; miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : '';
mini.hidden = !miniKind; mini.hidden = !miniKind;