Some Simple Podcast Player
Fleetingsome simple podcast tool, very specialized to my stack.
For audiobooks, prefer using Voice Audiobook Player, much more attractive (I think).
(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
localStorageand$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 (viadevicemotion). - Playback controls: previous/rewind/play-pause/ffwd/next buttons and speed adjustment.
- Progress bar: current time display and seek slider (double-click to enable).
- Toolbar: toggles for auto-next, show-old, sync status, locate current episode, dark mode, and build hash.
<div id="listings" class="h-screen flex flex-col bg-white text-black dark:bg-gray-900 dark:text-gray-100">
<div id="podcasts" class="flex-grow overflow-scroll"
x-effect.save-old-podcats="
if(current.podcast && current.episode && current.dir){
let old = {...podcast_last_episode}
old[current.podcast.name] = {episode: current.episode, dir: current.dir}
podcast_last_episode = old
old = {...dir_last_episode}
old[current.dir] = {episode: current.episode, podcast: current.podcast}
dir_last_episode = old
}
"
>
<template x-for="(podcasts, dir) in dirs">
<div class="dir"
x-data="{extended: false}">
<h1
:class="{
'bg-yellow-100 dark:bg-yellow-800': current.dir === dir,
'text-red-300 dark:text-red-500': podcasts.every(podcast => localStorage.getItem(podcast.name + '_looped_first_episode') === podcast.episodes[0].url),
}"
class="rounded" @click="extended = !extended">
<span class="w-8 inline-block text-right" x-text="extended ? '↓' : '→'"></span>
<span class="text-2xl" x-text="dir"></span>
<button class="text-sm"
@click.stop="
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
localStorageon init - Media Session API integration (lock screen controls, metadata)
- Continuous position saving to
localStorageand Yjs ontimeupdate - Auto-advance to next episode on end (if
auto_nextenabled)
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`)
}
});