Konubinix' opinionated web of thoughts

Some Simple Podcast Player

Fleeting

some 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 localStorage and $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 /ywebsocket on the serving host; a ?sync_url= query parameter overrides it (persisted to localStorage, 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 (via devicemotion).
  • 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 localStorage on init
  • Media Session API integration (lock screen controls, metadata)
  • Continuous position saving to localStorage and Yjs on timeupdate
  • Auto-advance to next episode on end (if auto_next enabled)
  • Seamless track transitions: goto_next=/=goto_prev assign the new episode directly (without nulling current.episode) so the media element is never destroyed. A $watch in medium detects the URL change and calls .load() to switch source. This avoids an await $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/

Notes linking here