Some Simple Podcast Player
Fleetingsome simple podcast tool
<html>
<head>
<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://konubinix.eu/ipfs/bafkreidregenuw7nhqewvimq7p3vwwlrqcxjcrs4tiocrujjzggg26gzcu?orig=https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://konubinix.eu/ipfs/bafkreic33rowgvvugzgzajagkuwidfnit2dyqyn465iygfs67agsukk24i?orig=https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://konubinix.eu/ipfs/bafybeihp5kzlgqt56dmy5l4z7kpymfc4kn3fnehrrtr7cid7cn7ra36yha?orig=https://cdn.tailwindcss.com/3.4.3"></script>
[[wip_ipfsdocs_slider.org:toast-code-ex()]]
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./swpodcast.js')
.then(function() {
console.log('Service Worker Registered');
});
}
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{
n = Date.now()
res = await fetch(`http://localhost:9904/${this.src}?n=${n}`)
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
}
}
catch(err)
{
error(err)
}
},
dirs: undefined,
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"),
},
key(stuff) {
return `${stuff}_${this.current.episode.url}`
},
async goto_next() {
localStorage.setItem(this.key('time'), 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)
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
},
}))
})
</script>
</head>
<body x-data="app" x-init="init_app"
@gotonext="
if($event.detail.playing){
current.playing = true
}
await goto_next()
"
@gotoprev="
if($event.detail.playing){
current.playing = true
}
await goto_prev()
"
>
<div id="listings" class="h-screen flex flex-col">
<div id="podcasts" class="flex-grow border-red-100 border-4 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': current.dir === dir
}"
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 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': current.podcast && current.podcast.name === podcast.name,
'text-red-100': 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 border-2 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': current.episode && current.episode.url === episode.url,
'text-red-100': localStorage.getItem(podcast.name + '_looped_first_episode') === podcast.episodes[0].url,
'font-bold': 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>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<div id="countdown"
x-show='current_progress.duration'
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()
}
"
>
<input type="number" class="w-8" x-model="time"/>
<input type="number" disabled class="w-8" x-model="remaining_time"/>
<input type="range" min="0" disabled :max="time * 60" :value="time * 60 - remaining_time" class="flex-grow"/>
<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>
<input type="checkbox" x-model="show_old"/>
<span class="text-sm">
<span class="w-8 text-right inline-block" x-text="current_speed"></span>:
<input class="align-middle"
x-data="{
disabled_timer: null,
async set_timer () {
if(this.disabled_timer !== null){
clearTimeout(this.disabled_timer)
}
this.disabled_timer = setTimeout(() => {this.$el.disabled = true; this.disabled_timer = null}, 5000)
},
}"
type="range"
x-effect.follow-current-episode="if(current.episode){current_speed = localStorage.getItem(key('rate')) || 1}"
:value="current_speed"
disabled
@input="$dispatch('pleasesetspeed', {speed: $el.value}) ; await set_timer()"
@dblclick="$el.disabled = false ; await set_timer()"
min="0.25" max="4" step="0.25"/>
</span>
</span>
<span id="progress" class="text-sm w-full flex flex-row">
<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
x-data="{
disabled_timer: null,
async set_timer () {
if(this.disabled_timer !== null){
clearTimeout(this.disabled_timer)
}
this.disabled_timer = setTimeout(() => {this.$el.disabled = true; this.disabled_timer = null}, 5000)
},
}"
:value="current_progress.cur"
@input="$dispatch('setcurrenttime', {value: $el.value}) ; await set_timer()"
@dblclick="$el.disabled = false ; await set_timer()"
/>
</span>
</div>
</template>
</div>
<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.$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
},
['@pleasescroll.window']() {
this.$el.scrollIntoView()
},
['@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}
},
['@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'))">
<video class="w-screen max-h-screen 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>
</template>
</div>
</body>
</html>
{
"name": "podcast",
"short_name": "podcast",
"description": "podcast",
"start_url": "./podcast.html",
"display": "minimal-ui",
"background_color": "#ffffff",
"theme_color": "#4285f4",
"orientation": "natural",
"scope": "./",
"permissions": [],
"splash_pages": null,
"categories": []
}
// taken from https://googlechrome.github.io/samples/service-worker/basic/
const PRECACHE = 'precache-v1';
const RUNTIME = 'runtime';
// A list of local resources we always want to be cached.
const PRECACHE_URLS = [
"https://konubinix.eu/ipfs/bafkreidregenuw7nhqewvimq7p3vwwlrqcxjcrs4tiocrujjzggg26gzcu?orig=https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js",
"https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js",
"https://konubinix.eu/ipfs/bafkreic33rowgvvugzgzajagkuwidfnit2dyqyn465iygfs67agsukk24i?orig=https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js",
"https://konubinix.eu/ipfs/bafybeihp5kzlgqt56dmy5l4z7kpymfc4kn3fnehrrtr7cid7cn7ra36yha?orig=https://cdn.tailwindcss.com/3.4.3"
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(PRECACHE)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(self.skipWaiting())
);
});
// clean up old caches
self.addEventListener('activate', event => {
const currentCaches = [PRECACHE, RUNTIME];
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())
);
});
self.addEventListener('fetch', event => {
if (event.request.url.endsWith("podcast.html")) {
event.respondWith(
fetch(event.request).then(response => {
// Put a copy of the response in the runtime cache.
return caches.open(RUNTIME).then(cache => {
return cache.put(event.request, response.clone()).then(() => {
return response;
});
});
}).catch(function() {
return caches.match(event.request)
})
);
}
});