Your own webplayer with &
by Jonas Thelemann
On today's menu:
- Why Jonas, that already exists 🙄
- Nvm, get cheap S3 storage 💾
- Bootstrap a Nuxt app 🚀
- 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