Some Simple Podcast Player
Fleetingsome simple podcast tool, very specialized to my stack.
For audiobooks, prefer using Voice Audiobook Player, much more attractive (I think).
<html>
<head>
[[head-content]]
<script>
[[app-script]]
</script>
</head>
<body x-data="app" x-init="init_app"
x-effect="document.documentElement.classList.toggle('dark', dark_mode)"
@gotonext="
if($event.detail.playing){
current.playing = true
}
await goto_next()
"
@gotoprev="
if($event.detail.playing){
current.playing = true
}
await goto_prev()
"
@selectepisode="
$dispatch('pleasepause')
if($event.detail.playing){
current.playing = true
}
if($event.detail.resetTime){
localStorage.setItem(`time_${$event.detail.episode.url}`, 0)
}
current.podcast = $event.detail.podcast
current.dir = $event.detail.dir
current.episode = $event.detail.episode
await $nextTick()
if($event.detail.playing){
$dispatch('pleaseplay')
}
$dispatch('pleaserewind')
"
>
[[listings-ui]]
[[player-ui]]
</body>
</html>
Head
Dependencies, styles, and imports needed by the application.
- TailwindCSS for utility-first styling, self-hosted; dark mode toggled via class.
- Alpine (core + persist + collapse) and Yjs (
yjs,y-websocket,y-indexeddb) bundled together as one ES module — see the App bundle section.
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="./podcastmanifest.json">
<script src="./tailwind.js"></script>
<script>tailwind.config = { darkMode: 'class' }</script>
<style>
html, body { margin: 0; overflow-x: hidden; background: #000; }
video { display: block; }
</style>
<script type="module" src="./bundle.js"></script>
[[wip_ipfsdocs_slider_alpinejs.org:toast-code-ex()]]
App bundle
Alpine (core + persist + collapse) plus Yjs (yjs, y-websocket,
y-indexeddb) ship as one ES module. Yjs’s three packages need to
share a single runtime — loaded separately they’d each carry their
own copy and break class identity. Alpine joins the same bundle so
the head only needs one <script type“module”>=.
{
"name": "app-bundle",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"alpinejs": "^3",
"@alpinejs/persist": "^3",
"@alpinejs/collapse": "^3",
"yjs": "^13",
"y-websocket": "^1",
"y-indexeddb": "^9"
}
}
The entry expects the inline app-script to have registered its
alpine:init listener already — the inline script runs sync, the
module is deferred, so the listener is in place by the time
Alpine.start() runs. Yjs classes are exposed on window so the
rest of the app picks them up without imports.
import Alpine from 'alpinejs'
import persist from '@alpinejs/persist'
import collapse from '@alpinejs/collapse'
import { Doc } from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
window.YjsDoc = Doc
window.YjsWebsocketProvider = WebsocketProvider
window.YjsIndexeddb = IndexeddbPersistence
Alpine.plugin(persist)
Alpine.plugin(collapse)
window.Alpine = Alpine
Alpine.start()
bun install + bun build produces the file directly into the
tangle dir; re-run when a dep version bumps.
mkdir -p /var/run/user/1000/app-bundle
cd /var/run/user/1000/app-bundle
cat > package.json <<'JSON_EOF'
[[app-bundle-package]]
JSON_EOF
cat > entry.js <<'ENTRY_EOF'
[[app-bundle-entry]]
ENTRY_EOF
bun install >&2
bun build entry.js --outfile=/var/run/user/1000/podcast/bundle.js >&2
echo done
done
Tailwind
The play-CDN build of Tailwind is a self-contained runtime script — no
node toolchain needed. Pinned to 3.4.3; re-run the fetch when bumping.
mkdir -p /var/run/user/1000/podcast
curl -fsSL https://cdn.tailwindcss.com/3.4.3 -o /var/run/user/1000/podcast/tailwind.js
Application script
The main <script> block containing the service worker registration, utility
functions, and the Alpine.js application store.
Service worker & background lock
Registers the service worker for offline caching and acquires a Web Lock to prevent the browser from discarding the page when backgrounded (important for uninterrupted audio/video playback).
Utilities
formatTime converts seconds to a human-readable HH:MM:SS string —
hours appear only past the hour mark; minutes and seconds always
zero-pad to two digits. The user meets it in the progress bar: open a
directory, open a podcast, start a three-second episode, and the total
time reads 00:03.
@testcase
def test_play_shows_duration(page):
reset(page)
page.get_by_text("dir-one", exact=True).click()
page.get_by_text("Podcast Alpha", exact=True).click()
page.get_by_text("Alpha One", exact=True).dblclick()
expect(time_readout(page)).to_contain_text("/00:03")
Alpine application store
The app store holds all reactive state and methods. Key responsibilities:
- Fetching and displaying the podcast directory (
init_app) - Persisting playback position, speed, and preferences via
localStorageand$persist - Navigating between episodes (
goto_next,goto_prev) - Syncing state across devices via Yjs CRDT (
_setupYjsSync):- The doc is local-first: it lives in IndexedDB, so it is usable and keeps recording the listener’s moves with no server in sight, and merges with peers’ state whenever the websocket reaches one
- Simple settings (show_old, auto_next, dark_mode) live as plain keys in a shared YMap; Yjs’s per-key merge picks the winner on concurrent writes
- Current episode/podcast/dir are synced as one atomic snapshot so a remote view never sees an impossible combination
- Podcast/dir bookmarks use dedicated YMaps (
podcast-last-episode,dir-last-episode) with per-name keys so entries from different devices coexist - Episode playback times use a dedicated YMap (
episode-times) with Math.max merge (further progress wins)
- Magic link authorization for Yjs websocket connections
- The websocket endpoint defaults to
/ywebsocketon the serving host; a?sync_url=query parameter overrides it (persisted tolocalStorage, then stripped from the address bar), so a device can be aimed at any sync server
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./swpodcast.js')
.then(function() {
console.log('Service Worker Registered');
})
.catch(error => {
console.error('Service Worker registration failed:', error);
alert(`Service Worker registration failed: ${error}`);
});
}
// Prevent the browser from discarding this page when backgrounded.
// The lock callback returns a promise that never resolves, so the
// lock is held for the entire lifetime of the page.
if ('locks' in navigator) {
navigator.locks.request('podcast-app-alive', () => new Promise(() => {}))
}
function formatTime(seconds) {
// remove the milliseconds
seconds = Math.floor(seconds)
// Calculate hours, minutes, and remaining seconds
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds % 3600) / 60);
let remainingSeconds = seconds % 60;
// Create an array to store parts of the time
let parts = [];
// Add hours if its greater than 0
if (hours > 0) {
parts.push(hours.toString().padStart(2, '0'));
}
// Always add minutes and seconds
parts.push(minutes.toString().padStart(2, '0'));
parts.push(remainingSeconds.toString().padStart(2, '0'));
// Join the parts with ':' and return
return parts.join(':');
}
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => ({
src: "podcasts.json",
async init_app () {
try{
// no-store because the browser cache will hide changes in
// the file. This is dealt with by the service worker
// anyway
res = await fetch(`./${this.src}`, {cache: 'no-store'})
this.dirs = await res.json()
var lastpodcast
var lastdir
var keepcurrent = false
var keeplastdir
var keeplastpodcast
for(const dir in this.dirs) {
lastdir = this.dir_last_episode[dir]
keeplastdir = false
for(const podcast of this.dirs[dir]){
lastpodcast = this.podcast_last_episode[podcast.name]
keeplastpodcast = false
for(const episode of podcast.episodes){
if(lastdir && episode.url === lastdir.episode.url && podcast.name === lastdir.podcast.name){
keeplastdir = true
}
if(lastpodcast && episode.url === lastpodcast.episode.url){
keeplastpodcast = true
}
if(this.current.episode && this.current.episode.url === episode.url) {
keepcurrent = true
}
}
if(lastpodcast && !keeplastpodcast) {
delete this.podcast_last_episode[podcast.name]
this.podcast_last_episode = {...this.podcast_last_episode}
}
}
if(lastdir && !keeplastdir) {
delete this.dir_last_episode[dir]
this.dir_last_episode = {...this.dir_last_episode}
}
}
// Clean stale entries for podcasts/dirs no longer in the feed
let allPodcastNames = new Set()
for(const dir in this.dirs) {
for(const podcast of this.dirs[dir]) {
allPodcastNames.add(podcast.name)
}
}
for(const name in this.podcast_last_episode) {
if(!allPodcastNames.has(name)) {
delete this.podcast_last_episode[name]
}
}
this.podcast_last_episode = {...this.podcast_last_episode}
for(const dir in this.dir_last_episode) {
if(!(dir in this.dirs)) {
delete this.dir_last_episode[dir]
}
}
this.dir_last_episode = {...this.dir_last_episode}
if(! keepcurrent) {
this.current.episode = null
if(this.current.podcast && !allPodcastNames.has(this.current.podcast.name)) {
this.current.podcast = null
this.current.dir = null
}
}
if(!this.current.episode && this.current.podcast) {
let last = this.podcast_last_episode[this.current.podcast.name]
if(last) {
this.current.episode = last.episode
}
}
this._setupYjsSync()
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
this._deferredInstallPrompt = e
})
window.addEventListener('appinstalled', () => {
this._deferredInstallPrompt = null
})
}
catch(err)
{
error(err)
}
},
dirs: undefined,
dark_mode: Alpine.$persist(false).as("dark_mode"),
show_old: Alpine.$persist(false).as("show_old"),
podcast_last_episode: Alpine.$persist({}).as("podcast_last_episode3"),
dir_last_episode: Alpine.$persist({}).as("dir_last_episode"),
current_speed: null,
current_progress: {cur: 0, duration: 0},
current: {
playing: false,
episode: Alpine.$persist(null).as("episode"),
podcast: Alpine.$persist(null).as("podcast"),
dir: Alpine.$persist(null).as("dir"),
},
auto_next: Alpine.$persist(true).as("auto_next"),
_deferredInstallPrompt: null,
_yjsSyncing: false,
_yjsConnected: false,
_yjsReady: false,
_yjsMap: null,
_yjsTimeSyncTimeout: null,
_yjsTimeSyncLast: 0,
_yjsTimeSyncPending: new Map(),
// Throttle (not debounce) — flush every 5s while timeupdate keeps
// firing, plus a trailing flush so the last value lands when
// playback stops (debounce alone meant nothing synced until pause,
// which is the worst moment if pause coincides with losing network).
// Pending is a Map url→time so auto-advance mid-throttle doesn't
// clobber the 0-reset of the just-ended episode (which would
// leave Yjs with a near-end time and make the next loop resume
// that episode near the end instead of the start).
_syncEpisodeTime(url, time) {
if (!this._yjsTimesMap) return
this._yjsTimeSyncPending.set(url, time)
const flush = () => {
const pending = this._yjsTimeSyncPending
this._yjsTimeSyncPending = new Map()
this._yjsTimeSyncTimeout = null
this._yjsTimeSyncLast = Date.now()
if (pending.size === 0) return
this._yjsTimesMap.doc.transact(() => {
for (const [u, t] of pending) this._yjsTimesMap.set(u, t)
}, 'local')
}
const since = Date.now() - this._yjsTimeSyncLast
if (since >= 5000) {
clearTimeout(this._yjsTimeSyncTimeout)
flush()
} else if (this._yjsTimeSyncTimeout === null) {
this._yjsTimeSyncTimeout = setTimeout(flush, 5000 - since)
}
},
key(stuff) {
return `${stuff}_${this.current.episode.url}`
},
goto_next() {
localStorage.setItem(this.key('time'), 0)
this._syncEpisodeTime(this.current.episode.url, 0)
let pos_current_episode = this.current.podcast.episodes.findIndex(
(episode) => episode.url === this.current.episode.url
)
if(pos_current_episode + 1 === this.current.podcast.episodes.length) {
localStorage.setItem(this.current.podcast.name + "_looped_first_episode", this.current.podcast.episodes[0].url)
}
this.current.episode = this.current.podcast.episodes[(pos_current_episode + 1) % this.current.podcast.episodes.length]
},
goto_prev() {
localStorage.setItem(this.key('time'), 0)
this._syncEpisodeTime(this.current.episode.url, 0)
let pos_current_episode = this.current.podcast.episodes.findIndex(
(episode) => episode.url === this.current.episode.url
)
this.current.episode = this.current.podcast.episodes[(pos_current_episode - 1 + this.current.podcast.episodes.length) % this.current.podcast.episodes.length]
},
async shareEpisode() {
if (navigator.share) {
try {
await navigator.share({
title: this.current.episode.name,
text: this.current.episode.name,
url: this.current.episode.origin_url,
});
info('Episode shared successfully');
} catch (error) {
info('Error sharing episode: ' + error);
console.log(`Error sharing episode: ${error}, ${this.current.episode.origin_url}`);
}
} else {
// Fallback for browsers that do not support the Web Share API
try {
await navigator.clipboard.writeText(this.current.episode.origin_url);
info('Episode URL copied to clipboard');
} catch (error) {
info('Error copying to clipboard: ' + error);
}
}
},
async _setupYjsSync() {
if (!window.YjsDoc || !window.YjsWebsocketProvider || !window.YjsIndexeddb) {
console.warn('Yjs not loaded, skipping sync')
return
}
const doc = new window.YjsDoc()
const ymap = doc.getMap("podcast-player")
const timesMap = doc.getMap("episode-times")
const podcastLastMap = doc.getMap("podcast-last-episode")
const dirLastMap = doc.getMap("dir-last-episode")
this._yjsMap = ymap
this._yjsTimesMap = timesMap
// Tag every local Yjs write so observers can recognise their
// own echo and skip.
const localTransact = (fn) => doc.transact(fn, 'local')
// While applying a remote change to Alpine, raise _yjsSyncing so
// the Alpine→Yjs watchers don't bounce the same value back out
// on the next reactive cycle. Clear via $nextTick — after
// Alpine has flushed effects.
const applyingRemote = (fn) => {
this._yjsSyncing = true
fn()
this.$nextTick(() => { this._yjsSyncing = false })
}
const getVal = (path) => path.split('.').reduce((obj, key) => obj?.[key], this)
const setVal = (path, val) => {
const parts = path.split('.')
const last = parts.pop()
parts.reduce((obj, key) => obj[key], this)[last] = val
}
// --- Simple keys (show_old, auto_next, dark_mode) ---
// Each lives at its own key in the shared ymap. Yjs's per-key
// merge picks the winner on concurrent writes; no wall-clock
// timestamps.
const SIMPLE_KEYS = {
'show_old': 'show_old',
'auto_next': 'auto_next',
'dark_mode': 'dark_mode',
}
for (const [alpinePath, yjsKey] of Object.entries(SIMPLE_KEYS)) {
this.$watch(alpinePath, (newVal) => {
if (!this._yjsReady || this._yjsSyncing) return
localTransact(() => ymap.set(yjsKey, JSON.parse(JSON.stringify(newVal))))
})
}
// --- Current triple (episode/podcast/dir as one atomic value) ---
// Synced as one snapshot — switching podcasts means three
// sub-keys change at once and we never want a remote to see an
// impossible combination (new podcast, old episode url).
const CURRENT_YJS_KEY = 'current'
const getCurrentSnapshot = () => JSON.parse(JSON.stringify({
episode: this.current.episode,
podcast: this.current.podcast,
dir: this.current.dir,
}))
const applyCurrentSnapshot = (snap) => {
this.current.episode = snap.episode
this.current.podcast = snap.podcast
this.current.dir = snap.dir
// If we landed on a podcast with no episode (rare), pick its
// last known so playback has something to load.
if (!this.current.episode && this.current.podcast) {
const last = this.podcast_last_episode[this.current.podcast.name]
if (last) this.current.episode = last.episode
}
}
for (const subKey of ['current.episode', 'current.podcast', 'current.dir']) {
this.$watch(subKey, () => {
if (!this._yjsReady || this._yjsSyncing) return
localTransact(() => ymap.set(CURRENT_YJS_KEY, getCurrentSnapshot()))
})
}
// --- Shared observer for simple keys + current ---
ymap.observe((event) => {
if (event.transaction.origin === 'local') return
applyingRemote(() => {
event.changes.keys.forEach((change, key) => {
if (key === CURRENT_YJS_KEY) {
const snap = ymap.get(key)
if (snap) applyCurrentSnapshot(snap)
} else {
const alpinePath = Object.entries(SIMPLE_KEYS).find(([, k]) => k === key)?.[0]
if (alpinePath) setVal(alpinePath, ymap.get(key))
}
})
})
})
// --- Per-key CRDT maps (podcast_last_episode, dir_last_episode) ---
// Each named entry is its own slot; Yjs's per-key merge handles
// concurrent edits to different names without losing entries.
const setupPerKeyMap = (alpinePath, yjsMap) => {
const pullAll = () => {
const obj = {}
yjsMap.forEach((val, name) => { obj[name] = val })
setVal(alpinePath, obj)
}
yjsMap.observe((event) => {
if (event.transaction.origin === 'local') return
applyingRemote(pullAll)
})
this.$watch(alpinePath, (newVal) => {
if (!this._yjsReady || this._yjsSyncing) return
localTransact(() => {
for (const [name, entry] of Object.entries(newVal)) {
const serialized = JSON.parse(JSON.stringify(entry))
if (JSON.stringify(serialized) !== JSON.stringify(yjsMap.get(name))) {
yjsMap.set(name, serialized)
}
}
})
})
// Initial merge: pull remote entries into Alpine, push
// local-only entries to Yjs.
return () => {
yjsMap.forEach((val, name) => { getVal(alpinePath)[name] = val })
setVal(alpinePath, {...getVal(alpinePath)})
for (const [name, entry] of Object.entries(getVal(alpinePath))) {
if (!yjsMap.has(name)) yjsMap.set(name, JSON.parse(JSON.stringify(entry)))
}
}
}
const mergePodcastLast = setupPerKeyMap('podcast_last_episode', podcastLastMap)
const mergeDirLast = setupPerKeyMap('dir_last_episode', dirLastMap)
// --- Episode times (max-merge) ---
// Outbound writes happen in =_syncEpisodeTime= (debounced).
// Inbound is "remote wins for this url" into localStorage.
// Initial merge uses Math.max so further-along progress wins.
timesMap.observe((event) => {
if (event.transaction.origin === 'local') return
event.changes.keys.forEach((change, url) => {
const time = timesMap.get(url)
if (time !== undefined) localStorage.setItem('time_' + url, time)
})
})
const mergeTimes = () => {
// Prune entries for episodes absent from the feed for
// more than 2 months. Each load refreshes a per-url
// =seen_<url>= timestamp in localStorage; URLs that have
// gone stale for >2 months are dropped from both timesMap
// and localStorage. Without this the map grows forever.
// Legacy entries without a seen-record get a fresh grace
// period from now (we don't punish them for not having
// been tracked).
const TWO_MONTHS_MS = 60 * 24 * 60 * 60 * 1000
const now = Date.now()
const validUrls = new Set()
for (const dir in this.dirs) {
for (const podcast of this.dirs[dir]) {
for (const episode of podcast.episodes) {
validUrls.add(episode.url)
}
}
}
for (const url of validUrls) {
localStorage.setItem('seen_' + url, now)
}
const stale = []
timesMap.forEach((_, url) => {
if (validUrls.has(url)) return
const seen = parseInt(localStorage.getItem('seen_' + url))
if (!seen) {
localStorage.setItem('seen_' + url, now)
} else if (now - seen > TWO_MONTHS_MS) {
stale.push(url)
}
})
for (const url of stale) {
timesMap.delete(url)
localStorage.removeItem('time_' + url)
localStorage.removeItem('seen_' + url)
}
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (!k?.startsWith('time_')) continue
const url = k.slice(5)
if (!timesMap.has(url)) timesMap.set(url, parseFloat(localStorage.getItem(k)))
}
timesMap.forEach((remoteTime, url) => {
const localTime = parseFloat(localStorage.getItem('time_' + url)) || 0
const merged = Math.max(localTime, remoteTime)
localStorage.setItem('time_' + url, merged)
if (merged > remoteTime) timesMap.set(url, merged)
})
}
// --- Local persistence + readiness gate ---
// Persist the Yjs doc to IndexedDB so offline ops survive an app
// restart and merge with the server's state on reconnect via
// Yjs's native CRDT. Without this, the in-memory doc dies with
// the JS heap and the next sync clobbers any offline changes.
// Once =whenSynced= resolves the local state is loaded, and that
// is the moment the store becomes the source of truth: we raise
// the readiness gate so every Alpine→Yjs watcher above starts
// writing through to the doc. The gate is local, not remote —
// the listener may have no server in sight, yet what they do
// between launches must still reach IndexedDB and survive the
// reload. The websocket, opened next, only folds in what a peer
// knew that we didn't.
const persistence = new window.YjsIndexeddb('podcast-player', doc)
await persistence.whenSynced
this._yjsReady = true
// --- WebSocket + remote fold-in ---
// The gate is already open and the doc is the source of truth;
// this runs once the socket reports a completed sync, when the
// doc has merged with the server's. For simple keys and current:
// whatever the merged doc holds wins, applied locally; a key the
// doc lacks is seeded from the local Alpine value. Per-key maps
// and times have their own bidirectional merges above.
// The endpoint defaults to /ywebsocket on the serving host.
// A ?sync_url= query parameter overrides it: persist it, then
// strip it from the address bar so a shared link can't re-pin a
// stale endpoint on the next reload.
const params = new URLSearchParams(location.search)
const fromParam = params.get('sync_url')
if (fromParam) {
localStorage.setItem('podcast.sync_url', fromParam)
const clean = new URL(location)
clean.searchParams.delete('sync_url')
history.replaceState(null, '', clean)
}
const wsUrl = localStorage.getItem('podcast.sync_url')
|| (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ywebsocket'
const provider = new window.YjsWebsocketProvider(wsUrl, 'podcast-player', doc)
provider.on('status', (event) => { this._yjsConnected = event.status === 'connected' })
provider.on('sync', (isSynced) => {
if (!isSynced) return
this._yjsSyncing = true
localTransact(() => {
for (const [alpinePath, yjsKey] of Object.entries(SIMPLE_KEYS)) {
const remote = ymap.get(yjsKey)
if (remote !== undefined) {
setVal(alpinePath, remote)
} else {
ymap.set(yjsKey, JSON.parse(JSON.stringify(getVal(alpinePath))))
}
}
const remoteCurrent = ymap.get(CURRENT_YJS_KEY)
if (remoteCurrent) {
applyCurrentSnapshot(remoteCurrent)
} else {
ymap.set(CURRENT_YJS_KEY, getCurrentSnapshot())
}
mergePodcastLast()
mergeDirLast()
mergeTimes()
})
this.$nextTick(() => { this._yjsSyncing = false })
})
},
}))
})
Cross-device sync
Simple settings live in a shared Yjs map, so a change on one device
reaches the others. The test stands up a real konubinix/ywebsocket
server, opens the app twice against one private room, waits for both
to report sync connected, then toggles dark mode on one device and
watches the other’s toggle flip to offer light — the listener’s two
phones agree without either being touched twice.
@testcase
def test_setting_syncs_across_devices(page):
sync_url = require_sync_server() + "/" + uuid.uuid4().hex
with two_contexts(page.context.browser) as (ctxA, ctxB):
pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
pageA.get_by_role("button", name="sync").wait_for(timeout=15000)
pageB = open_app(ctxB, f"{BASE_URL}?sync_url={sync_url}")
pageB.get_by_role("button", name="sync").wait_for(timeout=15000)
pageA.get_by_role("button", name="dark", exact=True).click()
expect(pageB.get_by_role("button", name="light", exact=True)).to_be_visible()
Surviving a dropped sync
The sync socket is not always there — the phone walks out of wifi, the
server restarts. When it drops, the listener keeps browsing, and the
episode they open must be the one waiting for them on the next launch.
A device that has synced once carries the synced episode in its local
doc, and that is the trap: the test syncs against a real server, opens
Alpha One, then stops the server mid-session. With the socket gone
the listener moves on to Alpha Two and relaunches — and finds
Alpha Two loaded in the player, not the episode the last sync
happened to leave behind.
@testcase
def test_offline_navigation_survives_reload(page):
reset(page)
sync_url, container = start_disposable_sync_server()
try:
page.goto(f"{BASE_URL}?sync_url={sync_url}")
page.get_by_text("dir-one", exact=True).wait_for(timeout=15000)
page.get_by_role("button", name="sync").wait_for(timeout=15000)
page.get_by_text("dir-one", exact=True).click()
page.get_by_role("heading", level=2, name="Podcast Alpha").click()
page.get_by_text("Alpha One", exact=True).dblclick()
expect(time_readout(page)).to_contain_text("/00:03")
page.get_by_role("button", name="⏸", exact=True).click()
finally:
stop_sync_server(container)
page.reload()
page.get_by_text("dir-one", exact=True).wait_for()
page.get_by_text("dir-one", exact=True).click()
page.get_by_role("heading", level=2, name="Podcast Alpha").click()
page.get_by_text("Alpha Two", exact=True).dblclick()
expect(time_readout(page)).to_contain_text("/00:05")
page.get_by_role("button", name="⏸", exact=True).click()
page.reload()
page.get_by_text("dir-one", exact=True).wait_for()
expect(page.locator("#player source")).to_have_attribute("src", re.compile(r"b\.mp3$"))
The server here is disposable — the test stops it mid-run to make the
socket drop — so it can’t share the cached one from the previous test.
start_disposable_sync_server starts a fresh konubinix/ywebsocket
container and hands back its url and name; stop_sync_server kills it.
def start_disposable_sync_server():
if not shutil.which("docker"):
raise RuntimeError("docker not found — required for sync tests")
port = _free_port()
container = f"podcast-sync-{uuid.uuid4().hex[:8]}"
subprocess.check_call(
["docker", "run", "-d", "--rm", "--name", container,
"-e", "HOST=0.0.0.0", "-e", "PORT=1234",
"-p", f"127.0.0.1:{port}:1234",
SYNC_IMAGE, "y-websocket-server"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
ready = _wait_ws_handshake(port)
if ready is not True:
stop_sync_server(container)
raise RuntimeError(f"disposable sync server didn't handshake on :{port} (last: {ready!r})")
return f"ws://localhost:{port}", container
def stop_sync_server(container):
subprocess.run(["docker", "stop", container],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
Listings UI
The main browsable interface showing the podcast directory hierarchy:
directories > podcasts > episodes.
Divided into several areas stacked vertically in a full-height flex column:
- Episode browser (
#podcasts): collapsible directory and podcast sections, with resume/play buttons at each level. Episodes are highlighted when currently playing. Completed podcasts shown in red. - Countdown timer (
#countdown): sleep timer that gradually fades volume before pausing. Shake the phone within 10 seconds to restart (viadevicemotion). - Playback controls: previous/rewind/play-pause/ffwd/next buttons and speed adjustment.
- Progress bar: current time display and seek slider (double-click to enable).
- Toolbar: toggles for auto-next, show-old, sync status, locate current episode, dark mode, and build hash.
<div id="listings" class="h-screen flex flex-col bg-white text-black dark:bg-gray-900 dark:text-gray-100">
<div id="podcasts" class="flex-grow overflow-scroll"
x-effect.save-old-podcats="
if(current.podcast && current.episode && current.dir){
let old = {...podcast_last_episode}
old[current.podcast.name] = {episode: current.episode, dir: current.dir}
podcast_last_episode = old
old = {...dir_last_episode}
old[current.dir] = {episode: current.episode, podcast: current.podcast}
dir_last_episode = old
}
"
>
<template x-for="(podcasts, dir) in dirs">
<div class="dir"
x-data="{extended: false}">
<h1
:class="{
'bg-yellow-100 dark:bg-yellow-800': current.dir === dir,
'text-red-300 dark:text-red-500': podcasts.every(podcast => localStorage.getItem(podcast.name + '_looped_first_episode') === podcast.episodes[0].url),
}"
class="rounded" @click="extended = !extended">
<span class="w-8 inline-block text-right" x-text="extended ? '↓' : '→'"></span>
<span class="text-2xl" x-text="dir"></span>
<button class="text-sm"
@click.stop="
if (dir in dir_last_episode) {
$dispatch('selectepisode', {
dir: dir,
podcast: dir_last_episode[dir].podcast,
episode: dir_last_episode[dir].episode,
playing: true,
})
} else {
$dispatch('selectepisode', {
dir: dir,
podcast: podcasts[0],
episode: podcasts[0].episodes[0],
playing: true,
resetTime: true,
})
}">▶</button> <span class="text-xs" x-text="dir_last_episode[dir] && dir_last_episode[dir].podcast.name"></span>
</h1>
<div x-collapse x-show="extended" class="border-2 border-yellow-100 dark:border-yellow-800 rounded">
<template x-for="podcast in podcasts">
<div class="podcast"
x-data="{extended: false}"
x-show="show_old || localStorage.getItem(podcast.name + '_looped_first_episode') !== podcast.episodes[0].url"
>
<h2
:class="{
'bg-blue-100 dark:bg-blue-900': current.podcast && current.podcast.name === podcast.name,
'text-red-300 dark:text-red-500': localStorage.getItem(podcast.name + '_looped_first_episode') === podcast.episodes[0].url,
'font-bold': dir_last_episode[dir] && (podcast.name === dir_last_episode[dir].podcast.name),
} "
@click="extended = !extended"
class="rounded pl-8"
>
<span class="w-8 inline-block text-right" x-text="extended ? '↓' : '→'"></span>
<span x-text="podcast.name" class="text-lg"></span>
<button class="text-sm"
@click.stop="
let saved = podcast_last_episode[podcast.name]
let episode = saved && podcast.episodes.find(e => e.url === saved.episode.url)
if (episode) {
$dispatch('selectepisode', {
dir: saved.dir,
podcast: podcast,
episode: episode,
playing: true,
})
} else {
$dispatch('selectepisode', {
dir: dir,
podcast: podcast,
episode: podcast.episodes[0],
playing: true,
resetTime: true,
})
}">▶</button>
</h2>
<div id="episodes" class="bg-slate-100 dark:bg-slate-800 border-2 dark:border-slate-700 overflow-x-scroll" x-collapse x-show="extended">
<template x-for="(episode, index) in podcast.episodes">
<div id="episode"
@dblclick="$dispatch('selectepisode', {dir: dir, podcast: podcast, episode: episode, playing: true})"
:class="{
'bg-green-100 dark:bg-green-900': current.episode && current.episode.url === episode.url,
'text-red-300 dark:text-red-500': localStorage.getItem(podcast.name + '_looped_first_episode') === podcast.episodes[0].url,
'font-bold': (current.podcast && current.podcast.name === podcast.name) ? (current.episode && current.episode.url === episode.url) : (podcast.name in podcast_last_episode && podcast_last_episode[podcast.name].episode.url === episode.url),
}"
class="rounded w-fit text-nowrap"
>
<div>
<span x-show="episode.album">
<span x-text="episode.album"></span>:
<span x-text="episode.index"></span> -
</span>
<span x-text="episode.name"></span><span x-text="episode.date ? ' - ' + episode.date : ''"></span>
<button x-show="current.episode && current.episode.url === episode.url && episode.origin_url" @click.stop="current.episode = episode ; shareEpisode()" class="text-sm">📤</button>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<div id="countdown"
x-show='current_progress.duration'
class="flex items-center bg-gray-100 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700"
x-data="{motionreplaytimer: null, countdown: null, remaining_time: 0, time: $persist(1),
start() {
this.$dispatch('pleaseplay')
this.remaining_time = this.time * 60
if(this.countdown)
{
clearInterval(this.countdown)
this.countdown = null
}
this.countdown = setInterval(() => {
this.remaining_time-=1
if(this.remaining_time == 4)
{
this.$dispatch('pleasevolume', {volume: 0.8})
}
if(this.remaining_time == 3)
{
this.$dispatch('pleasevolume', {volume: 0.6})
}
if(this.remaining_time == 2)
{
this.$dispatch('pleasevolume', {volume: 0.4})
}
if(this.remaining_time == 1)
{
this.$dispatch('pleasevolume', {volume: 0.2})
}
if(this.remaining_time <= 0){
this.$dispatch('pleasepause')
this.$dispatch('pleaserewind')
this.remaining_time = 0
clearInterval(this.countdown)
this.countdown=null
if(this.motionreplaytimer){clearTimeout(this.motionreplaytimer)}
this.motionreplaytimer = setTimeout(() => {
info('end of time to shake to play')
this.motionreplaytimer = null
this.$dispatch('pleaserewind', {time: 50}) // I most likely missed the end
}, 10000)
}
}, 1000)
},
}" class="w-full flex"
@devicemotion.window="let motion = (
$event.acceleration.x * $event.acceleration.x
+ $event.acceleration.y * $event.acceleration.y
+ $event.acceleration.z * $event.acceleration.z
)
if(motion > 400 && motionreplaytimer){
clearTimeout(motionreplaytimer)
motionreplaytimer = null
info('shaked, again!!')
start()
}
"
>
<div class="flex-grow flex items-center">
<input x-show="!countdown" type="number" class="w-8 dark:text-gray-100 dark:bg-gray-700" x-model="time" @focus="$el.select()"/>
<span x-show="countdown" class="text-sm dark:text-gray-100" x-text="Math.floor(remaining_time/60) + ':' + String(remaining_time%60).padStart(2,'0')"></span>
<input x-show="countdown" type="range" min="0" disabled :max="time * 60" :value="time * 60 - remaining_time" class="flex-grow"/>
</div>
<button @click="
if(countdown){
clearInterval(countdown)
countdown = null
$dispatch('pleasepause')
$dispatch('pleaserewind')
remaining_time = 0
}
else
{
start()
}
"
x-text="countdown ? 'stop' : 'start'"></button>
</div>
<template x-if="current_progress.duration">
<div id="controls" x-show="current.episode && dirs">
<span id="controls" class="flex place-content-evenly text-3xl">
<button @click="$dispatch('gotoprev')">⏮</button>
<button @click="$dispatch('pleaserewind')">⏪</button>
<button @click="$dispatch(current.playing ? 'pleasepause' : 'pleaseplay')" x-text="current.playing ? '⏸' : '▶'"></button>
<button @click="$dispatch('pleaseffwd')">⏩</button>
<button @click="$dispatch('gotonext')">⏭</button>
<span class="text-sm flex items-center gap-1"
x-effect.follow-current-episode="if(current.episode){current_speed = localStorage.getItem(key('rate')) || 1}"
>
<button @click="$dispatch('pleasesetspeed', {speed: Math.max(0.25, parseFloat(current_speed) - 0.25)})">-</button>
<span x-text="current_speed">1</span>x
<button @click="$dispatch('pleasesetspeed', {speed: Math.min(4, parseFloat(current_speed) + 0.25)})">+</button>
</span>
</span>
<span id="progress" class="text-sm w-full flex flex-row"
x-data="{
disabled: true,
disabled_timer: null,
async set_timer () {
if(this.disabled_timer !== null){
clearTimeout(this.disabled_timer)
}
this.disabled_timer = setTimeout(() => {this.disabled = true; this.disabled_timer = null}, 5000)
},
}"
@dblclick="disabled = !disabled ; if(! disabled) {await set_timer()} else if (disabled_timer !== null) {clearTimeout(disabled_timer) ; disabled_timer=null;}"
>
<span>
<span x-text="formatTime(current_progress.cur)"></span>/<span x-text="formatTime(current_progress.duration)"></span>
</span>
<input
type="range"
class="flex-grow"
min="0" :max="current_progress.duration"
:disabled="disabled"
:value="current_progress.cur"
@input="$dispatch('setcurrenttime', {value: $el.value}) ; await set_timer()"
/>
</span>
</div>
</template>
<div class="flex place-content-evenly items-center py-1 bg-gray-100 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700">
<button @click="auto_next = !auto_next" :class="{'bg-green-200 dark:bg-green-800': auto_next, 'bg-red-200 dark:bg-red-800': !auto_next}" class="px-1 rounded text-sm" x-text="auto_next ? '⏭auto' : '⏹auto'"></button>
<button @click="show_old = !show_old" :class="{'bg-green-200 dark:bg-green-800': show_old, 'bg-red-200 dark:bg-red-800': !show_old}" class="px-1 rounded text-sm" x-text="show_old ? 'old' : 'new'"></button>
<button x-show="_deferredInstallPrompt" @click="_deferredInstallPrompt.prompt().then(() => _deferredInstallPrompt.userChoice).then(r => { if(r.outcome === 'accepted') _deferredInstallPrompt = null })" class="bg-purple-600 text-white text-sm px-2 py-0.5 rounded cursor-pointer font-bold">install</button>
<button x-show="_yjsConnected" @click="if(confirm('Clear authorization?')) location.target="_blank" href='/authorizationserver/clear-cookie?resource=ywebsocket/podcast-player'" class="bg-green-600 text-white text-sm px-2 py-0.5 rounded cursor-pointer font-bold">sync</button>
<span x-show="!_yjsConnected" class="bg-red-600 text-white text-sm px-2 py-0.5 rounded font-bold">offline</span>
<button @click="
if(!current.dir) return;
let dirEl = [...document.querySelectorAll('.dir')].find(d => Alpine.$data(d).dir === undefined ? false : true);
for(let d of document.querySelectorAll('.dir')) { let h = d.querySelector('h1'); if(h && h.textContent.includes(current.dir)) { Alpine.$data(d).extended = true; let podcasts = d.querySelectorAll('.podcast'); for(let p of podcasts) { let name = current.podcast?.name; if(name && p.textContent.includes(name)) { Alpine.$data(p).extended = true; } } break; } }
await $nextTick();
document.querySelector('#episode.bg-green-100')?.scrollIntoView({behavior:'smooth',block:'center'});
" class="text-sm px-2 py-0.5 rounded cursor-pointer font-bold bg-gray-200 dark:bg-gray-700">📍</button>
<button @click="dark_mode = !dark_mode" class="text-sm px-2 py-0.5 rounded cursor-pointer font-bold bg-gray-200 dark:bg-gray-700" x-text="dark_mode ? 'light' : 'dark'"></button>
<span class="text-xs text-gray-400">[[build-hash()]]</span>
</div>
</div>
Advancing and completing
The ⏭ button steps to the next episode of the current podcast. Start
Alpha One — three seconds — and one press lands on Alpha Two — five; the
progress bar’s total time is how the listener tells which episode now
plays.
page.get_by_text("dir-one", exact=True).click()
page.get_by_text("Podcast Alpha", exact=True).click()
page.get_by_text("Alpha One", exact=True).dblclick()
expect(time_readout(page)).to_contain_text("/00:03")
page.get_by_role("button", name="⏭", exact=True).click()
expect(time_readout(page)).to_contain_text("/00:05")
On the last episode the same press wraps back to the first, so a podcast loops instead of dead-ending.
page.get_by_role("button", name="⏭", exact=True).click()
expect(time_readout(page)).to_contain_text("/00:03")
That wrap completes the podcast. Reopen the app with show_old off —
the default — and the finished podcast drops out of the directory’s
browsable list while an unfinished neighbour stays put: the listener is
left with only what they have not heard. (Its name still lingers in the
directory header as the last thing played there — completion hides the
row you browse, not the memory of it.)
page.reload()
page.get_by_text("dir-one", exact=True).click()
expect(page.get_by_role("heading", level=2, name="Podcast Beta")).to_be_visible()
expect(page.get_by_role("heading", level=2, name="Podcast Alpha")).not_to_be_visible()
Resuming what still exists
A podcast’s ▶ resumes the episode the listener last left there. But a
feed is not frozen: episodes get re-cut, renamed, dropped. The
remembered episode is a snapshot, and the synced doc keeps handing it
back even after init_app has pruned it, so resume must not trust it
blindly — it resumes the saved episode only while the feed still lists
it, and otherwise starts the podcast from the top rather than loading a
URL that no longer exists.
The test plays Alpha Two, then swaps the feed for one where Alpha Two is gone and a fresh Alpha Three stands in its place. Pressing
resume lands on Alpha Three — the podcast’s first surviving episode —
not on the vanished clip.
@testcase
def test_resume_after_episodes_changed(page):
reset(page)
page.get_by_text("dir-one", exact=True).click()
page.get_by_role("heading", level=2, name="Podcast Alpha").click()
page.get_by_text("Alpha Two", exact=True).dblclick()
expect(time_readout(page)).to_contain_text("/00:05")
page.get_by_role("button", name="⏸", exact=True).click()
changed = {
"dir-one": [
{"name": "Podcast Alpha", "episodes": [episode("Alpha Three", "c.mp3", 7)]},
{"name": "Podcast Beta", "episodes": [episode("Beta One", "c.mp3", 7)]},
],
}
(TANGLE_DIR / "podcasts.json").write_text(json.dumps(changed))
try:
page.reload()
page.get_by_text("dir-one", exact=True).wait_for()
page.get_by_text("dir-one", exact=True).click()
page.get_by_role("heading", level=2, name="Podcast Alpha").get_by_role(
"button", name="▶").click()
expect(page.locator("#player source")).to_have_attribute("src", re.compile(r"c\.mp3$"))
finally:
write_fixture()
Player
The audio/video player, shown below the listings. Uses a shared medium Alpine
binding for both <audio> and <video> elements, providing:
- Position and speed restoration from
localStorageon init - Media Session API integration (lock screen controls, metadata)
- Continuous position saving to
localStorageand Yjs ontimeupdate - Auto-advance to next episode on end (if
auto_nextenabled) - Seamless track transitions:
goto_next=/=goto_prevassign the new episode directly (without nullingcurrent.episode) so the media element is never destroyed. A$watchinmediumdetects the URL change and calls.load()to switch source. This avoids anawait $nextTick()gap during which Chrome can freeze the PWA when the screen is locked. A silent looping audio keepalive was tried first but didn’t prevent the freeze — the issue is Chrome suspending JS execution during the async gap, not the OS killing the app for lack of audio session.
The video player adds touch zones: left quarter rewinds, right quarter fast-forwards, center toggles play/pause, bottom center triggers Picture-in-Picture.
<div id="player"
class="flex justify-center bg-black"
x-data="{
init_values() {
let cur = localStorage.getItem(key('time'))
if(cur)
{
this.$el.currentTime = cur
}
let episode_rate = localStorage.getItem(key('rate'))
if(episode_rate)
{
this.$el.playbackRate = parseFloat(episode_rate)
}
else
{
let podcast_rate = localStorage.getItem(`rate_${current.podcast.name}`)
if(podcast_rate)
{
this.$el.playbackRate = parseFloat(podcast_rate)
}
}
window.v = this.$el
},
onended() {
localStorage.setItem(this.key('time'), 0)
this._syncEpisodeTime(this.current.episode.url, 0)
if(this.auto_next) {
this.$dispatch('gotonext', {playing: true})
}
},
_initMedium(el) {
this.init_values(el)
if('mediaSession' in navigator) {
var artist = ''
if(this.current.episode.index !== null)
{
artist += `${this.current.episode.index}`
}
if(this.current.episode.number !== null)
{
artist += `/${this.current.episode.number}`
}
if(artist !== '')
{
artist += ' '
}
artist += this.current.podcast.name
navigator.mediaSession.metadata = new MediaMetadata({
title: this.current.episode.name,
artist: artist,
album: this.current.episode.album || this.current.podcast.name,
});
navigator.mediaSession.setActionHandler('nexttrack', () => {this.$dispatch('gotonext', {playing: true})});
navigator.mediaSession.setActionHandler('previoustrack', () => {this.$dispatch('gotoprev', {playing: true})});
navigator.mediaSession.playbackState = 'paused'
}
},
medium: {
async ['x-init']() {
this._initMedium(this.$el)
// watch for episode changes without element destruction (goto_next/prev)
this.$watch('current.episode', async (ep, oldEp) => {
if(!ep || ep === oldEp) return
if(oldEp && ep.url === oldEp.url) return
if(!this.$el.isConnected) return
this.$el.load()
this._initMedium(this.$el)
if(this.current.playing) {
await this.$nextTick()
this.$el.play()
}
this.current_progress = {cur: this.$el.currentTime, duration: this.$el.duration}
})
if(this.current.playing) {
await $nextTick()
this.$el.play()
}
this.current_progress = {cur: this.$el.currentTime, duration: this.$el.duration}
},
['@pleasesetspeed.window']() {
this.$el.playbackRate = this.$event.detail.speed
},
['@play']() {
this.$el.volume = 1
this.current.playing = true
},
['@pleaseplay.window'](){
this.$el.play()
},
['@pleasevolume.window'](){
this.$el.volume = this.$event.detail.volume
},
['@pleasepause.window'](){
this.$el.pause()
},
['@pleaseffwd.window'](){
this.$el.currentTime += 10
},
['@pleaserewind.window'](){
this.$el.currentTime -= this.$event.detail.time || 10
},
['@pause.window'](){
this.$el.pause()
},
async ['@loadeddata'](){
await $nextTick()
this.$el.scrollIntoView()
},
['@pause']() {
this.current.playing = false
if(this.$el.currentTime === this.$el.duration)
{
this.onended()
}
},
['@ended']() {
// this is not triggerd for audio in qutebrowser
},
['@timeupdate'](){
localStorage.setItem(this.key('time'), this.$el.currentTime)
this.current_progress = {cur: this.$el.currentTime, duration: this.$el.duration}
this._syncEpisodeTime(this.current.episode.url, this.$el.currentTime)
},
['@setcurrenttime.window']() {
this.$el.currentTime = this.$event.detail.value
},
['@ratechange'](){
localStorage.setItem(this.key('rate'), this.$el.playbackRate)
localStorage.setItem(`rate_${current.podcast.name}`, this.$el.playbackRate)
this.current_speed = this.$el.playbackRate
},
},
}"
>
<template x-if="current.episode && ( current.episode.url.endsWith('mp3') || current.episode.url.endsWith('ogg') || current.episode.url.endsWith('m4a'))">
<audio class="w-full"
x-bind="medium"
>
<source :src="current.episode && current.episode.url" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</template>
<template x-if="current.episode && ! ( current.episode.url.endsWith('mp3') || current.episode.url.endsWith('ogg') || current.episode.url.endsWith('m4a'))">
<div class="relative w-full h-screen">
<video class="w-full h-full object-contain"
x-bind="medium"
x-data="{
async click(event) {
xpos = event.offsetX / event.target.clientWidth
ypos = event.offsetY / event.target.clientHeight
if(xpos < 0.25) {
info('rewind')
this.$dispatch('pleaserewind')
}
else if (xpos > 0.75) {
info('ffwd')
this.$dispatch('pleaseffwd')
}
else {
if(ypos > 0.80)
{
info('PIP')
try{
await this.$el.requestPictureInPicture()
} catch(e) {
info(JSON.stringify(e))
}
}
else
{
info('toggling')
if(this.current.playing){
this.$dispatch('pleasepause')
}
else
{
this.$dispatch('pleaseplay')
}
}
}
}
}"
@dblclick="await click($event)"
>
<source :src="current.episode && current.episode.url" type="audio/mpeg">
</video>
</div>
</template>
</div>
Authorization
Authorization is handled via a magic link: the admin generates a link
with clk authorizationserver generate, the user clicks it, and the
server sets a cookie and redirects to the app. The sync indicator in the
toolbar turns green when connected.
Pour generer un code d’autorisation :
clk authorizationserver generate --redirect "http://localhost:9682/Documents/localhost/podcast.html" ywebsocket/podcast-player sam-phone
Manifest
PWA manifest declaring the app name, icons, display mode (fullscreen) and scope.
Tangled separately to podcastmanifest.json.
{
"name": "podcast",
"short_name": "podcast",
"description": "podcast",
"start_url": "./podcast.html",
"display": "fullscreen",
"background_color": "#cccccc",
"theme_color": "#4285f4",
"icons": [
{
"src": "./podcast.png",
"type": "image/png",
"sizes": "192x192"
}
],
"scope": "./",
"permissions": [],
"splash_pages": null,
"categories": []
}
Note: one may be tempted to add “orientation”: “any” but that would actually say to ignore the device orientation settings. Putting no orientation requests is saying to follow the one of the device.
I put this icon aside with the manifest (generated with the prompt “generate a 192x192 image on the theme podcast” in chatgpt).
Service worker
Caching service worker for offline support. Pre-caches all CDN dependencies (Alpine, Tailwind, Yjs) and app resources. Uses network-first for dynamic URLs (the app HTML, podcasts list, manifest) and cache-first for immutable IPFS/CDN content.
// taken from https://googlechrome.github.io/samples/service-worker/basic/
const CACHE_NAME = 'podcastcache';
const DYNAMIC_URLS = [
"./podcast.html",
"./podcasts.json",
"./podcastmanifest.json",
"./swpodcast.js",
"./podcast.png",
"./bundle.js",
"./tailwind.js",
]
const PRECACHE_URLS = [];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll([
...PRECACHE_URLS,
...DYNAMIC_URLS
]))
.then(self.skipWaiting())
);
});
// clean up old caches
self.addEventListener('activate', event => {
const currentCaches = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return cacheNames.filter(cacheName => !currentCaches.includes(cacheName));
}).then(cachesToDelete => {
return Promise.all(cachesToDelete.map(cacheToDelete => {
return caches.delete(cacheToDelete);
}));
}).then(() => self.clients.claim())
);
});
// taken from https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Caching
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log(`Get ${request.url} from the cache (cacheFirst)`)
return cachedResponse;
}
try {
// for some reason, the intercepted requests uses crendentials: include,
// leading to an error, explicitely tell to omit credentials then. I
// also have to explicitely tell it that I want to use cors.
const networkResponse = await fetch(request, { mode: 'cors', credentials: 'omit' });
console.log(`cacheFirst: ${request.url}: ${request.credentials}, ${networkResponse.ok}, ${networkResponse.status}, ${networkResponse.statusText}`)
if (networkResponse.ok) {
console.log(`cacheFirst: Put ${request.url} into the cache`)
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
else
{
console.log(`cacheFirst: bad response with ${request.url}`)
}
return networkResponse;
} catch (error) {
console.log(`cacheFirst: error with ${request.url}`)
return Response.error();
}
}
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
console.log(`Put ${request.url} into the cache (networkfirst), ${request.credentials}`)
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
console.log(`Got ${request.url} from the cache (networkfirst)`)
return cachedResponse || Response.error();
}
}
self.addEventListener('fetch', event => {
const dynamicUrlsRegex = new RegExp(`^.*(${DYNAMIC_URLS.join('|')}).*`);
if (event.request.url.match(dynamicUrlsRegex)) {
// this is likely to change from time to time, use a network first approach
console.log(`Dealing with ${event.request.url} (networkfirst)`)
event.respondWith(networkFirst(event.request));
} else if (event.request.url.includes("/ipfs/") || event.request.url.includes("unpkg.com/")) {
// immutable CDN content, get it from the cache if possible
console.log(`Dealing with ${event.request.url} (cacheFirst)`)
event.respondWith(cacheFirst(event.request));
}
else
{
console.log(`Leaving ${event.request.url} to the browser`)
}
});
Deploy to mojito
Tangling drops the three files into /var/run/user/1000/podcast/.
Deploying to the phone is the explicit second step:
mojito:/sdcard/Documents/localhost/ is where the service worker scope
and start_url expect them.
scp -O /var/run/user/1000/podcast/podcast.html \
/var/run/user/1000/podcast/podcastmanifest.json \
/var/run/user/1000/podcast/swpodcast.js \
/var/run/user/1000/podcast/bundle.js \
/var/run/user/1000/podcast/tailwind.js \
mojito:/sdcard/Documents/localhost/