Your own webplayer with      &  

by Jonas Thelemann

On today's menu:

  1. Why Jonas, that already exists 🙄
  2. Nvm, get cheap S3 storage 💾
  3. Bootstrap a Nuxt app 🚀
  4. Get some 🔥 tunes

(homework)

Why Jonas,

that already exists

Wouldn't

be nice?

Get cheap S3 storage 💾

Amazon S3 or Amazon Simple Storage Service is a service offered by Amazon Web Services (AWS) that provides object storage through a web service interface. [...] AWS launched Amazon S3 in the United States on March 14, 2006, then in Europe in November 2007.

Get cheap S3 storage 💾

  • add files by drag and drop
  • install & configure the AWS CLI
$ cat ~/.aws/config
[plugins]
endpoint = awscli_plugin_endpoint

[default]
region = nl-ams
s3 =
  endpoint_url = https://s3.nl-ams.scw.cloud
  signature_version = s3v4
  max_concurrent_requests = 100
  max_queue_size = 1000
  multipart_threshold = 50MB
  # Edit the multipart_chunksize value according to the file sizes that you want to upload. The present configuration allows to upload files up to 10 GB (100 requests * 10MB). For example setting it to 5GB allows you to upload files up to 5TB.
  multipart_chunksize = 10MB
s3api =
  endpoint_url = https://s3.nl-ams.scw.cloud

$ cat ~/.aws/credentials
[default]
aws_access_key_id=SCWX7Q00000000000000
aws_secret_access_key=48c6e6a6-3710-4e00-0000-000000000000
  • Setup CORS headers
$ cat cors.json
{
  "CORSRules": [
    {
      "AllowedOrigins": ["http://localhost:3000"],
      "AllowedHeaders": ["*"],
      "AllowedMethods": ["GET", "HEAD", "POST", "PUT", "DELETE"],
      "MaxAgeSeconds": 3000,
      "ExposeHeaders": ["Etag"]
    }
  ]
}

$ aws s3api put-bucket-cors --bucket your-own-webplayer --cors-configuration file://cors.json

Add credentials

Use Docker Swarm or

Bootstrap Nuxt.js 🚀

Bootstrap Nuxt.js 🚀

npx create-nuxt-app your-own-webplayer
  • un-compiled assets like SASS or JS
  • Vue.js components
  • application layouts
  • custom functions that affect page rendering
  • 🕳
  • application Views and Routes
  • Javascript plugins running before root mount
  • static files like robots.txt
  • Vuex Store files
  • Nuxt's config file

Welcome!

Add API / server middleware

$ yarn add aws-sdk lodash.mergewith
$ yarn add @types/lodash.mergewith --dev
$ cat nuxt.config.js
...
	serverMiddleware: ['~/api/playlists.ts', '~/api/signedUrl.ts'],
}
  • /api/playlists.ts
  • /api/signedUrl.ts
import fs from 'fs'
import { ServerResponse, IncomingMessage } from 'http'
import { URL } from 'url'

import S3 from 'aws-sdk/clients/s3'
import AWS from 'aws-sdk'
import merge from 'lodash.mergewith'

export default {
  path: '/api/playlists',
  handler(req: IncomingMessage, res: ServerResponse) {
    const s3 = new S3({
      apiVersion: '2006-03-01',
      credentials: new AWS.SharedIniFileCredentials({
        filename: '/run/secrets/yow_aws-credentials',
      }),
      endpoint: 'https://s3.nl-ams.scw.cloud',
      region: 'nl-ams',
    })

    const bucket = fs.readFileSync('/run/secrets/yow_aws-bucket', 'utf8').trim()
    const urlSearchParams = new URL(
      req.url !== undefined ? req.url : '',
      'https://example.org/'
    ).searchParams
    const continuationToken = urlSearchParams.get('continuation-token')
    const prefix = urlSearchParams.get('prefix')

    s3.listObjectsV2(
      {
        ...{
          Bucket: bucket,
          // MaxKeys: 10,
        },
        ...(continuationToken !== null && {
          ContinuationToken: continuationToken,
        }),
        ...(prefix !== null && {
          Prefix: prefix,
        }),
      },
      function (err, data) {
        if (err) {
          res.writeHead(500)
          res.end(err.message)
          return
        }

        if (data.Contents === undefined) {
          res.writeHead(204)
          res.end('No content')
          return
        }

        const playlists = {}

        data.Contents.forEach((content) => {
          if (content.Key === undefined) {
            res.writeHead(500)
            res.end('Content key undefined')
            return
          }

          const keyParts = content.Key.split('/')

          if (keyParts[keyParts.length - 1] === '') {
            // no file
            return
          }

          const nestedObject = getNestedObject(
            keyParts,
            content.Size !== undefined ? content.Size : 0
          )

          merge(playlists, nestedObject, (objValue: any, srcValue: any) => {
            if (Array.isArray(objValue)) {
              return objValue.concat(srcValue)
            }
          })
        })

        res.setHeader('Content-Type', 'application/json')
        res.end(
          JSON.stringify({
            playlists,
            ...(data.NextContinuationToken !== undefined && {
              nextContinuationToken: data.NextContinuationToken,
            }),
          })
        )
      }
    )
  },
}

function getNestedObject(properties: Array<string>, size: Number) {
  const nestedObject: any = {}

  if (properties.length < 2) {
    return undefined
  } else if (properties.length === 2) {
    if (properties[1].match(/^.+\.mp3$/)) {
      nestedObject[properties[0]] = { items: [{ name: properties[1], size }] }
    } else if (properties[1].match(/^.+\.(jpg|png)$/)) {
      nestedObject[properties[0]] = { cover: true }
    }
  } else {
    // properties.length is >2
    const key = properties[0]
    properties.shift()
    nestedObject[key] = { collections: getNestedObject(properties, size) }
  }

  return nestedObject
}

/api/playlists.ts

import fs from 'fs'
import { ServerResponse, IncomingMessage } from 'http'
import { URL } from 'url'

import S3 from 'aws-sdk/clients/s3'
import AWS from 'aws-sdk'

export default {
  path: '/api/signedUrl',
  handler(req: IncomingMessage, res: ServerResponse) {
    const s3 = new S3({
      apiVersion: '2006-03-01',
      credentials: new AWS.SharedIniFileCredentials({
        filename: '/run/secrets/yow_aws-credentials',
      }),
      endpoint: 'https://s3.nl-ams.scw.cloud',
      region: 'nl-ams',
    })

    const bucket = fs.readFileSync('/run/secrets/yow_aws-bucket', 'utf8').trim()
    const key = new URL(
      req.url !== undefined ? req.url : '',
      'https://example.org/'
    ).searchParams.get('key')

    s3.getSignedUrlPromise('getObject', {
      Bucket: bucket,
      Expires: 21600, // 6h
      Key: key,
    }).then(
      function (url) {
        res.end(url)
      },
      function (err) {
        res.writeHead(500)
        res.end(err.message)
      }
    )
  },
}

/api/signedUrl.ts

Add components

Add components

$ yarn add @fortawesome/free-solid-svg-icons nuxt-fontawesome nuxt-property-decorator
$ cat nuxt.config.json
...
modules: [
  // Doc: https://axios.nuxtjs.org/usage
  '@nuxtjs/axios',
  'nuxt-fontawesome',
],
...
axios: {
  baseURL: 'http://localhost:3000/api/',
},
...
fontawesome: {
  imports: [
    {
      set: '@fortawesome/free-solid-svg-icons',
      icons: ['faDownload', 'faPlay'],
    },
  ],
},
...
$ cat vue-shim.d.ts
declare module '*.vue' {
  import axios from '@nuxtjs/axios'
}

/assets/playlist-cover_default.jpg

/components/Playlist.vue

<template>
  <section>
    <img :src="coverUrl" />
    <h1 class="leading-tight m-0 mt-1 text-2xl text-left">
      {{ playlist.name }}
    </h1>
  </section>
</template>

<script lang="ts">
import merge from 'lodash.mergewith'
import { Component, Prop, Vue } from 'nuxt-property-decorator'

interface Playlist {
  name: string
  items: any[]
}

@Component({})
export default class extends Vue {
  @Prop({ type: Object }) readonly playlist!: Playlist

  coverUrl = require('~/assets/playlist-cover_default.jpg')

  created() {
    if ((this.playlist.items as any).cover) {
      this.getCoverUrl(this.playlist.name)
    }
  }

  async getCoverUrl(name: string) {
    const key = `${
      this.$route.query.playlist !== undefined
        ? `${this.$route.query.playlist}/`
        : ''
    }${name}/playlist-cover.jpg`
    this.coverUrl = await this.$axios.$get('/signedUrl', {
      params: new URLSearchParams(
        merge(
          {},
          {
            ...(key !== undefined && {
              key,
            }),
          }
        )
      ),
    })
  }
}
</script>

/components/Playlist.vue

<template>
  <div class="flex select-none">
    <button class="ml-2 mr-3 lg:mr-10" title="download" @click="download()">
      <font-awesome-icon :icon="['fas', 'download']" />
    </button>
    <button
      class="cursor-default flex-grow py-2 lg:py-3 text-left"
      @click="itemClick"
    >
      {{ playlistItem.name.replace(/\.mp3$/, '') }}
    </button>
    <button class="ml-3 lg:ml-10 mr-2" title="play" @click="play()">
      <font-awesome-icon :icon="['fas', 'play']" />
    </button>
  </div>
</template>

<script lang="ts">
import merge from 'lodash.mergewith'
import { Component, Prop, Vue } from 'nuxt-property-decorator'

interface PlaylistItem {
  name: string
  size: number
}

@Component({})
export default class extends Vue {
  @Prop({ type: Object, required: true }) readonly playlistItem!: PlaylistItem
  @Prop({ type: Function, required: true })
  readonly setSourceFunction!: Function

  async getSignedUrl() {
    const key = this.$route.query.playlist + '/' + this.playlistItem.name
    return await this.$axios.$get('/signedUrl', {
      params: new URLSearchParams(
        merge(
          {},
          {
            ...(key !== undefined && {
              key,
            }),
          }
        )
      ),
    })
  }

  async download() {
    const link = document.createElement('a')
    link.setAttribute('href', await this.getSignedUrl())
    link.setAttribute('download', '123.mp3')
    link.click()
  }

  async play() {
    this.setSourceFunction(await this.getSignedUrl())
  }

  itemClick(event: any) {
    if (event.detail === 2) {
      // double click
      this.play()
    }
  }
}
</script>

/components/PlaylistItem.vue

Add Plyr

$ yarn add vue-plyr
$ yarn add @babel/runtime-corejs3 core-js --dev
$ cat nuxt.config.js
...
css: ['plyr/dist/plyr.css'],
...
plugins: ['~/plugins/vue-plyr'],
...
build: {
  /*
   ** You can extend webpack config here
   */
  extend(_config, _ctx) {},
  /*
   ** https://github.com/nuxt-community/nuxt-property-decorator
   */
  babel: {
    presets({ _isServer }) {
      return [
        ['@nuxt/babel-preset-app', { loose: true, corejs: { version: 3 } }],
      ]
    },
  },
},
$ cat plugins/vue-plyr.js
import Vue from 'vue'
import VuePlyr from 'vue-plyr/dist/vue-plyr.ssr.js'

// The second argument is optional and sets the default config values for every player.
Vue.use(VuePlyr, {
  plyr: {
    controls: ['progress'],
  },
})

Fill Index Page

$ yarn add lodash.get
$ yarn add @types/lodash.get --dev

// and some styling in tailwind.config.js
<template>
  <div class="container mx-auto mb-4">
    <section>
      <h1>Player</h1>
      <div class="flex flex-col mb-2">
        <div class="bg-gray-900 flex-grow p-2">
          <div class="m-auto w-5/6">
            <h2 v-if="this.$route.query.playlist" class="ml-2">
              {{ this.$route.query.playlist }}
            </h2>
            <ul
              v-if="playlists !== undefined && playlists.length > 0"
              class="flex flex-wrap justify-center list-none"
            >
              <li
                v-for="playlist in playlists"
                :key="playlist.name"
                class="max-w-xs m-2 mb-4"
              >
                <a :alt="playlist.name" :href="getPlaylistLink(playlist.name)">
                  <Playlist :playlist="playlist" />
                </a>
              </li>
            </ul>
            <div v-if="playlistItems !== undefined && playlistItems.length > 0">
              <ul class="list-none">
                <li
                  v-for="playlistItem in playlistItems"
                  :key="playlistItem.name"
                  class="border-b border-gray-800 first:border-t hover:bg-gray-800"
                >
                  <PlaylistItem
                    :playlist-item="playlistItem"
                    :set-source-function="setSource"
                  />
                </li>
              </ul>
            </div>
          </div>
          <div
            v-if="
              !(playlists !== undefined && playlists.length > 0) &&
              !(playlistItems !== undefined && playlistItems.length > 0)
            "
            class="text-center"
          >
            No items found.
          </div>
        </div>
      </div>
      <vue-plyr
        v-if="playlistItems !== undefined"
        ref="plyr"
        class="fixed bottom-0 left-0 right-0"
      >
        <audio />
      </vue-plyr>
    </section>
    <!-- player below is for spacing (invisible) -->
    <vue-plyr v-if="playlistItems !== undefined" class="invisible">
      <audio />
    </vue-plyr>
  </div>
</template>

<script lang="ts">
import get from 'lodash.get'
import merge from 'lodash.mergewith'
import { Component, Vue } from 'nuxt-property-decorator'

import Playlist from '~/components/Playlist.vue'
import PlaylistItem from '~/components/PlaylistItem.vue'

interface AsyncData {
  playlists?: object
  playlistItems?: object
}

interface AxiosPlaylistData {
  playlists: object
  nextContinuationToken: string
}

@Component({
  components: {
    Playlist,
    PlaylistItem,
  },
})
export default class extends Vue {
  playlists?: Array<object>

  get player() {
    return (this.$refs.plyr as any).player
  }

  async asyncData({
    $axios,
    query,
  }: {
    $axios: any
    query: any
  }): Promise<AsyncData> {
    let continuationToken
    const playlistsObject: object = {}

    try {
      do {
        const playlistData: AxiosPlaylistData = await $axios.$get(
          '/playlists',
          {
            params: new URLSearchParams(
              merge(
                {},
                {
                  ...(continuationToken !== undefined && {
                    'continuation-token': continuationToken,
                  }),
                  ...(query.playlist !== undefined && {
                    prefix: query.playlist,
                  }),
                }
              )
            ),
          }
        )

        merge(
          playlistsObject,
          playlistData.playlists /* flattenPlaylists(playlistData.playlists) */
        )
        continuationToken = playlistData.nextContinuationToken
      } while (continuationToken !== undefined)
    } catch (e) {
      return {
        playlists: undefined,
        playlistItems: undefined,
      }
    }

    const playlists = []
    const playlistKeyParts =
      query.playlist !== undefined
        ? query.playlist.split('/').join('/collections/').split('/')
        : undefined
    const playlistsSource =
      query.playlist !== undefined
        ? get(playlistsObject, playlistKeyParts, { collections: {} })
            .collections
        : playlistsObject
    const playlistItems =
      query.playlist !== undefined
        ? get(playlistsObject, playlistKeyParts, { items: {} }).items
        : playlistsObject

    if (playlistsSource !== undefined) {
      for (const [key, value] of Object.entries(playlistsSource)) {
        playlists.push({ name: key, items: value })
      }
    }

    return {
      playlists,
      playlistItems,
    }
  }

  getPlaylistLink(name: string) {
    const queryObject = JSON.parse(JSON.stringify(this.$route.query))
    const playlistLinkParts: Array<string> = []

    queryObject.playlist = encodeURIComponent(
      [queryObject.playlist, name]
        .filter(Boolean) // prevent initial join character
        .join('/')
    )

    for (const [key, value] of Object.entries(queryObject)) {
      playlistLinkParts.push(value === null ? key : `${key}=${value}`)
    }

    return `?${playlistLinkParts.join('&')}`
  }

  setSource(url: URL) {
    this.player.config.controls = [
      'play-large',
      'play',
      'progress',
      'current-time',
      'mute',
      'volume',
      'airplay',
    ]
    this.player.source = {
      type: 'audio',
      sources: [
        {
          src: url,
          type: 'audio/mp3',
        },
      ],
    }

    this.player.play()
  }
}
</script>

🥳 Done! 🥳

And now

get

some

🔥

tunes!

Your own webplayer with Nuxt & S3

By Jonas Thelemann

Your own webplayer with Nuxt & S3

  • 128