diff --git a/static/ambient/index.html b/static/ambient/index.html
index a3757fd..32b33dc 100644
--- a/static/ambient/index.html
+++ b/static/ambient/index.html
@@ -296,7 +296,8 @@ function buildGraph() {
nB.connect(sl).connect(sMod).connect(g.seaGain).connect(g.noiseBus);
nA.start(); nB.start(0, 3.1);
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
// 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);
};
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); };
}
@@ -953,7 +958,7 @@ function buildTrain(K) {
};
K.every(() => {
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);
[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;
@@ -1004,6 +1009,25 @@ function applyRev() {
if (!S.g) return;
const M = MODES[S.mode];
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() {
const M = MODES[S.mode], K = newKit(M.gain || .7);
diff --git a/static/js/app.js b/static/js/app.js
index c942dc8..5e09935 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1246,6 +1246,7 @@ function showTab(name) {
// {type: 'ambient', state: 'playing'|'stopped'} from it.
// ---------------------------------------------------------------------------
let _ambientPlaying = false;
+let _ambientLast = false; // Ambient was started last: the player bar shows it (native-podcast.js)
function _ambientFrame() {
return $('ambient-frame');
@@ -1255,8 +1256,14 @@ function _ambientLoad() {
const f = _ambientFrame();
if (!f) return;
if (!f.getAttribute('src')) {
- // Drift takes the page's volume before its first sound.
- f.addEventListener('load', () => _ambientVolume());
+ // Drift takes the page's volume before its first sound; the bar follows its sound world.
+ f.addEventListener('load', () => {
+ _ambientVolume();
+ try {
+ f.contentDocument.getElementById('mode').addEventListener('change', () =>
+ window.__dioraAmbientChanged && window.__dioraAmbientChanged());
+ } catch (e) {}
+ });
f.src = f.dataset.src;
}
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}, '*');
};
-// 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 () {
if (_ambientPlaying) window.__dioraAmbientCmd('stop');
+ if (_ambientLast) {
+ _ambientLast = false;
+ if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
+ }
};
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
// embedded. Not in the Android app: there the volume keys set it, as for everything.
function _ambientVolume(val) {
@@ -1305,6 +1327,8 @@ window.addEventListener('message', e => {
const d = e.data;
if (!f || e.source !== f.contentWindow || !d || d.type !== 'ambient' || !d.state) return;
_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.
if (window.DioraNative && DioraNative.ambientState) DioraNative.ambientState(_ambientPlaying);
if (window.__dioraOnAmbient) window.__dioraOnAmbient(_ambientPlaying);
diff --git a/static/js/sw.js b/static/js/sw.js
index 9c4576b..907ce35 100644
--- a/static/js/sw.js
+++ b/static/js/sw.js
@@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
-const CACHE = 'diora-v79';
+const CACHE = 'diora-v80';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',
diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js
index df2a096..4d63a72 100644
--- a/static/podcasts/native-podcast.js
+++ b/static/podcasts/native-podcast.js
@@ -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
// and copy again. The browser host is static/js/podcasts-web.js.
@@ -2798,7 +2798,8 @@
const text = el('button', 'nui-mini-text');
text.type = 'button';
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 here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here');
devices.hidden = here.hidden = true;
@@ -2808,6 +2809,7 @@
button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'),
devices, here,
button('▶', 'Abspielen', () => (miniKind === 'remote' ? remoteToggle()
+ : miniKind === 'ambient' ? ambientToggle()
: miniKind === 'radio' ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle'));
dock.prepend(mini);
// One player for podcasts and radio: the radio's own bar steps aside.
@@ -2841,6 +2843,42 @@
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".
function sleepSuffix() {
const l = sleepLabel();
@@ -2871,10 +2909,13 @@
function renderMini() {
renderAmbientSleep();
+ if (renderAmbientMini()) return;
const r = remote();
const isPod = player.kind === 'podcast';
document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r);
if (!mini) return;
+ const ambArt = mini.querySelector('.nui-amb-art');
+ if (ambArt) ambArt.remove();
const radio = isPod || r ? null : radioNow();
miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : '';
mini.hidden = !miniKind;