...
 
Commits (11)
......@@ -6,6 +6,7 @@
.env.local
.env.*.local
/public/thekno-env.json
/public/local/
# Log files
npm-debug.log*
# thekno
A Vue.js-based frontend for the Lohrothek REST API.
A Vue.js-based media library and stream web app for your community radio.
This project required *NodeJS* and *npm*. If any of the commands below are missing, refer to the *System Requirements* section at the end of this document.
This project requires *NodeJS* and *npm*. If any of the commands below are
not available on your computer, see the [System Requirements](#system-requirements)
section at the end of this document.
### Development and Testing Workflow
The project uses a number of external dependencies, that you can install with the `npm install` command. After that you can just run `npm run serve` to start up a development server.
The project uses numerous external dependencies that you can install with the
`npm install` command. After that you can just run `npm run serve` to start up
a development server.
*thekno* ships with fixtures that do not require a backend server, but in case you do want to work with the [Lohrothek REST API](https://git.hack-hro.de/lohro/lohrothek/lohrothek-api) you can do that. The development server started by `npm run serve` will proxy all requests to the `/api/` endpoint to `http://localhost:8000/` by default. This matches the default configuration for the Django webserver. In case you want to use a different backend server or have started Django on a non-standard port you can provide the `PROXY_TARGET` environment variable to `npm run serve`. A development server with the production-backend available on [thek.lohro.de](https://thek.lohro.de) can be started like this:
*thekno* ships with fixtures that do not require a backend server, but in case
you do want to work with the [Lohrothek REST API](https://git.hack-hro.de/lohro/lohrothek/lohrothek-api)
you can do that. The development server started by `npm run serve` will proxy all
requests to the `/api/` endpoint to `http://localhost:8000/` by default. This
matches the default configuration for the Django webserver. In case you want
to use a different backend server or have started Django on a non-standard
port you can provide the `PROXY_TARGET` environment variable to `npm run serve`.
A development server with the production-backend available on
[thek.lohro.de](https://thek.lohro.de) can be started like this:
```sh
PROXY_TARGET=http://localhost:5000 npm run serve
```
Alternatively you can also directly use another API by passing the `VUE_APP_APIORIGIN` environment variable.
Alternatively you can also directly use another API by passing the
`VUE_APP_APIORIGIN` environment variable.
```sh
VUE_APP_APIORIGIN=https://thek.lohro.de npm run serve
```
You may also use the included docker script `scripts/docker.sh`. It will build and start *thekno* in an environment similar to those in production. Providing a [custom environment](#custom-environment) is possible with the `--build-arg` option like this:
You may also use the included docker script `scripts/docker.sh`. It will build
and start *thekno* in an environment similar to those in production. Providing
a [custom environment](#environments-and-configuration) is possible with the
`--build-arg` option like this:
```sh
scripts/docker.sh --build-arg env_file=path/to/my/environment-file.json
```
```
## Contributions
Contributions are always welcome. Before you push your commits or create *merge requests* make sure that `npm run lint` and `npm run test` do not report any errors (that did not exist before 😅).
Contributions are always welcome. Before you push your commits or create
*merge requests* make sure that `npm run lint` and `npm run test` do not
report any errors (that did not exist before 😅).
## Builds & Deployment
VueJS provides a build target out of the box. You can create a build with `npm run build` and deploy the contents of the `build/dist` directory afterwards. There is also a `Dockerfile` that can be used to create docker images (in fact it’s used to automatically deploy *thekno* to our [staging server](https://lohro-lohrothek-thekno-staging.lohrothek.git-k8s.hack-hro.de/)).
VueJS provides a build target out of the box. You can create a build with
`npm run build` and deploy the contents of the `build/dist` directory
afterwards. There is also a `Dockerfile` that can be used to create docker
images (in fact it’s used to automatically deploy *thekno* to our
[staging server](https://thekno.farbdev.org)).
There will also be a deb package for Debian at a later point.
See [Custom Environments](#custom-environments) for a way to alter the default behaviour of *thekno*.
See [Custom Environments](#custom-environments) for a way to alter the
default behaviour of *thekno*.
## Custom Environments
*thekno* supports some environment configuration. You may put the following options in a JSON file and serve it under the `/thekno-env.json` endpoint.
## Environments and Configuration
apiOrigin:
Origin (`protocol://host:port`, e.g. `http://localhost:8000` or `https://thek.lohro.de`) of the [*thekno* API](https://git.hack-hro.de/lohro/lohrothek/lohrothek-api).
*thekno* supports extensive environment runtime configuration. See the
[Environment Configuration](./docs/environment-configuration.md) document for
more information on this topic.
## System Requirements
This projects requires fairly recent versions of *NodeJS* (v8+) and *npm* (v5.8+). Both are bundled with the default distribution available on [nodejs.org](https://nodejs.org/en/download/).
This projects requires fairly recent versions of *NodeJS* (v8+) and *npm* (v5.8+).
Both are bundled with the default distribution available on
[nodejs.org](https://nodejs.org/en/download/).
### Debian Stretch
If you are using Debian Stretch you’ll notice that the distributed `nodejs` package is fairly old and that npm is missing entirely.
If you are using Debian Stretch you’ll notice that the distributed `nodejs` package
is fairly old and that npm is missing entirely.
If you prefer packages provided by the Debian project, you can activate the `stretch-backports` repository. Add the following line to your `/etc/apt/sources.list`:
If you prefer packages provided by the Debian project, you can activate the
`stretch-backports` repository. Add the following line to your
`/etc/apt/sources.list`:
```
deb http://deb.debian.org/debian stretch-backports main
```
After that you will be able to install `nodejs` and `npm` with the following command:
After that you will be able to install `nodejs` and `npm` with the
following command:
```sh
apt update && apt install -t stretch-backports nodejs npm
......@@ -74,11 +102,14 @@ apt update && apt install -t stretch-backports nodejs npm
### Debian Buster
Debian Buster ships with the required versions so that you can just install the `nodejs` and `npm` packages.
Debian Buster ships with the required versions so that you can just install
the `nodejs` and `npm` packages.
### Other Distributions & Windows
For all other distributions that do not contain the required *NodeJS* and *npm* packages visit the [download section](https://nodejs.org/en/download/) on the NodeJS page.
For all other distributions that do not contain the required *NodeJS* and *npm*
packages visit the [download section](https://nodejs.org/en/download/) on the
NodeJS page.
If you have better advice for any distribution, add them to this section.
# Runtime Environment Configuration
thekno supports a variety of configuration options that you can change at runtime.
There is no need to modify the code of thekno, instead you modify a JSON file that
you serve with your web server.
When thekno is loaded it will query the `/thekno-env.json` endpoint. This is where
the runtime configuration file is expected.
## Configuration Options
### Base Data
`apiOrigin` (required):
Origin (`protocol://host:port`, e.g. `http://localhost:8000`
or `https://thek.lohro.de`) of the [*thekno* API](./thekno-api.md).
`name` (required):
The name of your community Radio. This is used in various places
throughout the app.
`about`:
A short (or even long) markdown-formatted info text on your
community radio.
### Provider & Legal Information
The following items are displayed on your about page in the legal section.
These configuration options are not required but you are advised to add them
as they are most likely required by law (especially in Germany).
`provider.legal`:
URL to your legal terms & conditions or Impressum.
`provider.contact`:
URL to your contact page.
`provider.privacy`:
URL to your privacy page.
### Social Sharing & Presence
Social platforms will be displayed as icon links on your about page.
If you don’t have any social media presence you can leave out the
`social` property on your configuration.
`social.platforms[].id`:
Platform identifier for a social platform. One of `grouprise`, `mastodon`,
`facebook`, `instagram`, `twitter`, or `youtube`.
`social.platforms[].instance`:
Platform instance for platforms that are federated. Required for `grouprise`
and `mastodon`.
`social.platforms[].handle`:
Username/Handle on the platform. Required for every platform except `youtube`.
`social.platforms[].url`:
URL to your page on the platform. Only used and required for `youtube`.
### Livestream
Apart from playing existing recordings thekno also supports livestreams. If
you configure live streams users will be able to conveniently start the stream
on the Thekno start page.
If you have multiple streams with different codecs you’ll be able to add all
of them with the same sources format that is used in the [Thekno API](./thekno-api.md)
for recordings.
If you don’t have a livestream you can leave out the `live` property
on your configuration.
`live.audio.sources[].bitrate` (required):
Average bitrate of this stream. This will be used in conjunction with the
codec and container information and the users network connection and codec
support to determine the appropriate stream.
`live.audio.sources[].src` (required):
Absolute URL to the source of this stream.
`live.audio.sources[].codec` (required):
Codec mime-type of this stream. For MP3 this is `audio/mpeg`, for AAC it’s
`audio/aac`, Opus has `audio/opus` and Vorbis `audio/vorbis`.
`live.audio.sources[].container` (required):
Container mime-type of this stream. For MP3 this is `null`, for AAC it’s
`audio/mp4` and OGG/Opus and OGG/Vorbis both use `audio/ogg`.
In order to display track metadata while the stream is playing Thekno specifies
a small [Track-Service API](./trackservice-api.md). Thekno supports
polling and WebSockets, but in the interest of users with metered
network connection you’re advised to use WebSockets if you can.
`live.trackService.url`:
The URL to your track-service API. This can either be a WebSocket URL
starting with `wss://` or a HTTP URL used for polling.
`live.trackService.timeOffsetSec`:
If your track service is pushing out new track data faster than the
stream you can define an offset. Each new track date record will be
queued for the specified amount of time before the interface will
be updated. Offset is in seconds.
### Content Suggestions
It’s hard and time-intensive to write proper descriptions for recordings.
In the spirit of open participation users can submit content via mail.
`suggestContent.name`:
The name displayed in the frontend where content can be suggested.
`suggestContent.address`:
The email address the suggestion is send to.
### User OS Integration
There are several browser APIs that allow a tighter integration with the
underlying operating system and enable Thekno to provide a better user
experience on various platforms.
`integration.logo[]`:
This is an array of objects as defined by the
[web app manifest specification](https://www.w3.org/TR/appmanifest/#icons-member).
`integration.shortName`:
The shortest possible name for your community radio.
`integration.description`:
A short description of your app content. See the example
configuration below for a good example.
`integration.splashBackground`:
The color of the splash screen as a valid css expression.
This is used when Thekno is started by the user.
`integration.mediaSession.fallbackArtwork`:
Fallback artwork that is displayed as part of the media session.
On mobile devices this is usually part of a sticky notification
with controls for the playing track(s) and other information like
the artist, album and title.
## Example Configuration
```json
{
"name": "LOHRO 90.2",
"apiOrigin": "https://thek.lohro.de",
"about": "LOHRO realisiert ein 24-stündiges Vollprogramm an sieben Wochentagen als nicht kommerzielles Lokalradio in Rostock und Umgebung. Es steht für Engagement, Vielfalt und Musik außerhalb des Mainstreams.",
"provider": {
"legal": "https://www.lohro.de/impressum/",
"contact": "https://www.lohro.de/kontakt/",
"privacy": "https://www.lohro.de/datenschutz/"
},
"social": {
"platforms": [
{
"id": "grouprise",
"instance": "https://stadtgestalten.org",
"handle": "lohro"
},
{
"id": "facebook",
"handle": "LOHRO"
},
{
"id": "twitter",
"handle": "LOHRO"
},
{
"id": "instagram",
"handle": "lohro_90.2"
},
{
"id": "youtube",
"url": "https://www.youtube.com/channel/UCQ1TiXn2QNsPVM0X1JTzbrQ"
}
]
},
"live": {
"trackService": {
"url": "wss://api-trackservice.lohro.de/api/events",
"timeOffsetSec": 30
},
"audio": {
"sources": [
{
"bitrate": 192,
"src": "https://stream.lohro.de/lohro.mp3",
"codec": "audio/mpeg",
"container": null
},
{
"bitrate": 128,
"src": "https://stream.lohro.de/lohro_low.mp3",
"codec": "audio/mpeg",
"container": null
},
{
"bitrate": 253.6,
"src": "https://stream.lohro.de/lohro_opus.ogg",
"codec": "audio/opus",
"container": "audio/ogg"
},
{
"bitrate": 250.4,
"src": "https://stream.lohro.de/lohro_vorbis.ogg",
"codec": "audio/vorbis",
"container": "audio/ogg"
},
{
"bitrate": 70.8,
"src": "https://stream.lohro.de/lohro.aac",
"codec": "audio/aac",
"container": "audio/mp4"
}
]
}
},
"suggestContent": {
"subject": "Beschreibungsvorschlag für \"{title}\"",
"name": "musik@lohro.de",
"address": "musik@lohro.de"
},
"integration": {
"splashBackground": "#fff",
"shortName": "Lohro",
"description": "Musik & Beiträge zum Nachhören vom Rostocker Lokalradio.",
"logo": [
{
"src": "https://thek.lohro.de/~local/logo.svg",
"type": "image/svg",
"sizes": "512x512"
},
{
"src": "https://thek.lohro.de/~local/logo_144.png",
"type": "image/png",
"sizes": "144x144"
},
{
"src": "https://thek.lohro.de/~local/logo_256.png",
"type": "image/png",
"sizes": "256x256"
},
{
"src": "https://thek.lohro.de/~local/logo_512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"mediaSession": {
"fallbackArtwork": "https://thek.lohro.de/~local/media-session-bg.png"
}
}
}
```
# Thekno API
TODO
# Thekno Track-Service API
TODO
......@@ -2150,8 +2150,7 @@
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==",
"dev": true
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
},
"batch": {
"version": "0.6.1",
......@@ -9365,6 +9364,11 @@
"readable-stream": "^2.0.2"
}
},
"reconnecting-websocket": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.2.0.tgz",
"integrity": "sha512-HMD8A0sv40xhkHf/T4qxktyOvHx7K3d2A9i1QG2wRIYdMecxQJMhTIBH4aQ8KfQLfQW4UOqNSfxTgv0C+MbPIA=="
},
"regenerate": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
......@@ -10870,6 +10874,11 @@
}
}
},
"text-encoder-lite": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/text-encoder-lite/-/text-encoder-lite-2.0.0.tgz",
"integrity": "sha512-bo08ND8LlBwPeU23EluRUcO3p2Rsb/eN5EIfOVqfRmblNDEVKK5IzM9Qfidvo+odT0hhV8mpXQcP/M5MMzABXw=="
},
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
......
......@@ -10,6 +10,7 @@
"docker": "scripts/docker.sh"
},
"dependencies": {
"base64-js": "^1.3.1",
"body-scroll-toggle": "^0.2.0",
"date-fns": "^2.1.0",
"howler": "^2.1.1",
......@@ -17,7 +18,9 @@
"mem": "^5.1.1",
"multi-download": "^3.0.0",
"nano-markdown": "^1.2.0",
"reconnecting-websocket": "^4.2.0",
"supports-webp": "^2.0.1",
"text-encoder-lite": "^2.0.0",
"vue": "^2.6.10",
"vue-clamp": "^0.2.1",
"vue-feather-icons": "^5.0.0",
......
......@@ -2,6 +2,7 @@
<div :style="style">
<NavBar/>
<router-view :key="routerViewKey"></router-view>
<ProviderData/>
<Player/>
</div>
</template>
......@@ -10,6 +11,7 @@
import Player from './components/player/Player'
import NavBar from './components/nav/NavBar'
import { oneOf } from './util'
import ProviderData from './components/nav/ProviderData'
function isTextField (field) {
if (!field) { return false }
......@@ -23,7 +25,7 @@
export default {
name: 'App',
components: { NavBar, Player },
components: { ProviderData, NavBar, Player },
data () {
return {
hasFocus: isTextField(document.activeElement)
......
......@@ -26,7 +26,7 @@
size: {
type: String,
default: null,
validator: oneOf(['tiny', 'small', 'large', 'huge'])
validator: oneOf(['small', 'medium', 'large'])
},
progress: {
type: Number,
......@@ -59,22 +59,27 @@
display: inline-flex;
border: none;
background: rgba(255, 255, 255, .5);
font-weight: bold;
color: lighten($color-base, 10%);
min-width: 7.5em;
text-align: center;
align-items: center;
justify-content: center;
border-radius: 2px;
padding: .75em 1em;
padding: calc(.375em - 1px) .75em;
min-width: 5em;
cursor: pointer;
transition: none 66ms cubic-bezier(.3, 0, 0, 1);
transition-property: box-shadow, transform, background-color, opacity;
transform-origin: 50% 50%;
position: relative;
font-size: 1rem;
height: 2.25em;
font-family: inherit;
text-decoration: none;
svg {
display: block;
height: 80%;
width: auto;
}
&.has-icon-only {
......@@ -95,21 +100,16 @@
transform: scale(.9) !important;
}
&.is-tiny {
font-size: .65rem;
}
&.is-small {
font-size: .85rem;
padding: .5em .75em;
}
&.is-large {
font-size: 1.15rem;
&.is-medium {
font-size: 1.25rem;
}
&.is-huge {
font-size: 1.35rem;
&.is-large {
font-size: 1.5rem;
}
&.is-primary {
......
......@@ -5,7 +5,8 @@
</template>
<template v-else>
<app-button @click="paginator.prev" :disabled="!paginator.hasPrev" class="mr-3"
:aria-label="$t('paginator_backward_label')" :size="isSmall ? 'small' : null">
:aria-label="$t('paginator_backward_label')" :size="isSmall ? 'small' : null"
:style="{ visibility: paginator.hasPrev ? null : 'hidden' }">
<template v-slot:icon v-if="isSmall">
<ChevronLeftIcon style="margin-left: -.1em"/>
</template>
......@@ -52,9 +53,9 @@
paginator_more: Show More
de:
paginator_forward: Nächste
paginator_forward: Mehr
paginator_forward_label: Nächste Seite
paginator_backward: Vorherige
paginator_backward: Zurück
paginator_backward_label: Vorherige Seite
paginator_more: Mehr anzeigen
</i18n>
<template>
<a :href="platform.href" :aria-label="label" :title="label" target="_blank"
class="button is-social" :class="platform.icon ? 'has-icon-only' : null">
<component v-if="platform.icon" :is="platform.icon" class="white" style="width: 24px"/>
<span v-if="!platform.icon">{{ platform.name }}</span>
</a>
</template>
<script>
export default {
props: {
platform: Object,
name: String
},
computed: {
label () {
return this.$t('social_label', { platform: this.platform.name, name: this.name })
}
}
}
</script>
<i18n lang="yaml">
en:
social_label: '{name} on {platform}'
de:
social_label: '{name} auf {platform}'
</i18n>
<style lang="scss">
.button.is-social {
padding: 0;
background: rgba(0, 0, 0, 0.1);
color: white;
text-decoration: none;
&.has-icon-only {
width: 48px;
height: 48px;
}
}
</style>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.675 0h-21.35c-.732 0-1.325.593-1.325 1.325v21.351c0 .731.593 1.324 1.325 1.324h11.495v-9.294h-3.128v-3.622h3.128v-2.671c0-3.1 1.893-4.788 4.659-4.788 1.325 0 2.463.099 2.795.143v3.24l-1.918.001c-1.504 0-1.795.715-1.795 1.763v2.313h3.587l-.467 3.622h-3.12v9.293h6.116c.73 0 1.323-.593 1.323-1.325v-21.35c0-.732-.593-1.325-1.325-1.325z"/>
</svg>
</template>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0v24h24V0z" fill="currentColor"/>
<path d="M18.963 2.17h2.24v2.24h-2.24zM16.723 2.17h2.24v2.24h-2.24z" opacity=".5"/>
<path d="M14.483 2.17h2.24v2.24h-2.24z" opacity=".4"/>
<path d="M12.257 2.17h2.24v2.24h-2.24z" opacity=".2"/>
<path d="M18.963 4.41h2.24v2.24h-2.24z"/>
<path d="M16.723 4.41h2.24v2.24h-2.24z" opacity=".9"/>
<path d="M14.483 4.41h2.24v2.24h-2.24z" opacity=".8"/>
<path d="M12.257 4.41h2.24v2.24h-2.24z"/>
<path d="M10.017 4.41h2.24v2.24h-2.24z" opacity=".5"/>
<path d="M18.963 6.65h2.24v2.24h-2.24z"/>
<path d="M16.723 6.65h2.24v2.24h-2.24z" opacity=".3"/>
<path d="M18.963 8.89h2.24v2.24h-2.24z"/>
<path d="M16.723 8.89h2.24v2.24h-2.24z" opacity=".3"/>
<path d="M18.963 11.117h2.24v2.24h-2.24z"/>
<path d="M16.723 11.117h2.24v2.24h-2.24z" opacity=".5"/>
<path d="M18.963 13.357h2.24v2.24h-2.24z"/>
<path d="M16.723 13.357h2.24v2.24h-2.24z" opacity=".8"/>
<path d="M18.963 15.597h2.24v2.24h-2.24z"/>
<path d="M16.723 15.597h2.24v2.24h-2.24z" opacity=".3"/>
<path d="M18.963 17.837h2.24v2.24h-2.24z"/>
<path d="M16.723 17.837h2.24v2.24h-2.24z" opacity=".3"/>
<path d="M18.963 20.077h2.24v2.24h-2.24z"/>
<path d="M16.723 20.077h2.24v2.24h-2.24z" opacity=".3"/>
<path d="M14.483 11.117h2.24v2.24h-2.24z" opacity=".4"/>
<path d="M12.257 11.117h2.24v2.24h-2.24z" opacity=".9"/>
<path d="M10.017 11.117h2.24v2.24h-2.24z" opacity=".7"/>
<path d="M12.257 6.65h2.24v2.24h-2.24z" opacity=".6"/>
<path d="M10.017 6.65h2.24v2.24h-2.24z"/>
<path d="M12.257 8.89h2.24v2.24h-2.24z" opacity=".5"/>
<path d="M10.017 8.89h2.24v2.24h-2.24z"/>
<path d="M14.483 13.357h2.24v2.24h-2.24z" opacity=".9"/>
<path d="M12.257 13.357h2.24v2.24h-2.24z" opacity=".9"/>
<path d="M12.257 15.597h2.24v2.24h-2.24z"/>
<path d="M10.017 15.597h2.24v2.24h-2.24z" opacity=".5"/>
<path d="M12.257 17.837h2.24v2.24h-2.24z" opacity=".7"/>
<path d="M10.017 17.837h2.24v2.24h-2.24z" opacity=".9"/>
<path d="M14.483 15.597h2.24v2.24h-2.24z" opacity=".3"/>
<path d="M7.777 17.837h2.24v2.24h-2.24z" opacity=".1"/>
<path d="M10.017 20.077h2.24v2.24h-2.24z"/>
<path d="M7.777 20.077h2.24v2.24h-2.24z" opacity=".5"/>
<path d="M12.243 20.077h2.24v2.24h-2.24z" opacity=".2"/>
</svg>
</template>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</template>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="currentColor"
preserveAspectRatio="xMidYMid meet">
<path d="M23.193 7.879c0-5.206-3.411-6.732-3.411-6.732C18.062.357 15.108.025 12.041 0h-.076c-3.068.025-6.02.357-7.74 1.147c0 0-3.411 1.526-3.411 6.732c0 1.192-.023 2.618.015 4.129c.124 5.092.934 10.109 5.641 11.355c2.17.574 4.034.695 5.535.612c2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.129.539c-2.165-.074-4.449-.233-4.799-2.891a5.499 5.499 0 0 1-.048-.745s2.125.52 4.817.643c1.646.075 3.19-.097 4.758-.283c3.007-.359 5.625-2.212 5.954-3.905c.517-2.665.475-6.507.475-6.507zm-4.024 6.709h-2.497V8.469c0-1.29-.543-1.944-1.628-1.944c-1.2 0-1.802.776-1.802 2.312v3.349h-2.483v-3.35c0-1.536-.602-2.312-1.802-2.312c-1.085 0-1.628.655-1.628 1.944v6.119H4.832V8.284c0-1.289.328-2.313.987-3.07c.68-.758 1.569-1.146 2.674-1.146c1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.043c.64-.983 1.608-1.474 2.886-1.474c1.104 0 1.994.388 2.674 1.146c.658.757.986 1.781.986 3.07v6.304z"/>
</svg>
</template>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/>
</svg>
</template>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/>
</svg>
</template>
export { default as FacebookIcon } from './FacebookIcon'
export { default as GroupriseIcon } from './GroupriseIcon'
export { default as InstagramIcon } from './InstagramIcon'
export { default as MastodonIcon } from './MastodonIcon'
export { default as TwitterIcon } from './TwitterIcon'
export { default as YoutubeIcon } from './YoutubeIcon'
import { createHowlerTracks } from '../../util'
import { createHowlerTracks } from '../../util/audio'
export const recordingTracks = {
data () {
......
<template>
<app-container>
<footer class="center">
<router-link :to="{ name: 'about' }" class="button is-transparent mb-3" v-if="$route.name !== 'about'">
{{ $t('provider.about', [this.env.name]) }}
</router-link>
<div class="d-flex justify-center">
<a :href="url" v-t="label" v-for="{id, url, label} in links" :key="id"
class="mr-3" target="_blank"></a>
</div>
</footer>
</app-container>
</template>
<script>
export default {
inject: ['env'],
computed: {
links () {
return Object.entries(this.env.provider || {})
.map(([id, url]) => ({ id, url, label: `provider.${id}` }))
}
}
}
</script>
<i18n lang="yaml">
en:
provider.legal: Legal
provider.contact: Contact
provider.privacy: Privacy
provider.about: 'About {0}'
de:
provider.legal: Impressum
provider.contact: Kontakt
provider.privacy: Datenschutz
provider.about: 'Über {0}'
</i18n>
......@@ -6,35 +6,54 @@
const { MediaMetadata } = window
const session = navigator.mediaSession
export default {
created () {
if (session) {
session.setActionHandler('previoustrack', () => {
this.$player.queue.prev()
})
session.setActionHandler('nexttrack', () => {
this.$player.queue.next()
})
const seekTime = 15
session.setActionHandler('seekbackward', () => {
this.$player.seekRelative(-15)
})
session.setActionHandler('seekforward', () => {
this.$player.seekRelative(+15)
})
export default {
computed: {
actionHandlers () {
const { canSeek } = this.$player
const { hasNext, hasPrev } = this.$player.queue
return {
seekbackward: canSeek ? () => { this.$player.seekRelative(-seekTime) } : null,
seekforward: canSeek ? () => { this.$player.seekRelative(seekTime) } : null,
previoustrack: hasPrev || canSeek ? () => { this.$player.queue.prev() } : null,
nexttrack: hasNext ? () => { this.$player.queue.next() } : null
}
},
fallbackArtwork () {
return this.env.integration && this.env.integration.mediaSession
? this.env.integration.mediaSession.fallbackArtwork || null
: null
}
},
watch: {
'$player.currentTrack' (newTrack) {
actionHandlers (newHandlers) {
if (session) {
for (const [name, handler] of Object.entries(newHandlers)) {
session.setActionHandler(name, handler)
}
}
},
'$player.currentTrack.metadata' (metadata) {
if (session && MediaMetadata) {
// TODO: add artwork, artist, and album
navigator.mediaSession.metadata = !newTrack ? null : new MediaMetadata({
title: newTrack.metadata.title
navigator.mediaSession.metadata = !metadata ? null : new MediaMetadata({
title: metadata.title,
// TODO: when artist and album are not defined Chromium-based mobile browsers
// show the thekno origin address instead. There doesn’t seem to be a good
// way to change this. Adding an empty string will hide the URL but forces
// a minus-sign next to the title :(.
artist: metadata.artist || this.env.name,
// TODO: use the series of the recording as album here if available
album: metadata.album || metadata.description || undefined,
// TODO: artwork takes an array of { src, sizes, type }, but we can’t guarantee
// that we can deduce sizes and type directly from the source, so this is WIP
artwork: metadata.cover_image || this.fallbackArtwork ? [
{ src: metadata.cover_image || this.fallbackArtwork }
] : undefined
})
}
}
}
},
inject: ['env']
}
</script>
<template>
<PlayPauseButton :tracks="tracks" track-id="livestream"
:play-label="label" :play-icon="RadioIcon"
v-bind="$attrs"/>
</template>
<script>
import { RadioIcon } from 'vue-feather-icons'
import PlayPauseButton from './PlayPauseButton'
import { detectSources, createHowlerData } from '../../util/audio'
export default {
components: { PlayPauseButton },
data () {
return {
liveStream: null,
trackData: null,
RadioIcon
}
},
computed: {
tracks () {
if (!this.trackData) return []
return [
{
...this.trackData,
id: 'livestream',
collectionId: 'livestream',
title: `${this.env.name}`,
description: null
}
]
},
label () {
return this.$t(`play_live.label${this.$mq === 'xs' ? '_short' : ''}`)
}
},
async created () {
const sources = await detectSources(this.env.live.audio.sources)
this.trackData = {
sources,
howlerData: createHowlerData(sources),
isStream: true
}
},
inject: ['env']
}
</script>
<i18n lang="yaml">
en:
play_live.label_short: Live
play_live.label: Play Livestream
de:
play_live.label_short: Live
play_live.label: Jetzt Live hören
</i18n>
<template>
<app-button @click="togglePlay" v-bind="$attrs" style="width: 165px" :progress="durationProgress"
<app-button @click="togglePlay" v-bind="$attrs" :progress="durationProgress"
@mouseover.native="isHovered = true" @mouseleave.native="isHovered = false">
<template v-slot:icon>
<component :is="icon"/>
......@@ -21,7 +21,9 @@
},
computed: {
label () {
return this.$t(`rpb_action_${this.currentAction}`)
return this.currentAction === 'play' && this.playLabel
? this.playLabel
: this.$t(`rpb_action_${this.currentAction}`)
},
durationProgress () {
if (!this.isCurrentTrack) return null
......
......@@ -2,6 +2,7 @@
<div class="player">
<PlayerDrawer v-if="isMinimal" :is-open="isDrawerOpen" @close="isDrawerOpen = false"/>
<MediaSession/>
<TrackService/>
<div class="player-io no-select glass" :class="$player.currentTrack !== null && !isDrawerOpen ? 'is-active' : true">
<div class="player-level is-drawer" v-if="isMinimal">
......@@ -29,6 +30,7 @@
<script>
import MediaSession from './MediaSession'
import TrackService from './TrackService'
import PlayerControls from './PlayerControls'
import PlayerDrawer from './PlayerDrawer'
import PlayerExtraControls from './PlayerExtraControls'
......@@ -38,13 +40,14 @@
export default {
components: {
PlayerDrawerIcon,
MediaSession,
PlayerDrawerIcon,
PlayerControls,
PlayerDrawer,
PlayerExtraControls,
PlayerProgress,
PlayerTrackInfo
PlayerTrackInfo,
TrackService
},
data () {
return {
......
<template>
<div class="player-controls">
<PlayerControl class="mx-2" :title="$t('player_prev_track')" v-if="!isMinimal"
:disabled="!$player.queue.hasPrev && !$player.isPlaying"
:disabled="!$player.queue.hasPrev && !$player.canSeek"
@click="$player.queue.prev">
<SkipBackIcon size="19"/>
</PlayerControl>
......
......@@ -4,7 +4,7 @@
<div class="player-progress-bar" :class="isMinimal ? null : 'mx-2'">
<SeekableProgress :min="progress.min" :max="progress.max" v-model="value"
:step="5" :step-factor="6" :knob-label="$t('player_set_seek')"
:look="isMinimal ? 'plain' : 'default'" :editable="!isMinimal"
:look="isMinimal ? 'plain' : 'default'" :editable="!isMinimal && $player.canSeek"
:label="`${timeLabels.progress} / ${timeLabels.duration}`"/>
</div>
<span class="player-progress-label" v-if="!isMinimal">{{ timeLabels.duration }}</span>
......@@ -30,12 +30,16 @@
}
},
progress () {
return this.$player.currentTrack
const { currentTrack } = this.$player
return currentTrack && currentTrack.duration > 0 && currentTrack.duration !== Number.POSITIVE_INFINITY
? { min: 0, max: this.$player.currentTrack.duration, value: this.$player.currentTrack.progress }
: { min: 0, max: 1, value: 0 }
},
timeLabels () {
const { duration, progress } = this.$player.currentTrack || { duration: 0, progress: 0 }
let { duration, progress } = this.$player.currentTrack || { duration: 0, progress: 0 }
if (duration === Number.POSITIVE_INFINITY) {
duration = progress = 0
}
return {
duration: secondsToDuration(duration),
progress: secondsToDuration(progress, duration)
......
<template>
<app-card :link="{ to: { name: 'recording', params: { id: trackData.collectionId } } }"
<app-card :link="link"
:image="{ useDefaultFallback: true, size: isVertical ? null : 64 }" :is-vertical="isVertical"
:title="trackData.title" :description="trackData.description"
v-bind="$attrs" v-if="trackData">
......@@ -16,6 +16,11 @@
return this.$player.currentTrack
? this.$player.currentTrack.metadata
: null
},
link () {
return this.trackData && typeof this.trackData.collectionId === 'number'
? { to: { name: 'recording', params: { id: this.trackData.collectionId } } }
: null
}
}
}
......
<template>
<span></span>
</template>
<script>
import { createTrackServiceObservable } from '../../util/live-stream'
export default {
data () {
return {
trackService: null
}
},
async created () {
if (this.env.live.trackService) {
const { url, ...options } = this.env.live.trackService
this.trackService = await createTrackServiceObservable(url, options)
}
},
watch: {
'$player.currentTrack.isStream' (isStream) {
if (isStream) {
this.trackService.start()
} else {
this.trackService.pause()
}
},
'trackService.metadata' (newMetadata) {
this.$player.replaceMetadata(newMetadata)
}
},
beforeDestroy () {
if (this.trackService) {
this.trackService.destroy()
}
},
inject: ['env']
}
</script>
......@@ -12,7 +12,9 @@ export const playPauseMixin = {
trackId: {
type: [String, Number],
required: true
}
},
playLabel: String,
playIcon: Object
},
data () {
return {
......@@ -23,7 +25,8 @@ export const playPauseMixin = {
},
computed: {
isCurrentTrack () {
return this.$player.currentTrack && this.$player.currentTrack.metadata.collectionId === this.trackId
const { currentTrack } = this.$player
return currentTrack && currentTrack.metadata && currentTrack.metadata.collectionId === this.trackId
},
currentAction () {
if (this.isCurrentTrack && this.$player.isPlaying) {
......@@ -40,7 +43,11 @@ export const playPauseMixin = {
} else if (this.isCurrentTrack && this.$player.isPlaying) {
return PlayingIcon
} else {
return this.circleIcons ? PlayCircleIcon : PlayIcon
return this.playIcon
? this.playIcon
: this.circleIcons
? PlayCircleIcon
: PlayIcon
}
}
},
......
......@@ -14,6 +14,7 @@ import 'vue-resize/dist/vue-resize.css'
import './styles/main.scss'
import './components/register'
import { registerPWA } from './util/pwa'
Vue.config.productionTip = false
Vue.config.keyCodes = {
......@@ -37,6 +38,7 @@ Vue.use(VueMq, {
})
function init (env) {
registerPWA(env)
new Vue({
router,
i18n,
......
......@@ -36,9 +36,9 @@ function createQueue (Vue, player) {
},
methods: {
prev () {
if (!this.hasPrev || player.currentTrack.progress > 3) {
if (player.canSeek && player.currentTrack.progress > 3) {
player.seek(0)
} else {
} else if (this.hasPrev) {
player.playTrack(this.prevTrack)
}
},
......@@ -85,6 +85,9 @@ function createPlayer (Vue) {
isPlaying () {
return this.isStopped === false && this.isPaused === false
},
canSeek () {
return this.currentTrack && !this.currentTrack.isStream
},
...mapState({
isMuted: 'player.isMuted',
volume: 'player.volume'
......@@ -92,7 +95,7 @@ function createPlayer (Vue) {
},
methods: {
playTrack (trackData) {
const { howlerData: howlerTrackData, ...trackMetadata } = trackData
const { howlerData: howlerTrackData, isStream, ...trackMetadata } = trackData
if (trackControl) {
trackControl.destroy()
......@@ -164,6 +167,7 @@ function createPlayer (Vue) {
duration: 0,
progress: 0,
progressPercentage: 0,
isStream: isStream || false,
metadata: trackMetadata
}
......@@ -213,6 +217,16 @@ function createPlayer (Vue) {
} else {
this.pause()
}
},
replaceMetadata (newMetadata) {
/**
* You only ever need to call this method if the track that is being
* played is a stream and has no end. Otherwise just use the playTrack
* method.
*/
if (this.currentTrack) {
this.currentTrack.metadata = newMetadata
}
}
},
watch: {
......
......@@ -54,6 +54,11 @@ const router = new Router({
path: '/search',
name: 'search',
component: () => import(/* webpackChunkName: "search" */ './views/SearchPage.vue')
},
{
path: '/about',
name: 'about',
component: () => import(/* webpackChunkName: "about" */ './views/AboutPage.vue')
}
]
})
......
......@@ -32,6 +32,18 @@
align-items: center;
}
.justify-sb {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.grow {
flex-grow: 1;
}
.l-comma {
> li:not(:last-child)::after {
content: ',\00a0'
......@@ -146,6 +158,10 @@
color: white;
}
.center {
text-align: center;
}
// borders
.br-1 {
border-radius: 2px;
......
import mem from 'mem'
let trackSeqId = 0
const supportsAudioCodec = mem(function hasSupportFor (codec, container) {
if (!hasSupportFor.audio) {
hasSupportFor.audio = document.createElement('audio')
}
if (codec === 'audio/opus') {
// browsers are picky about these type of questions
codec = `${container}; codecs="opus"`
}
return hasSupportFor.audio.canPlayType(codec) === 'probably'
})
const getHowlerFormat = source => {
if (source.container === null && source.codec === 'audio/mpeg') return 'mp3'
if (source.codec === 'audio/opus') return 'opus'
if (source.codec === 'audio/aac') return 'aac'
return null
}
export async function detectSources (sources) {
const orderBySize = (source1, source2) => source1.bitrate - source2.bitrate
const orderByQuality = (source1, source2) => {
// We can’t just say that OPUS is better than MP3, because MP3 would still
// win if we compared a ridiculously low-bitrate OPUS file to a ridiculously
// high-bitrate MP3. Instead we compare bitrates but multiply them with a
// modifier that accounts for the better compression ratio.
const bitrateModifier = source => {
return ({ 'audio/opus': 3.5, 'audio/aac': 2 }[source.codec] || 1) * source.bitrate
}
return bitrateModifier(source2) - bitrateModifier(source1)
}
const optimizeSourceOrder = sources => {
/**
* Sorts the sources based on the current connection speed or device type,
* so that we offer higher quality on fast connections and lower quality
* on slow or metered connections
*/
// create a copy to make sure we don’t change the argument in-place
sources = [].concat(sources)
if ('connection' in window.navigator && navigator.connection.effectiveType) {
// if the client supports the Network Information API use that
// see: https://wicg.github.io/netinfo/
// The current spec treats 4G as the maximum effective type. It would be
// nice if we could also take into account if the connection is metered,
// but there doesn’t seem to be support for this.
sources.sort(navigator.connection.effectiveType === '4g' ? orderByQuality : orderBySize)
} else {
// assume that only desktop or laptops have logical resolutions past a screen width
// of 1024px and that we have fast connections on these devices
sources.sort(window.innerWidth > 1024 ? orderByQuality : orderBySize)
}
return sources
}
const selectSources = sources => {
return optimizeSourceOrder(sources)
.filter(source => supportsAudioCodec(source.codec, source.container))
.map(source => {
return {
src: source.src,
format: getHowlerFormat(source)
}
})
}
return selectSources(sources)
}
export const createHowlerData = sources => {
return sources.reduce((result, source) => {
result.src.push(source.src)
result.format.push(source.format)
return result
}, { src: [], format: [] })
}
export async function createHowlerTrack (recording, track) {
const sources = await detectSources(track.sources)
const howlerData = createHowlerData(sources)
return {
id: track.id || trackSeqId++,
collectionId: recording.id,
title: recording.title,
description: track.description || recording.description,
sources,
howlerData
}
}
export function createHowlerTracks (recording) {
return Promise.all(
recording.audio
.map(track => createHowlerTrack(recording, track))
)
}
import mem from 'mem'
export const oneOf = values => value => values.indexOf(value) !== -1
export const renderSimpleTemplateString = (str, ctx) => {
......@@ -41,94 +39,3 @@ export function secondsToDuration (seconds, max = null) {
date.setSeconds(seconds)
return date.toISOString().substring(start, 19)
}
const supportsAudioCodec = mem(function hasSupportFor (codec, container) {
if (!hasSupportFor.audio) {
hasSupportFor.audio = document.createElement('audio')
}
if (codec === 'audio/opus') {
// browsers are picky about these type of questions
codec = `${container}; codecs="opus"`
}
return hasSupportFor.audio.canPlayType(codec) === 'probably'
})
let trackSeqId = 0
export async function createHowlerTrack (recording, track) {
const orderBySize = (source1, source2) => source1.bitrate - source2.bitrate
const orderByQuality = (source1, source2) => {
// We can’t just say that OPUS is better than MP3, because MP3 would still
// win if we compared a ridiculously low-bitrate OPUS file to a ridiculously
// high-bitrate MP3. Instead we compare bitrates but multiply them with a
// modifier that accounts for the better compression ratio.
const bitrateModifier = source => {
return ({ 'audio/opus': 3.5, 'audio/aac': 2 }[source.codec] || 1) * source.bitrate
}
return bitrateModifier(source2) - bitrateModifier(source1)
}
const optimizeSourceOrder = sources => {
/**
* Sorts the sources based on the current connection speed or device type,
* so that we offer higher quality on fast connections and lower quality
* on slow or metered connections
*/
// create a copy to make sure we don’t change the argument in-place
sources = [].concat(sources)
if ('connection' in window.navigator && navigator.connection.effectiveType) {
// if the client supports the Network Information API use that
// see: https://wicg.github.io/netinfo/
// The current spec treats 4G as the maximum effective type. It would be
// nice if we could also take into account if the connection is metered,
// but there doesn’t seem to be support for this.
sources.sort(navigator.connection.effectiveType === '4g' ? orderByQuality : orderBySize)
} else {
// assume that only desktop or laptops have logical resolutions past a screen width
// of 1024px and that we have fast connections on these devices
sources.sort(window.innerWidth > 1024 ? orderByQuality : orderBySize)
}
return sources
}
const getHowlerFormat = source => {
if (source.container === null && source.codec === 'audio/mpeg') return 'mp3'
if (source.codec === 'audio/opus') return 'opus'
if (source.codec === 'audio/aac') return 'aac'
return null
}
const selectSources = sources => {
return optimizeSourceOrder(sources)
.filter(source => supportsAudioCodec(source.codec, source.container))
.map(source => {
return {
src: source.src,
format: getHowlerFormat(source)
}
})
}
const result = {
id: track.id || trackSeqId++,
collectionId: recording.id,
title: recording.title,
description: track.description || recording.description,
sources: selectSources(track.sources)
}
Object.defineProperty(result, 'howlerData', {
get () {
return result.sources.reduce((result, source) => {
result.src.push(source.src)
result.format.push(source.format)
return result
}, { src: [], format: [] })
}
})
return result
}
export function createHowlerTracks (recording) {
return Promise.all(
recording.audio
.map(track => createHowlerTrack(recording, track))
)
}
import Vue from 'vue'
const createWebSocketSubscriber = async (url, callback) => {
const ReconnectingWebSocket = (await import('reconnecting-websocket')).default
const rws = new ReconnectingWebSocket(url, [], { startClosed: true })
const processEvent = event => {
const eventData = JSON.parse(event.data)
if (eventData.source === 'current_track' && eventData.event === 'update') {
callback(eventData.payload)
}
}
rws.addEventListener('message', processEvent)
return {
start () {
rws.reconnect()
},
pause () {
rws.close()
},
destroy () {
rws.removeEventListener('message', processEvent)
rws.close()
}
}
}
const createPollSubscriber = async (url, callback) => {
let interval
const getData = () =>
fetch(url)
.then(res => res.json())
.then(data => data.results[0])
.then(track => { callback(track) })
const start = () => {
clearInterval(interval)
interval = setInterval(getData, 5000)
}
const pause = () => { clearInterval(interval) }
return {
start,
pause,
destroy: pause
}
}
export const createTrackServiceObservable = async (url, { timeOffsetSec = 0 }) => {
const protocol = new URL(url).protocol
const createSubscriber =
['ws:', 'wss:'].includes(protocol)
? createWebSocketSubscriber
: createPollSubscriber
let isFirstSinceStart = true
const observable = new Vue({
data: {
metadata: null,
isPaused: true
},
methods: {
start () {
isFirstSinceStart = true
this.isPaused = false
return subscriber.start()
},
pause () {
this.isPaused = true
return subscriber.pause()
},
destroy () {
this.isPaused = true
subscriber.destroy()
}
}
})
const subscriber = await createSubscriber(url, newMetadata => {
setTimeout(
() => {
if (!observable.isPaused) {
observable.metadata = newMetadata ? {
...newMetadata,
id: 'livestream',
collectionId: 'livestream',
} : null
}
},
isFirstSinceStart ? 0 : timeOffsetSec * 1000)
isFirstSinceStart = false
})
return observable
}
<
import { base64EncodeString } from './strings'
const THEME_COLOR = '#24242e'
function createAppManifestFromEnv (env) {
return {
name: env.name,
short_name: env.integration.shortName,