Konubinix' opinionated web of thoughts

Some Simple Podcast Player

Fleeting

some 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)
            })
        );
    }
});