Posted on Leave a comment

Create An Online Radio & Podcast Streamer Using Vue and Media Session API

The Media Session API

I love listening to podcasts and online radio. I used to run an online station a fews ago called 90K Radio and I was hooked in the community ever since. Keeping on track with my PWA binge I thought it would be a cool to write a progressive web app that can take in any stream URL and play it. Not only that but I want to be able to control the audio using the native audio commands on Android, iOS and desktop. There is this awesome javascript API called the Media Session API. The Media Session API allows you to customize media notifications and how to handle them. It allows you to control your media without having to be on that specific webpage. This allows for things such as background playing ( a must need feature for online radio PWA). You can even do things like set album artwork and other metadata and have custom handlers for events such as skipping tracks and pausing/playing.

What Are The Benefits?

Primarily I built this because a PWA will load faster. Also no tracking, I don’t have to worry about Google or anyone else tracking my activity, I can just listen in peace. By using the media session API I can listen in the background whilst doing other things which most likely will be every time I use the app. Lastly it’s just an awesome feeling to use your own software 😅.

The Vue Application

I created the vue application using the standard Vue CLI and added Vuetify so that it has some basic responsive styling. The app has one component called Radio.vue which holds all of the logic. The application has some preset radio stations that I can click as well as a text field where I can put in any URL of their choosing for play. It also grabs an RSS feed for a few of my favorite podcasts so I can quickly listen. Everything is done client side including the RSS XML parsing! You can view the live version here and clone the repo here.

Let’s Get Coding

As I stated above, I created a new Vue application using the vue-cli and added vuetify using the vue add vuetify command. For brevity I will skip that part and only talk about the Radio.vue component which holds all of the logic. This component will grab the preset stations and turn those into buttons. The favorited podcast RSS feeds it will grab, parse the XML and play said podcast. There is a URL text input that the user can manually put an audio stream URL in. Finally I set the Media Session metadata to show the cover art and info of whatever is playing and if I don’t have it show a default image, artist and album.

<template>
  <v-container>
    <v-row class="text-center">
      <v-col cols="12">
        <v-img
          :src="require('../assets/logo.png')"
          class="my-3"
          contain
          height="200"
        />
      </v-col>

      <v-col class="mb-4">
        <h1 class="display-2 font-weight-bold mb-3">
          Welcome to PWA Radio
        </h1>

        <p class="subheading font-weight-regular text-center">
          <v-text-field type="url" placeholder="Enter stream URL" v-model="url" label="Stream URL" />
        </p>
        <v-row class="text-center">
          <v-btn v-on:click="playAudio" v-if="!isPlaying">Play</v-btn>
          <v-btn v-on:click="stopAudio" v-else color="red">Stop</v-btn>
        </v-row>
      </v-col>
    </v-row>
    <v-row class="text-center">
      <v-btn class="pa-md-4 mx-lg-auto" v-for="x in presets" v-on:click="setAudio(x)" :key="x.name" :color="x.color"> {{x.name}} </v-btn>
    </v-row>
    <v-row class="text-center">
      <v-select
          v-model="currentPodcast"
          :hint="`${currentPodcast.name}, ${currentPodcast.author}`"
          :items="favoritePodcasts"
          item-text="name"
          item-value="url"
          label="Favorite Podcasts"
          persistent-hint
          return-object
          single-line
          @change="playPodcast"
        ></v-select>
    </v-row>
  </v-container>
</template>

<script>
  export default {
    name: 'Radio',

    data: () => ({
      isPlaying: false,
      audio: {},
      url : '',
      currentPodcast: {},
      selectedEpisode: {},
      presets : [
        {
          name: 'WEKU-NPR',
          url : 'https://playerservices.streamtheworld.com/api/livestream-redirect/WEKUFM.mp3',
          color: "green",
          author: 'NPR'
        },
        {
          name: 'WEKU-Classical',
          url: 'https://playerservices.streamtheworld.com/api/livestream-redirect/WEKUHD2.mp3',
          color: 'orange',
          author: 'NPR'
        },
        {
          name: 'Vocalo Radio',
          url: 'https://stream.wbez.org/vocalo128',
          color: 'blue',
          author: 'NPR'
        },
        {
          name: 'WFPK',
          url: 'https://lpm.streamguys1.com/wfpk-popup',
          color: 'yellow',
          author: 'NPR'
        },
        {
          name: 'KEXP',
          url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3?listenerid=8044407b7410ad01f8210fd508279708&awparams=companionAds%3Atrue',
          color: '#cb349a',
          author: 'NPR'
        }
      ],
      favoritePodcasts: [],

      podcastURLS: [
        { url: 'https://anchor.fm/s/fdc3ac0/podcast/rss', name: 'Code Life' },
        { url: 'https://anchor.fm/s/42d5fca4/podcast/rss' , name: 'Intimate Spaces' },
        { url: 'https://feeds.npr.org/510289/podcast.xml', name: 'Project Money'}

      ]
    }),
    methods: {
      setMediaControls: function () {
        if ('mediaSession' in navigator) {
          navigator.mediaSession.metadata = new window.MediaMetadata({
            title: 'Pocket Radio',
            artist: 'J Computer Solutions LLC',
            album: 'Pocket Radio',
            artwork: [
              { src: 'https://radio.jcompsolu.com/images/logo-96.png',   sizes: '96x96',   type: 'image/png' },
              { src: 'https://radio.jcompsolu.com/images/logo-128.png', sizes: '128x128', type: 'image/png' },
              { src: 'https://radio.jcompsolu.com/images/logo-192.png', sizes: '192x192', type: 'image/png' },
              { src: 'https://radio.jcompsolu.com/images/logo-256.png', sizes: '256x256', type: 'image/png' },
              { src: 'https://radio.jcompsolu.com/images/logo-384.png', sizes: '384x384', type: 'image/png' },
              { src: 'https://radio.jcompsolu.com/images/logo-512.png', sizes: '512x512', type: 'image/png' },
            ]
          });

          navigator.mediaSession.setActionHandler('play', this.playAudio());
          navigator.mediaSession.setActionHandler('pause', this.pauseAudio());
          navigator.mediaSession.setActionHandler('stop', this.stopAudio());
          navigator.mediaSession.setActionHandler('seekbackward', function() { /* Code excerpted. */ });
          navigator.mediaSession.setActionHandler('seekforward', function() { /* Code excerpted. */ });
          navigator.mediaSession.setActionHandler('seekto', function() { /* Code excerpted. */ });
          navigator.mediaSession.setActionHandler('previoustrack', function() { /* Code excerpted. */ });
          navigator.mediaSession.setActionHandler('nexttrack', function() { /* Code excerpted. */ });
        }
      },
      playPodcast: function () {
        this.setAudio(this.currentPodcast)
        this.playAudio()
      },
      playAudio: function () {
        if(this.isPlaying){
          this.isPlaying = false
          this.audio.pause()
          this.audio = {}
        }
        this.audio = new Audio(this.url)
        this.isPlaying = true
        this.audio.play()
          .then(()=> {
        }).catch(error => { console.log(error) });
      },
      pauseAudio: function () {
        this.audio.pause()
        this.isPlaying = false
      },
      stopAudio: function () {
        this.audio.pause()
        this.audio = {}
        this.isPlaying = false
      },
      setAudio: function(preset) {
        this.url = preset.url
        navigator.mediaSession.metadata.title = preset.name
        navigator.mediaSession.metadata.artist = preset.author
        if(preset.image) {
          navigator.mediaSession.metadata.artwork = [
            { src: preset.image }
          ]
        }
        this.playAudio()
      }
    },
    mounted () {
      this.setMediaControls()
    },
    created () {
      this.podcastURLS.forEach(pod => {
        fetch(pod.url)
        .then(response => response.text())
        .then(str => new window.DOMParser().parseFromString(str, "text/xml"))
        .then(data => {
          const items = data.querySelectorAll("item");
          for (let i = 0; i < items.length; i++) {
            let item = items[i];
            console.log(item)
            let image = item.getElementsByTagName("itunes:image")[0].getAttribute("href")
            let title = item.querySelector("title").innerHTML.replace("<![CDATA[", "").replace("]]>", "")
            let author = item.getElementsByTagName("dc:creator")[0].innerHTML.replace("<![CDATA[", "").replace("]]>", "")
            let url = item.querySelector("enclosure").getAttribute("url")
            let podcast = { name: title, url: url, image: image, author: author }
            this.favoritePodcasts.push(podcast)
          }
        })
      })
    }
  }
</script>

Conclusion

This was a fun and easy PWA to make and I will turn it into an Android application to put on the Google Play Store (learn how with my PWA to APK course). Some features I will add will include:

  • save favorites locally using indexDB
  • create queue that can be skipped
  • download podcast episodes
  • Have everything play via the WebAudio API and add visualizations.