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).

(save-excursion
  (goto-char (org-babel-find-named-block "root"))
  (let ((body (org-element-property :value (org-element-at-point))))
    (substring (secure-hash 'sha256 (replace-regexp-in-string "\\[\\[build-hash()\\]\\]" "" body)) 0 7)))
1553a9a

<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()
                   "
        >
    [[listings-ui]]
    [[player-ui]]
  </body>
</html>

Head

Dependencies, styles, and imports needed by the application.

  • Alpine.js with persist and collapse plugins (loaded from IPFS for offline resilience)
  • TailwindCSS for utility-first styling (dark mode toggled via class)
  • Yjs for CRDT-based cross-device sync (loaded as ES module)

<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="./podcastmanifest.json">
<script defer src="https://ipfs.konubinix.eu/p/bafkreidregenuw7nhqewvimq7p3vwwlrqcxjcrs4tiocrujjzggg26gzcu?orig=https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://ipfs.konubinix.eu/p/bafkreighmyou4lhqizpdzvutdeg6xnpvskwhfxgez7tfawason3hkwfspm?orig=https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://ipfs.konubinix.eu/p/bafkreic33rowgvvugzgzajagkuwidfnit2dyqyn465iygfs67agsukk24i?orig=https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://ipfs.konubinix.eu/p/bafybeihp5kzlgqt56dmy5l4z7kpymfc4kn3fnehrrtr7cid7cn7ra36yha?orig=https://cdn.tailwindcss.com/3.4.3"></script>
<script>tailwind.config = { darkMode: 'class' }</script>
<style>
  html, body { margin: 0; overflow-x: hidden; background: #000; }
  video { display: block; }
</style>
<script type="module">
  import { Doc, WebsocketProvider } from 'https://ipfs.konubinix.eu/p/bafkreiap4qt2hkswcidxgqnzdt4irhslfc6kbpa2bw2lscz7zsd3gwr7mu?filename=yjs.js'
  window.YjsDoc = Doc
  window.YjsWebsocketProvider = WebsocketProvider
</script>
[[wip_ipfsdocs_slider_alpinejs.org:toast-code-ex()]]

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.

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):
    • Simple settings (show_old, auto_next, dark_mode) use timestamp-based LWW for proper merge
    • Current episode/podcast/dir are synced as one atomic object with timestamps
    • Podcast/dir bookmarks use dedicated YMaps (podcast-last-episode, dir-last-episode) with per-name keys for true CRDT merge
    • Episode playback times use a dedicated YMap (episode-times) with Math.max merge (further progress wins)
  • Magic link authorization for Yjs websocket connections

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){
  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}
  }

  }
  if(! keepcurrent) {
  this.current.episode = 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,
  _yjsInitialSyncDone: false,
  _yjsMap: null,
  _yjsTimeSyncTimeout: null,
  _localChangeTs: {},  // tracks when local values last changed (for merge)
  _syncEpisodeTime(url, time) {
      if (!this._yjsTimesMap) return
      clearTimeout(this._yjsTimeSyncTimeout)
      this._yjsTimeSyncTimeout = setTimeout(() => {
          this._yjsTimesMap.set(url, time)
      }, 5000)
  },
  key(stuff) {
  return `${stuff}_${this.current.episode.url}`
  },
  async 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 = null
  await this.$nextTick() // give time to the player to disappear
  this.current.episode = this.current.podcast.episodes[(pos_current_episode + 1) % this.current.podcast.episodes.length]
  await this.$nextTick() // give time to the player to appear
  },
  async 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 = null
  await this.$nextTick() // give time to the player to disapear
  this.current.episode = this.current.podcast.episodes[(pos_current_episode - 1 + this.current.podcast.episodes.length) % this.current.podcast.episodes.length]
  await this.$nextTick() // give time to the player to appear
  },
  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);
          }
      }
  },
  _setupYjsSync() {
      if (!window.YjsDoc || !window.YjsWebsocketProvider) {
          console.warn('Yjs not loaded, skipping sync')
          return
      }
      const doc = new window.YjsDoc()
      const ymap = doc.getMap("podcast-player")
      this._yjsMap = ymap
      const timesMap = doc.getMap("episode-times")
      this._yjsTimesMap = timesMap
      // Dedicated YMaps for per-entry CRDT merge
      const podcastLastMap = doc.getMap("podcast-last-episode")
      const dirLastMap = doc.getMap("dir-last-episode")

      // Simple keys synced individually with timestamps for LWW
      const SIMPLE_KEYS = {
          'show_old': 'show_old',
          'auto_next': 'auto_next',
          'dark_mode': 'dark_mode',
      }

      // current.episode + current.podcast + current.dir are synced
      // as one atomic object to avoid impossible combinations
      const CURRENT_YJS_KEY = 'current'

      const getVal = (path) => path.split('.').reduce((obj, key) => obj?.[key], this)
      const setVal = (path, val) => {
          const parts = path.split('.')
          const last = parts.pop()
          const parent = parts.reduce((obj, key) => obj[key], this)
          parent[last] = val
      }

      const getCurrentSnapshot = () => JSON.parse(JSON.stringify({
          episode: this.current.episode,
          podcast: this.current.podcast,
          dir: this.current.dir,
      }))

      // Track local change timestamps (ungated — runs even before initial sync)
      for (const [alpinePath] of Object.entries(SIMPLE_KEYS)) {
          this.$watch(alpinePath, () => {
              if (this._yjsSyncing) return
              this._localChangeTs[alpinePath] = Date.now()
          })
      }
      for (const subKey of ['current.episode', 'current.podcast', 'current.dir']) {
          this.$watch(subKey, () => {
              if (this._yjsSyncing) return
              this._localChangeTs['current'] = Date.now()
          })
      }

      // === Yjs -> Alpine observers ===

      // Simple keys + current (from ymap)
      ymap.observe((event) => {
          this._yjsSyncing = true
          event.changes.keys.forEach((change, key) => {
              if (key === CURRENT_YJS_KEY) {
                  const wrapper = ymap.get(key)
                  if (wrapper && wrapper.value) {
                      this.current.episode = wrapper.value.episode
                      this.current.podcast = wrapper.value.podcast
                      this.current.dir = wrapper.value.dir
                  }
                  if (!this.current.episode && this.current.podcast) {
                      let last = this.podcast_last_episode[this.current.podcast.name]
                      if (last) {
                          this.current.episode = last.episode
                      }
                  }
              } else {
                  const alpinePath = Object.entries(SIMPLE_KEYS)
                      .find(([, yjsKey]) => yjsKey === key)?.[0]
                  if (alpinePath) {
                      const wrapper = ymap.get(key)
                      setVal(alpinePath, wrapper && wrapper.value !== undefined ? wrapper.value : wrapper)
                  }
              }
          })
          setTimeout(() => { this._yjsSyncing = false }, 100)
      })

      // podcast_last_episode: per-podcast-name keys in dedicated YMap
      const rebuildPodcastLast = () => {
          const obj = {}
          podcastLastMap.forEach((val, key) => { obj[key] = val })
          this.podcast_last_episode = obj
      }
      podcastLastMap.observe(() => {
          this._yjsSyncing = true
          rebuildPodcastLast()
          setTimeout(() => { this._yjsSyncing = false }, 100)
      })

      // dir_last_episode: per-dir-name keys in dedicated YMap
      const rebuildDirLast = () => {
          const obj = {}
          dirLastMap.forEach((val, key) => { obj[key] = val })
          this.dir_last_episode = obj
      }
      dirLastMap.observe(() => {
          this._yjsSyncing = true
          rebuildDirLast()
          setTimeout(() => { this._yjsSyncing = false }, 100)
      })

      // Episode times
      timesMap.observe((event) => {
          event.changes.keys.forEach((change, url) => {
              const time = timesMap.get(url)
              if (time !== undefined) {
                  localStorage.setItem('time_' + url, time)
              }
          })
      })

      // === Alpine -> Yjs watchers (gated on initial sync done) ===

      // Simple keys: wrap with timestamp
      for (const [alpinePath, yjsKey] of Object.entries(SIMPLE_KEYS)) {
          this.$watch(alpinePath, (newVal) => {
              if (!this._yjsInitialSyncDone || this._yjsSyncing) return
              ymap.set(yjsKey, { value: JSON.parse(JSON.stringify(newVal)), ts: Date.now() })
          })
      }
      // Current triplet: wrap with timestamp
      for (const subKey of ['current.episode', 'current.podcast', 'current.dir']) {
          this.$watch(subKey, () => {
              if (!this._yjsInitialSyncDone || this._yjsSyncing) return
              ymap.set(CURRENT_YJS_KEY, { value: getCurrentSnapshot(), ts: Date.now() })
          })
      }
      // podcast_last_episode: diff and set per-key in dedicated YMap
      this.$watch('podcast_last_episode', (newVal) => {
          if (!this._yjsInitialSyncDone || this._yjsSyncing) return
          for (const [name, entry] of Object.entries(newVal)) {
              const serialized = JSON.parse(JSON.stringify(entry))
              const existing = podcastLastMap.get(name)
              if (JSON.stringify(serialized) !== JSON.stringify(existing)) {
                  podcastLastMap.set(name, serialized)
              }
          }
      })
      // dir_last_episode: diff and set per-key in dedicated YMap
      this.$watch('dir_last_episode', (newVal) => {
          if (!this._yjsInitialSyncDone || this._yjsSyncing) return
          for (const [name, entry] of Object.entries(newVal)) {
              const serialized = JSON.parse(JSON.stringify(entry))
              const existing = dirLastMap.get(name)
              if (JSON.stringify(serialized) !== JSON.stringify(existing)) {
                  dirLastMap.set(name, serialized)
              }
          }
      })

      const wsUrl = (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'
      })
      // On initial sync: merge local and remote using timestamps + per-key CRDT
      provider.on('sync', (isSynced) => {
          if (isSynced) {
              this._yjsSyncing = true

              // Simple keys: timestamp-based merge
              for (const [alpinePath, yjsKey] of Object.entries(SIMPLE_KEYS)) {
                  const remote = ymap.get(yjsKey)
                  const localTs = this._localChangeTs[alpinePath] || 0
                  const remoteTs = remote?.ts || 0
                  if (remote !== undefined && remoteTs >= localTs) {
                      // remote is newer or same age: apply remote
                      setVal(alpinePath, remote.value !== undefined ? remote.value : remote)
                  } else {
                      // local is newer or remote missing: push local
                      ymap.set(yjsKey, { value: JSON.parse(JSON.stringify(getVal(alpinePath))), ts: localTs || Date.now() })
                  }
              }

              // Current: timestamp-based merge
              const remoteCurrent = ymap.get(CURRENT_YJS_KEY)
              const localCurrentTs = this._localChangeTs['current'] || 0
              const remoteCurrentTs = remoteCurrent?.ts || 0
              if (remoteCurrent && remoteCurrentTs >= localCurrentTs && remoteCurrent.value) {
                  this.current.episode = remoteCurrent.value.episode
                  this.current.podcast = remoteCurrent.value.podcast
                  this.current.dir = remoteCurrent.value.dir
              } else {
                  ymap.set(CURRENT_YJS_KEY, { value: getCurrentSnapshot(), ts: localCurrentTs || Date.now() })
              }
              if (!this.current.episode && this.current.podcast) {
                  let last = this.podcast_last_episode[this.current.podcast.name]
                  if (last) {
                      this.current.episode = last.episode
                  }
              }

              // podcast_last_episode: per-key merge via dedicated YMap
              // Pull remote entries into Alpine
              podcastLastMap.forEach((val, name) => {
                  this.podcast_last_episode[name] = val
              })
              this.podcast_last_episode = {...this.podcast_last_episode}
              // Push local-only entries to YMap
              for (const [name, entry] of Object.entries(this.podcast_last_episode)) {
                  if (!podcastLastMap.has(name)) {
                      podcastLastMap.set(name, JSON.parse(JSON.stringify(entry)))
                  }
              }

              // dir_last_episode: per-key merge via dedicated YMap
              dirLastMap.forEach((val, name) => {
                  this.dir_last_episode[name] = val
              })
              this.dir_last_episode = {...this.dir_last_episode}
              for (const [name, entry] of Object.entries(this.dir_last_episode)) {
                  if (!dirLastMap.has(name)) {
                      dirLastMap.set(name, JSON.parse(JSON.stringify(entry)))
                  }
              }

              // Episode times: merge with Math.max (further progress wins)
              // Push local times missing from remote
              for (let i = 0; i < localStorage.length; i++) {
                  const k = localStorage.key(i)
                  if (k.startsWith('time_')) {
                      const url = k.slice(5)
                      const localTime = parseFloat(localStorage.getItem(k))
                      if (!timesMap.has(url)) {
                          timesMap.set(url, localTime)
                      }
                  }
              }
              // Merge remote times: take max of local and remote
              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)
                  }
              })

              this._yjsSyncing = false
              this._yjsInitialSyncDone = true
          }
      })
  },
  }))
  })

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="
                                 current.podcast = podcasts[0]
                                 current.dir = dir
                                 current.episode = null // to trigger reload
                                 await $nextTick()
                                 current.episode = podcasts[0].episodes[0]
                                 localStorage.setItem(`time_${current.episode.url}`, 0)
                                 await $nextTick() // wait for it to be ready
                                 $dispatch('pleaseplay')
                                 $dispatch('pleaserewind')"></button>
            <button class="text-sm" x-show="dir in dir_last_episode"
                    @click.stop="
                                 let podcast = dir_last_episode[dir].podcast
                                 let episode = dir_last_episode[dir].episode
                                 current.podcast = podcast
                                 current.dir = dir
                                 current.episode = null // to trigger reload
                                 await $nextTick()
                                 current.episode = episode
                                 await $nextTick() // wait for it to be ready
                                 $dispatch('pleaseplay')
                                 $dispatch('pleaserewind')"
                    >resume</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="
                                       current.podcast = podcast
                                       current.dir = dir
                                       current.episode = null // to trigger reload
                                       await $nextTick()
                                       current.episode = podcast.episodes[0]
                                       localStorage.setItem(`time_${current.episode.url}`, 0)
                                       await $nextTick() // wait for it to be ready
                                       $dispatch('pleaseplay')
                                       $dispatch('pleaserewind')"></button>
                  <button class="text-sm" x-show="podcast.name in podcast_last_episode"
                          @click.stop="
                                       let dir = podcast_last_episode[podcast.name].dir
                                       let episode = podcast_last_episode[podcast.name].episode
                                       current.dir = dir
                                       current.podcast = podcast
                                       current.episode = null // to trigger reload
                                       await $nextTick()
                                       current.episode = episode
                                       await $nextTick() // wait for it to be ready
                                       $dispatch('pleaseplay')
                                       $dispatch('pleaserewind')">resume</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="current.episode = null ; await $nextTick() ; current.episode = episode ; current.podcast = podcast ; current.dir = dir ; current.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>

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)

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})
      }
    },
    medium: {
      async ['x-init']() {
            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'
            }
            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",
]

// A list of local resources we always want to be cached.
const PRECACHE_URLS = [
    "https://ipfs.konubinix.eu/p/bafkreidregenuw7nhqewvimq7p3vwwlrqcxjcrs4tiocrujjzggg26gzcu?orig=https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js",
    "https://ipfs.konubinix.eu/p/bafkreighmyou4lhqizpdzvutdeg6xnpvskwhfxgez7tfawason3hkwfspm?orig=https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js",
    "https://ipfs.konubinix.eu/p/bafkreic33rowgvvugzgzajagkuwidfnit2dyqyn465iygfs67agsukk24i?orig=https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js",
    "https://ipfs.konubinix.eu/p/bafybeihp5kzlgqt56dmy5l4z7kpymfc4kn3fnehrrtr7cid7cn7ra36yha?orig=https://cdn.tailwindcss.com/3.4.3",
    "https://ipfs.konubinix.eu/p/bafkreiap4qt2hkswcidxgqnzdt4irhslfc6kbpa2bw2lscz7zsd3gwr7mu?filename=yjs.js",

];
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`)
    }
});

Notes linking here