diff --git a/static/js/app.js b/static/js/app.js index 5e09935..b1c5e61 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1179,7 +1179,7 @@ async function editNotes(pk, current) { // Tabs // --------------------------------------------------------------------------- -const TOP_TABS = ['radio', 'focus', 'podcasts', 'books', 'ambient']; +const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'ambient']; const RADIO_SUB_TABS = ['search', 'saved', 'history']; // --------------------------------------------------------------------------- @@ -1237,6 +1237,7 @@ function showTab(name) { if (name === 'books') loadBookList(); if (name === 'ambient') _ambientLoad(); + if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js } // --------------------------------------------------------------------------- @@ -1260,8 +1261,20 @@ function _ambientLoad() { f.addEventListener('load', () => { _ambientVolume(); try { - f.contentDocument.getElementById('mode').addEventListener('change', () => - window.__dioraAmbientChanged && window.__dioraAmbientChanged()); + const sel = f.contentDocument.getElementById('mode'); + // The sound world chosen last comes back (Drift itself starts with its default). + let last = ''; + try { last = localStorage.getItem('diora_ambient_mode') || ''; } catch (e) {} + if (last && last !== sel.value && [...sel.options].some(o => o.value === last)) { + f.contentWindow.postMessage({type: 'ambient', cmd: 'mode', mode: last}, '*'); + } + sel.addEventListener('change', () => { + try { + localStorage.setItem('diora_ambient_mode', sel.value); + localStorage.setItem('diora_ambient_mode_label', sel.selectedOptions[0] ? sel.selectedOptions[0].text : ''); + } catch (e) {} + if (window.__dioraAmbientChanged) window.__dioraAmbientChanged(); + }); } catch (e) {} }); f.src = f.dataset.src; @@ -1290,6 +1303,19 @@ window.__dioraAmbientCmd = function (cmd, value) { if (f && f.contentWindow && f.getAttribute('src')) f.contentWindow.postMessage({type: 'ambient', cmd, value}, '*'); }; +// Start Drift from elsewhere (Home): load the frame if needed, then start it — with the +// sound world chosen last, which the frame's load handler above sets. +window.__dioraAmbientStart = function () { + const f = _ambientFrame(); + if (!f) return; + const go = () => window.__dioraAmbientCmd('start'); + let ready = false; + try { ready = !!f.getAttribute('src') && f.contentDocument.readyState === 'complete' && !!f.contentDocument.getElementById('mode'); } catch (e) {} + if (ready) { go(); return; } + f.addEventListener('load', () => setTimeout(go, 0), {once: true}); + _ambientLoad(); +}; + // The radio or a podcast starts: Ambient stops, and the player bar is theirs again. window.__dioraAmbientStop = function () { if (_ambientPlaying) window.__dioraAmbientCmd('stop'); diff --git a/static/js/home.js b/static/js/home.js new file mode 100644 index 0000000..d193fc3 --- /dev/null +++ b/static/js/home.js @@ -0,0 +1,311 @@ +/** + * diora Home: what you were in the middle of, one click away — the book you're + * reading, the next episode, your stations, Ambient. + * + * Built only from what the page already has: the book list (app.js), the + * podcast host (window.DioraPodcastHost in the browser and diora desktop, + * window.DioraNative in the Android app), the saved stations in the radio tab + * and Drift (app.js __dioraAmbient*). Renders into #tab-home; the same file runs + * in the browser, diora desktop and the Android app (copied with static/js/). + * Written without ?. and ?? so old WebViews can still parse it. + */ +(function () { + 'use strict'; + const tab = document.getElementById('tab-home'); + if (!tab) return; + + const $ = id => document.getElementById(id); + const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, + c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); + const parse = (s, fallback) => { try { return typeof s === 'string' ? JSON.parse(s) : (s || fallback); } catch (e) { return fallback; } }; + const visible = () => tab.style.display !== 'none'; + const online = () => navigator.onLine && !document.body.classList.contains('desktop-offline'); + + function minutes(sec) { + const m = Math.max(0, Math.round((sec || 0) / 60)); + return m >= 60 ? Math.floor(m / 60) + ' h ' + String(m % 60).padStart(2, '0') + ' min' : m + ' min'; + } + + function bar(fraction) { + const pct = Math.max(0, Math.min(100, Math.round((fraction || 0) * 100))); + return ''; + } + + function podHost() { + if (window.DioraPodcastHost) return window.DioraPodcastHost; + if (window.DioraNative && window.DioraNative.podcastState) return window.DioraNative; + return null; + } + + // ---- Weiterlesen ------------------------------------------------------------ + + let askedForBooks = false; + + function booksSection() { + const books = typeof _lastBookListData !== 'undefined' ? _lastBookListData : []; + if (!books.length && !askedForBooks && typeof loadBookList === 'function') { + askedForBooks = true; // the list renders into the (hidden) books tab; renderBookList tells us + loadBookList(); + } + const recent = typeof _recentlyReadBooks === 'function' + ? _recentlyReadBooks(books).filter(b => b.keyOk !== false && !b.is_read) : []; + let body; + if (!recent.length) { + const locked = books.length && books.every(b => b.keyOk === false); + body = '
' + + (locked ? 'Die Bücher lassen sich mit dem Schlüssel auf diesem Gerät nicht öffnen.' + : books.length ? 'Gerade kein Buch angefangen.' : 'Noch keine Bücher.') + + '
'; + } else { + const b = recent[0]; + body = ''; + const more = recent.slice(1, 4); + if (more.length) { + body += '
' + more.map(o => + '').join('') + '
'; + } + } + return section('Bücher', body, 'books'); + } + + // ---- Weiterhören ------------------------------------------------------------ + + function podcastSection() { + const h = podHost(); + if (!h || !document.getElementById('tab-podcasts')) return ''; + const state = parse(h.podcastState(), {}) || {}; + const player = h.playerState ? parse(h.playerState(), {}) || {} : {}; + const queue = state.queue || []; + const fresh = state.inbox_total != null ? state.inbox_total : (state.inbox || []).length; + let card = ''; + + if (player.remote && player.remote.kind === 'podcast') { + const r = player.remote; + card = podCard('Läuft auf ' + (r.device || 'einem anderen Gerät'), r.title || '', r.feed || '', r.artwork || '', + r.dur ? (r.dur - (r.pos || 0)) / 1000 : 0, r.dur ? (r.pos || 0) / r.dur : 0, + ''); + } else if (player.kind === 'podcast') { + const left = player.dur ? (player.dur - (player.pos || 0)) / 1000 : 0; + card = podCard(player.playing ? 'Läuft gerade' : 'Weiterhören', player.title, player.feed, player.artwork, left, + player.dur ? (player.pos || 0) / player.dur : 0, + ''); + } else if (queue.length) { + const q = queue[0]; + const dur = q.episode__duration_seconds || 0; + const pos = q.position_seconds || 0; + card = podCard(pos > 0 ? 'Weiterhören' : 'Als Nächstes', q.episode__title, q.episode__feed__title, + q.episode__artwork_url || q.episode__feed__artwork_url, dur ? dur - pos : 0, dur ? pos / dur : 0, + ''); + } else { + card = '
Die Warteschlange ist leer.
'; + } + const extra = '
' + + (queue.length > 1 ? '' : '') + + (fresh ? '' : '') + + '
'; + return section('Podcasts', card + extra, 'podcasts'); + } + + function podCard(kicker, title, feed, art, leftSec, fraction, button) { + return '
' + + (art ? '' : '') + + '' + + '' + esc(kicker) + '' + + '' + esc(title) + '' + + '' + esc(feed) + (leftSec > 30 ? ' · noch ' + minutes(leftSec) : '') + '' + + '' + bar(fraction) + '' + + button + '
'; + } + + // ---- Radio ------------------------------------------------------------------ + + function stationRows() { + return Array.prototype.slice.call(document.querySelectorAll('#saved-tbody tr[data-id]')); + } + + function radioSection() { + if (!document.getElementById('tab-radio')) return ''; + // The app shows the radio only where it has one (native-radio.js marks ). + if (window.DioraNative && !document.documentElement.classList.contains('nui-radio')) return ''; + const rows = stationRows(); + const favs = rows.filter(r => r.querySelector('.fav-btn.active')); + const pick = (favs.length ? favs : rows).slice(0, 8); + const cur = typeof currentStation !== 'undefined' ? currentStation : null; + const playing = typeof isPlaying !== 'undefined' && isPlaying; + let chips = ''; + if (cur && !pick.some(r => r.dataset.url === cur.url)) { + chips += chip(cur.url, cur.name, playing, 'Zuletzt: '); + } + chips += pick.map(r => chip(r.dataset.url, r.dataset.name, playing && cur && cur.url === r.dataset.url, '')).join(''); + let body; + if (!chips) { + body = '
Noch keine Sender gespeichert. ' + + '
'; + } else { + body = '
' + chips + '
'; + } + if (!online()) body += '
Offline — Radio braucht eine Verbindung.
'; + return section(favs.length ? 'Radio · Favoriten' : 'Radio', body, 'radio'); + } + + function chip(url, name, playing, prefix) { + return ''; + } + + // ---- Ambient ---------------------------------------------------------------- + + function ambientSection() { + if (!document.getElementById('tab-ambient') || typeof window.__dioraAmbientInfo !== 'function') return ''; + if (window.DioraNative && !document.querySelector('#nui-tabbar [data-tab="ambient"]') && + document.getElementById('nui-tabbar')) return ''; + const info = window.__dioraAmbientInfo() || {}; + let label = info.mode || ''; + if (!label) { try { label = localStorage.getItem('diora_ambient_mode_label') || ''; } catch (e) {} } + const h = podHost(); + const player = h && h.playerState ? parse(h.playerState(), {}) || {} : {}; + const left = player.sleep && player.sleep.leftMs > 0 ? ' · ☾ ' + minutes(player.sleep.leftMs / 1000) : ''; + const body = '
' + + '∿' + + '' + + '' + (info.playing ? 'Läuft gerade' : 'Drift') + '' + + '' + esc(label || 'Ambient') + '' + + 'Generative Klangwelten' + esc(left) + '' + + (document.getElementById('nui-amb-sleep') ? '' : '') + + '
'; + return section('Ambient', body, 'ambient'); + } + + // ---- page --------------------------------------------------------------------- + + function section(title, body, tabName) { + return '

' + esc(title) + '

' + + '
' + body + '
'; + } + + function greeting() { + const h = new Date().getHours(); + return h < 5 ? 'Gute Nacht' : h < 11 ? 'Guten Morgen' : h < 18 ? 'Hallo' : 'Guten Abend'; + } + + let lastHtml = ''; + let host = null; + + function render() { + if (!visible()) return; + if (!host) { + host = document.createElement('div'); + host.className = 'home'; + tab.appendChild(host); + } + let html = '
' + greeting() + '
'; + for (const part of [booksSection, podcastSection, radioSection, ambientSection]) { + try { html += part(); } catch (e) { console.warn('home', e); } + } + if (html !== lastHtml) { + lastHtml = html; + host.innerHTML = html; + } + } + + function playStationFromHome(url, name) { + const cur = typeof currentStation !== 'undefined' ? currentStation : null; + if (typeof isPlaying !== 'undefined' && isPlaying && cur && cur.url === url) { stopPlayback(false); return; } + const row = stationRows().find(r => r.dataset.url === url); + const b = row && row.querySelector('button[onclick*="playStation"]'); + if (b) b.click(); + else playStation(url, name, row ? Number(row.dataset.id) || null : (cur && cur.url === url ? cur.id : null)); + } + + tab.addEventListener('click', e => { + const el = e.target.closest('[data-act]'); + if (!el || !tab.contains(el)) return; + const act = el.dataset.act; + const h = podHost(); + if (act === 'tab') showTab(el.dataset.tab); + else if (act === 'book') openBook(Number(el.dataset.id)); + else if (act === 'station') playStationFromHome(el.dataset.url, el.dataset.name); + else if (act === 'pod-queue' && h) { + const q = (parse(h.podcastState(), {}).queue || [])[0]; + if (q) h.podcastPlayEpisode(JSON.stringify(q), 0); + } else if (act === 'pod-resume' && h) h.podcastPlay(Number(el.dataset.id)); + else if (act === 'pod-pause' && h) h.podcastToggle(); + else if (act === 'pod-here' && h && h.playerTakeOver) h.playerTakeOver(); + else if (act === 'pod-open') { + if (window.__dioraOpenPlayer && h && parse(h.playerState(), {}).kind) window.__dioraOpenPlayer('podcast'); + else showTab('podcasts'); + } else if (act === 'pod-inbox') { + showTab('podcasts'); + if (window.__dioraPodcastView) window.__dioraPodcastView('inbox'); + } else if (act === 'amb-start' && window.__dioraAmbientStart) window.__dioraAmbientStart(); + else if (act === 'amb-stop' && window.__dioraAmbientCmd) window.__dioraAmbientCmd('stop'); + else if (act === 'amb-sleep') { const b = $('nui-amb-sleep'); if (b) b.click(); } + setTimeout(render, 300); + }); + + // Fresh whenever it shows, and while it shows (players move on, the book list arrives). + window.__dioraHomeShow = render; + if (typeof window.renderBookList === 'function') { + const renderBookList = window.renderBookList; + window.renderBookList = function () { + const r = renderBookList.apply(this, arguments); + render(); + return r; + }; + } + setInterval(render, 2000); + // The start page may have opened Home before this file ran (podcasts-web.js, native.js). + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', render); + else render(); + + const style = document.createElement('style'); + style.textContent = [ + '#tab-home .home { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }', + '#tab-home .home-hello { font-size: 22px; font-weight: 700; margin: 4px 0 18px; letter-spacing: 0.02em; }', + '#tab-home .home-section { margin-bottom: 22px; }', + '#tab-home .home-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }', + '#tab-home .home-head h2 { margin: 0; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }', + '#tab-home .home-more { background: none; border: none; color: var(--nui-muted, #9a9a9a); font: inherit; font-size: 12px; cursor: pointer; padding: 4px 0; }', + '#tab-home .home-card { display: flex; align-items: center; gap: 12px; width: 100%; box-sizing: border-box; padding: 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: #0b0b0b; color: inherit; font: inherit; text-align: left; }', + '#tab-home button.home-card, #tab-home .home-pod-text, #tab-home .home-row, #tab-home .home-station { cursor: pointer; }', + '#tab-home .home-main { flex-wrap: nowrap; }', + '#tab-home button.home-main { flex-direction: column; align-items: flex-start; gap: 3px; }', + '#tab-home .home-empty { color: var(--nui-muted, #9a9a9a); justify-content: space-between; }', + '#tab-home .home-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent, #e63946); }', + '#tab-home .home-title { font-size: 16px; font-weight: 700; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }', + '#tab-home .home-sub { font-size: 13px; color: var(--nui-muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }', + '#tab-home .home-state { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }', + '#tab-home .home-bar { display: inline-block; width: 160px; max-width: 40vw; height: 4px; border-radius: 2px; background: #2a2a2a; overflow: hidden; }', + '#tab-home .home-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }', + '#tab-home .home-pod-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }', + '#tab-home .home-art { width: 56px; height: 56px; flex: none; border-radius: 4px; object-fit: cover; background: #1a1a1a; }', + '#tab-home .home-amb-icon { width: 56px; height: 56px; flex: none; border-radius: 4px; background: #14102a; color: #b9a7ff; display: inline-flex; align-items: center; justify-content: center; font-size: 26px; }', + '#tab-home .home-play { flex: none; min-height: 36px; padding: 0 14px; }', + '#tab-home .home-list { margin-top: 6px; }', + '#tab-home .home-row { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 10px 14px; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 14px; text-align: left; }', + '#tab-home .home-row-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', + '#tab-home .home-row-side { flex: none; color: var(--nui-muted, #9a9a9a); font-size: 12px; }', + '#tab-home .home-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }', + '#tab-home .home-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }', + '#tab-home .home-stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }', + '#tab-home .home-station { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: #0b0b0b; color: inherit; font: inherit; font-size: 14px; text-align: left; }', + '#tab-home .home-station-icon { color: var(--accent, #e63946); flex: none; }', + '#tab-home .home-station-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', + '#tab-home .home-on { border-color: var(--accent, #e63946); }', + '#tab-home .home-note { margin-top: 8px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }', + '@media (hover: hover) { #tab-home .home-card:hover, #tab-home .home-station:hover, #tab-home .home-row:hover { background: #141414; } }', + '.reader-theme-bright #tab-home, body.eink #tab-home .home-card { background: #fff; }', + ].join('\n'); + document.head.appendChild(style); +})(); diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index ad914e4..5de28d6 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -1873,15 +1873,18 @@ // The shared player: what plays on the other devices, and from now on every change. startSync(); - // The start page chosen in the podcasts' settings (nui_start_page, this browser; none: the - // last used tab, the web's way). Opening diora shows it; reloading and back/forward keep the tab. + // The start page chosen in the podcasts' settings (nui_start_page, this browser): Home + // unless chosen otherwise; '' = the last used tab, the web's old way. Opening diora shows + // it; reloading and back/forward keep the tab. (function openStartPage() { - let start = ''; - try { start = localStorage.getItem('nui_start_page') || ''; } catch (e) {} + let start = null; + try { start = localStorage.getItem('nui_start_page'); } catch (e) {} + if (start === null) start = document.getElementById('tab-home') ? 'home' : ''; if (!start || typeof showTab !== 'function') return; const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null; if (nav && nav.type !== 'navigate') return; - const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' : 'books'; + const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' + : start === 'home' && document.getElementById('tab-home') ? 'home' : 'books'; if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab); })(); diff --git a/static/js/sw.js b/static/js/sw.js index 054601c..7020212 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,11 +2,12 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v86'; +const CACHE = 'diora-v87'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', '/static/js/podcasts-web.js', + '/static/js/home.js', '/static/podcasts/native-podcast.js', '/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index f8d9920..3a2ca2e 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 1b935ff 2026-10-04: web-native/static/native-podcast.js +// Copied from diora-android 7dcc0c6 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. @@ -34,9 +34,11 @@ const VIEW_IDS = VIEWS.map(v => v.id).concat('search'); // The app's start page (this device; native.js opens it, the browser host too). const IN_APP = !window.DioraPodcastHost; - const START_PAGES = [ - !IN_APP && {id: '', label: 'Zuletzt benutzt', hint: 'Standard im Browser'}, - {id: 'books', label: 'Bücher', hint: IN_APP ? 'Standard' : null}, + // A function: the app loads this file in , before #tab-home exists. + const startPages = () => [ + document.getElementById('tab-home') && {id: 'home', label: 'Home', hint: 'Standard'}, + !IN_APP && {id: '', label: 'Zuletzt benutzt'}, + {id: 'books', label: 'Bücher', hint: IN_APP && !document.getElementById('tab-home') ? 'Standard' : null}, {id: 'queue', label: 'Podcasts: Warteschlange'}, {id: 'inbox', label: 'Podcasts: Neu'}, {id: 'radio', label: 'Radio'}, @@ -44,7 +46,8 @@ function startPage() { let v = null; try { v = localStorage.getItem('nui_start_page'); } catch (e) {} - return START_PAGES.some(p => p.id === v) ? v : START_PAGES[0].id; + const pages = startPages(); + return pages.some(p => p.id === v) ? v : pages[0].id; } const POSITIONS = [ {value: 'back', label: 'Ans Ende'}, @@ -556,9 +559,9 @@ // Which page the app opens with (this device). function startPageItem() { const now = startPage(); - const label = id => (START_PAGES.find(p => p.id === id) || START_PAGES[0]).label; + const label = id => (startPages().find(p => p.id === id) || startPages()[0]).label; return {icon: '⌂', label: 'Startseite', hint: label(now) + (IN_APP ? ' · beim Öffnen der App' : ' · beim Öffnen von diora'), - action: () => openSheet('Beim Öffnen zeigen …', START_PAGES.filter(p => p.id !== 'radio' || !IN_APP || + action: () => openSheet('Beim Öffnen zeigen …', startPages().filter(p => p.id !== 'radio' || !IN_APP || document.documentElement.classList.contains('nui-radio')).map(p => ({ icon: p.id === now ? '●' : '○', label: p.label, hint: p.hint || null, active: p.id === now, action: () => { @@ -3487,6 +3490,17 @@ if (sheet && sheetKind === 'radio') renderRadioPlayer(); }; window.__dioraOpenPlayer = kind => (kind === 'radio' && !remote() ? openRadioPlayer() : openPlayer()); + // Home (diora-web static/js/home.js): one of the lists, e.g. "inbox" for the new episodes. + window.__dioraPodcastView = function (id) { + if (VIEW_IDS.indexOf(id) < 0) return; + selected = null; + feedView = null; + preview = null; + view = id; + try { localStorage.setItem('nui_pod_view', view); } catch (e) {} + render(); + scrollTop(0); + }; function renderPlayer() { if (!sheet) return; diff --git a/templates/radio/player.html b/templates/radio/player.html index 78ee240..25356cd 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -47,6 +47,8 @@
+ {# Order = app.js TOP_TABS. Home needs an account: hidden, but kept in place for the order. #} + @@ -255,6 +257,10 @@ {% endif %} + +{% comment %}Filled by static/js/home.js: the book in reading, the next episode, stations, Ambient.{% endcomment %} + + {% 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 %} @@ -338,4 +344,5 @@ + {% endblock %}