diff --git a/static/ambient/index.html b/static/ambient/index.html
new file mode 100644
index 0000000..fd541e0
--- /dev/null
+++ b/static/ambient/index.html
@@ -0,0 +1,778 @@
+
+
+
+
+
+Drift – Ambient Synth
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/css/app.css b/static/css/app.css
index 050ed16..e6d8b7b 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -389,6 +389,15 @@ a:hover {
min-height: 200px;
}
+/* Ambient: the synthesizer's frame fills the room down to the player bar (app.js _ambientSize). */
+.ambient-frame {
+ display: block;
+ width: 100%;
+ height: 70vh;
+ border: 0;
+ background: #000;
+}
+
.sub-tabs {
border-bottom-color: transparent;
margin-bottom: 1rem;
diff --git a/static/js/app.js b/static/js/app.js
index 6016b1b..6e6a0cd 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -158,6 +158,7 @@ function customPrompt(message, defaultValue = '') {
function playStation(url, name, stationId) {
stopPlayback(false);
+ if (window.__dioraAmbientStop) window.__dioraAmbientStop();
// A secure page can't load plain-http streams (mixed content). Native shells
// that play the stream themselves set window.DIORA_PLAYS_HTTP_STREAMS.
@@ -1177,7 +1178,7 @@ async function editNotes(pk, current) {
// Tabs
// ---------------------------------------------------------------------------
-const TOP_TABS = ['radio', 'focus', 'podcasts', 'books'];
+const TOP_TABS = ['radio', 'focus', 'podcasts', 'books', 'ambient'];
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
// ---------------------------------------------------------------------------
@@ -1234,8 +1235,56 @@ function showTab(name) {
_syncHash();
if (name === 'books') loadBookList();
+ if (name === 'ambient') _ambientLoad();
}
+// ---------------------------------------------------------------------------
+// Ambient: "Drift" (static/ambient/), a generative synthesizer in a frame of its
+// own. It and the radio / podcast player take turns — one starting stops the
+// other — by postMessage: {type: 'ambient', cmd: 'start'|'stop'} to the frame,
+// {type: 'ambient', state: 'playing'|'stopped'} from it.
+// ---------------------------------------------------------------------------
+let _ambientPlaying = false;
+
+function _ambientFrame() {
+ return $('ambient-frame');
+}
+
+function _ambientLoad() {
+ const f = _ambientFrame();
+ if (!f) return;
+ if (!f.getAttribute('src')) f.src = f.dataset.src;
+ requestAnimationFrame(_ambientSize);
+}
+
+// The frame fills the room between the tabs and the player bar at the bottom.
+function _ambientSize() {
+ const f = _ambientFrame();
+ if (!f || _currentTopTab !== 'ambient') return;
+ const root = getComputedStyle(document.documentElement);
+ const bar = parseFloat(root.getPropertyValue('--nui-dock-h')) || parseFloat(root.getPropertyValue('--dpw-bar-h')) || 0;
+ const top = f.getBoundingClientRect().top + window.scrollY;
+ f.style.height = Math.max(320, Math.floor(window.innerHeight - top - bar)) + 'px';
+}
+window.addEventListener('resize', () => requestAnimationFrame(_ambientSize));
+
+// The radio or a podcast starts: Ambient stops.
+window.__dioraAmbientStop = function () {
+ const f = _ambientFrame();
+ if (_ambientPlaying && f && f.contentWindow) f.contentWindow.postMessage({type: 'ambient', cmd: 'stop'}, '*');
+};
+
+window.addEventListener('message', e => {
+ const f = _ambientFrame();
+ const d = e.data;
+ if (!f || e.source !== f.contentWindow || !d || d.type !== 'ambient' || !d.state) return;
+ _ambientPlaying = d.state === 'playing';
+ if (!_ambientPlaying) return;
+ // Ambient starts: the radio and a podcast here stop.
+ if (typeof isPlaying !== 'undefined' && isPlaying) stopPlayback(false);
+ if (window.__dioraPausePlayer) window.__dioraPausePlayer();
+});
+
function showRadioTab(name) {
RADIO_SUB_TABS.forEach(p => {
const panel = $(`tab-${p}`);
diff --git a/static/js/sw.js b/static/js/sw.js
index 8c93abc..0f6ab15 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-v72';
+const CACHE = 'diora-v73';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',
@@ -11,6 +11,7 @@ const SHELL = [
'/static/podcasts/podcast-ui.css',
'/static/podcasts/podcasts-web.css',
'/static/manifest.json',
+ '/static/ambient/index.html',
'/static/fonts/SourceSerif4-normal-latin.woff2',
'/static/fonts/SourceSerif4-normal-latin-ext.woff2',
'/static/fonts/SourceSerif4-italic-latin.woff2',
@@ -77,7 +78,8 @@ self.addEventListener('fetch', function (event) {
const isShell = SHELL.some(function (s) { return url.pathname === s; });
if (isShell) {
event.respondWith(
- caches.match(event.request).then(function (cached) {
+ // ignoreSearch: the Ambient frame loads with ?embed=1&bg=…
+ caches.match(event.request, {ignoreSearch: true}).then(function (cached) {
return cached || fetch(event.request);
})
);
diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js
index 48023e6..0ad47fc 100644
--- a/static/podcasts/native-podcast.js
+++ b/static/podcasts/native-podcast.js
@@ -1,4 +1,4 @@
-// Copied from diora-android 370e653 2026-10-03: web-native/static/native-podcast.js
+// Copied from diora-android d323614 2026-10-03: 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.
@@ -4294,6 +4294,8 @@
window.__dioraPlayer = function (p) {
const before = player;
player = p || {kind: null};
+ // Something starts playing here (an episode, the radio): Ambient stops (app.js).
+ if (player.playing && !before.playing && !player.remote && window.__dioraAmbientStop) window.__dioraAmbientStop();
renderMini();
renderPlayer();
// The current episode changed: the queue view marks it.
@@ -4302,6 +4304,11 @@
if (epScreen && (before.episodeId !== player.episodeId || !!before.playing !== !!player.playing)) episodeScreenParts();
};
+ // Ambient starts (app.js): an episode playing here pauses.
+ window.__dioraPausePlayer = function () {
+ if (player.kind === 'podcast' && player.playing && !player.remote && N.podcastToggle) N.podcastToggle();
+ };
+
// The app loads this in ; the browser may load it after the page is parsed.
function start(fn) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
diff --git a/templates/radio/player.html b/templates/radio/player.html
index 9d57b91..78ee240 100644
--- a/templates/radio/player.html
+++ b/templates/radio/player.html
@@ -51,6 +51,7 @@
+
@@ -254,6 +255,14 @@
{% endif %}
+
+{% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its
+ script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %}
+
+