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()
                   "
        >
    [[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 && 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,
  _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}`
  },
  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);
          }
      }
  },
  _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="
                                 if (dir in dir_last_episode) {
                                     current.podcast = dir_last_episode[dir].podcast
                                     current.dir = dir
                                     current.episode = null // to trigger reload
                                     await $nextTick()
                                     current.episode = dir_last_episode[dir].episode
                                 } else {
                                     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> <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="
                                       if (podcast.name in podcast_last_episode) {
                                           current.dir = podcast_last_episode[podcast.name].dir
                                           current.podcast = podcast
                                           current.episode = null // to trigger reload
                                           await $nextTick()
                                           current.episode = podcast_last_episode[podcast.name].episode
                                       } else {
                                           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>
                </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)
  • 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
              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",
]

// 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