Remove Snapweb from project
9
.github/workflows/package.yml
vendored
|
@ -5,6 +5,7 @@ on:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BOOST_VERSION: 1_84_0
|
BOOST_VERSION: 1_84_0
|
||||||
|
SNAPWEB_VERSION: 0.5.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
@ -75,15 +76,19 @@ jobs:
|
||||||
path: /home/runner/.ccache
|
path: /home/runner/.ccache
|
||||||
key: ${{ matrix.os }}-${{ matrix.debian }}-ccache-${{ github.sha }}
|
key: ${{ matrix.os }}-${{ matrix.debian }}-ccache-${{ github.sha }}
|
||||||
restore-keys: ${{ matrix.os }}-${{ matrix.debian }}-ccache-
|
restore-keys: ${{ matrix.os }}-${{ matrix.debian }}-ccache-
|
||||||
|
- name: Get Snapweb
|
||||||
|
run: |
|
||||||
|
wget https://github.com/badaix/snapweb/releases/download/v${SNAPWEB_VERSION}/snapweb.zip
|
||||||
|
unzip snapweb.zip -d snapweb
|
||||||
- name: Create deb package
|
- name: Create deb package
|
||||||
env:
|
env:
|
||||||
# TODO: use environment variable $HOME/.ccache
|
# TODO: use environment variable $HOME/.ccache
|
||||||
CCACHE_DIR: /home/runner/.ccache
|
CCACHE_DIR: /home/runner/.ccache
|
||||||
run: |
|
run: |
|
||||||
fakeroot make -f debian/rules CMAKEFLAGS="-DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DBOOST_ROOT=$GITHUB_WORKSPACE/${{env.BOOST}} -DCMAKE_BUILD_TYPE:STRING=Release -DREVISION=${{ github.sha }} -DBUILD_WITH_PULSE=OFF" binary
|
fakeroot make -f debian/rules CMAKEFLAGS="-DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DBOOST_ROOT=$GITHUB_WORKSPACE/${{env.BOOST}} -DCMAKE_BUILD_TYPE:STRING=Release -DREVISION=${{ github.sha }} -DBUILD_WITH_PULSE=OFF -DSNAPWEB_DIR:STRING=$GITHUB_WORKSPACE/snapweb" binary
|
||||||
rename 's/_${{ matrix.arch }}/_without-pulse_${{ matrix.arch }}/g' ../snapclient*_${{ matrix.arch }}.deb
|
rename 's/_${{ matrix.arch }}/_without-pulse_${{ matrix.arch }}/g' ../snapclient*_${{ matrix.arch }}.deb
|
||||||
fakeroot make -f debian/rules clean
|
fakeroot make -f debian/rules clean
|
||||||
fakeroot make -f debian/rules CMAKEFLAGS="-DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DBOOST_ROOT=$GITHUB_WORKSPACE/${{env.BOOST}} -DCMAKE_BUILD_TYPE:STRING=Release -DREVISION=${{ github.sha }}" binary
|
fakeroot make -f debian/rules CMAKEFLAGS="-DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DBOOST_ROOT=$GITHUB_WORKSPACE/${{env.BOOST}} -DCMAKE_BUILD_TYPE:STRING=Release -DREVISION=${{ github.sha }} -DSNAPWEB_DIR:STRING=$GITHUB_WORKSPACE/snapweb" binary
|
||||||
- name: Archive artifacts
|
- name: Archive artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
@ -13,8 +13,8 @@ Build-Depends: debhelper (>= 10~),
|
||||||
libsoxr-dev
|
libsoxr-dev
|
||||||
Standards-Version: 4.1.4
|
Standards-Version: 4.1.4
|
||||||
Homepage: https://github.com/badaix/snapcast
|
Homepage: https://github.com/badaix/snapcast
|
||||||
Vcs-Git: https://salsa.debian.org/debian/snapcast.git
|
Vcs-Git: https://github.com/badaix/snapcast.git
|
||||||
Vcs-Browser: https://salsa.debian.org/debian/snapcast
|
Vcs-Browser: https://github.com/badaix/snapcast
|
||||||
|
|
||||||
Package: snapclient
|
Package: snapclient
|
||||||
Architecture: any
|
Architecture: any
|
||||||
|
|
|
@ -4,7 +4,7 @@ Upstream-Contact: Johannes Pohl <snapcast@badaix.de>
|
||||||
Source: https://github.com/badaix/snapcast
|
Source: https://github.com/badaix/snapcast
|
||||||
|
|
||||||
Files: *
|
Files: *
|
||||||
Copyright: 2014-2021 Johannes Pohl
|
Copyright: 2014-2024 Johannes Pohl
|
||||||
License: GPL-3+
|
License: GPL-3+
|
||||||
|
|
||||||
Files: client/browseZeroConf/browseAvahi.cpp
|
Files: client/browseZeroConf/browseAvahi.cpp
|
||||||
|
|
|
@ -124,8 +124,14 @@ else()
|
||||||
FILES etc/index.html
|
FILES etc/index.html
|
||||||
COMPONENT server
|
COMPONENT server
|
||||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/snapserver)
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/snapserver)
|
||||||
|
if(SNAPWEB_DIR)
|
||||||
|
install(DIRECTORY "${SNAPWEB_DIR}"
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/snapserver/snapweb)
|
||||||
|
else()
|
||||||
install(DIRECTORY etc/snapweb/
|
install(DIRECTORY etc/snapweb/
|
||||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/snapserver/snapweb)
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/snapserver/snapweb)
|
||||||
|
endif()
|
||||||
|
|
||||||
install(
|
install(
|
||||||
FILES etc/plug-ins/meta_mpd.py etc/plug-ins/meta_mopidy.py
|
FILES etc/plug-ins/meta_mpd.py etc/plug-ins/meta_mopidy.py
|
||||||
etc/plug-ins/meta_librespot-java.py
|
etc/plug-ins/meta_librespot-java.py
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
// local headers
|
// local headers
|
||||||
#include "common/aixlog.hpp"
|
#include "common/aixlog.hpp"
|
||||||
#include "common/message/pcm_chunk.hpp"
|
#include "common/message/pcm_chunk.hpp"
|
||||||
|
#include "common/utils/file_utils.hpp"
|
||||||
#include "control_session_ws.hpp"
|
#include "control_session_ws.hpp"
|
||||||
#include "stream_session_ws.hpp"
|
#include "stream_session_ws.hpp"
|
||||||
|
|
||||||
|
@ -281,9 +282,8 @@ void ControlSessionHttp::handle_request(http::request<Body, http::basic_fields<A
|
||||||
|
|
||||||
if (settings_.doc_root.empty())
|
if (settings_.doc_root.empty())
|
||||||
{
|
{
|
||||||
std::string default_page = "/usr/share/snapserver/index.html";
|
static constexpr auto default_page = "/usr/share/snapserver/index.html";
|
||||||
struct stat buffer;
|
if (utils::file::exists(default_page))
|
||||||
if (stat(default_page.c_str(), &buffer) == 0)
|
|
||||||
path = default_page;
|
path = default_page;
|
||||||
else
|
else
|
||||||
return send(unconfigured());
|
return send(unconfigured());
|
||||||
|
|
34569
server/etc/snapweb/3rd-party/libflac.js
vendored
|
@ -1,5 +0,0 @@
|
||||||
"use strict";
|
|
||||||
let config = {
|
|
||||||
baseUrl: (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=config.js.map
|
|
Before Width: | Height: | Size: 9.4 KiB |
|
@ -1,24 +1,70 @@
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<title>Snapweb Placeholder</title>
|
||||||
<meta name="theme-color" content="#455A64">
|
<style>
|
||||||
<meta name="author" content="Johannes M. Pohl">
|
body {
|
||||||
<meta name="version" content="0.5.0">
|
background: #ffffff;
|
||||||
<meta charset="utf-8">
|
color: rgb(0, 0, 0);
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
font-family: 'Arial', sans-serif;
|
||||||
<link rel="manifest" href="manifest.json">
|
margin: 20px;
|
||||||
<link rel="stylesheet" href="styles.css">
|
font-size: 20px;
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
}
|
||||||
<title>Snapweb</title>
|
|
||||||
<script src="3rd-party/libflac.js"></script>
|
pre {
|
||||||
<script src="config.js"></script>
|
margin: 8px 0px 8px 0px;
|
||||||
<script src="snapstream.js"></script>
|
padding: 20px;
|
||||||
<script src="snapcontrol.js"></script>
|
border-width: 1px;
|
||||||
|
border-style: dotted;
|
||||||
|
border-color: #000000;
|
||||||
|
background-color: #F5F6F7;
|
||||||
|
font-style: italic;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 15px 0px 0px 50px;
|
||||||
|
font-size: 180%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="show"></div>
|
<h1>Snapweb Placeholder</h1>
|
||||||
|
<p>
|
||||||
|
This is a placeholder for Snapweb, to install Snapweb, please follow these steps:
|
||||||
|
<ol>
|
||||||
|
<li>Download Snapweb on the <a href="https://github.com/badaix/snapweb/releases">Snapweb release page</a>. You can
|
||||||
|
either download
|
||||||
|
<ul>
|
||||||
|
<li><b>snapweb.zip</b> and extract it on your Snapcast server machine, e.g. into
|
||||||
|
<tt>/usr/share/snapcast/snapweb</tt> or
|
||||||
|
</li>
|
||||||
|
<li><b>snapweb_x.y.z-1_all.deb</b> and install it with <tt>sudo apt install ./snapweb_x.y.z-1_all.deb</tt> to
|
||||||
|
<tt>/usr/share/snapweb</tt>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Configure the document root (<tt>doc_root</tt>, see below) in the snapserver configuration file
|
||||||
|
<tt>snapserver.conf</tt> — usually
|
||||||
|
located in <tt>/etc/snapserver.conf</tt> — to the location where snapweb is extracted or installed.
|
||||||
|
</li>
|
||||||
|
<li>Restart the snapserver to activate the changes.</li>
|
||||||
|
</ol>
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
# HTTP RPC #####################################
|
||||||
|
#
|
||||||
|
[http]
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
# serve a website from the doc_root location
|
||||||
|
doc_root = /usr/share/snapserver/snapweb/
|
||||||
|
|
||||||
|
#
|
||||||
|
################################################</pre>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
Before Width: | Height: | Size: 15 KiB |
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"short_name": "Snapweb",
|
|
||||||
"name": "Snapcast WebApp",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "launcher-icon.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"start_url": "/index.html",
|
|
||||||
"display": "standalone",
|
|
||||||
"categories": ["music"],
|
|
||||||
"description": "Snapcast web client",
|
|
||||||
"theme_color": "#455A64"
|
|
||||||
}
|
|
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 34 KiB |
|
@ -1,903 +0,0 @@
|
||||||
"use strict";
|
|
||||||
class Host {
|
|
||||||
constructor(json) {
|
|
||||||
this.fromJson(json);
|
|
||||||
}
|
|
||||||
fromJson(json) {
|
|
||||||
this.arch = json.arch;
|
|
||||||
this.ip = json.ip;
|
|
||||||
this.mac = json.mac;
|
|
||||||
this.name = json.name;
|
|
||||||
this.os = json.os;
|
|
||||||
}
|
|
||||||
arch = "";
|
|
||||||
ip = "";
|
|
||||||
mac = "";
|
|
||||||
name = "";
|
|
||||||
os = "";
|
|
||||||
}
|
|
||||||
class Client {
|
|
||||||
constructor(json) {
|
|
||||||
this.fromJson(json);
|
|
||||||
}
|
|
||||||
fromJson(json) {
|
|
||||||
this.id = json.id;
|
|
||||||
this.host = new Host(json.host);
|
|
||||||
let jsnapclient = json.snapclient;
|
|
||||||
this.snapclient = { name: jsnapclient.name, protocolVersion: jsnapclient.protocolVersion, version: jsnapclient.version };
|
|
||||||
let jconfig = json.config;
|
|
||||||
this.config = { instance: jconfig.instance, latency: jconfig.latency, name: jconfig.name, volume: { muted: jconfig.volume.muted, percent: jconfig.volume.percent } };
|
|
||||||
this.lastSeen = { sec: json.lastSeen.sec, usec: json.lastSeen.usec };
|
|
||||||
this.connected = Boolean(json.connected);
|
|
||||||
}
|
|
||||||
id = "";
|
|
||||||
host;
|
|
||||||
snapclient;
|
|
||||||
config;
|
|
||||||
lastSeen;
|
|
||||||
connected = false;
|
|
||||||
}
|
|
||||||
class Group {
|
|
||||||
constructor(json) {
|
|
||||||
this.fromJson(json);
|
|
||||||
}
|
|
||||||
fromJson(json) {
|
|
||||||
this.name = json.name;
|
|
||||||
this.id = json.id;
|
|
||||||
this.stream_id = json.stream_id;
|
|
||||||
this.muted = Boolean(json.muted);
|
|
||||||
for (let client of json.clients)
|
|
||||||
this.clients.push(new Client(client));
|
|
||||||
}
|
|
||||||
name = "";
|
|
||||||
id = "";
|
|
||||||
stream_id = "";
|
|
||||||
muted = false;
|
|
||||||
clients = [];
|
|
||||||
getClient(id) {
|
|
||||||
for (let client of this.clients) {
|
|
||||||
if (client.id == id)
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class Metadata {
|
|
||||||
constructor(json) {
|
|
||||||
this.fromJson(json);
|
|
||||||
}
|
|
||||||
fromJson(json) {
|
|
||||||
this.title = json.title;
|
|
||||||
this.artist = json.artist;
|
|
||||||
this.album = json.album;
|
|
||||||
this.artUrl = json.artUrl;
|
|
||||||
this.duration = json.duration;
|
|
||||||
}
|
|
||||||
title;
|
|
||||||
artist;
|
|
||||||
album;
|
|
||||||
artUrl;
|
|
||||||
duration;
|
|
||||||
}
|
|
||||||
class Properties {
|
|
||||||
constructor(json) {
|
|
||||||
this.fromJson(json);
|
|
||||||
}
|
|
||||||
fromJson(json) {
|
|
||||||
this.loopStatus = json.loopStatus;
|
|
||||||
this.shuffle = json.shuffle;
|
|
||||||
this.volume = json.volume;
|
|
||||||
this.rate = json.rate;
|
|
||||||
this.playbackStatus = json.playbackStatus;
|
|
||||||
this.position = json.position;
|
|
||||||
this.minimumRate = json.minimumRate;
|
|
||||||
this.maximumRate = json.maximumRate;
|
|
||||||
this.canGoNext = Boolean(json.canGoNext);
|
|
||||||
this.canGoPrevious = Boolean(json.canGoPrevious);
|
|
||||||
this.canPlay = Boolean(json.canPlay);
|
|
||||||
this.canPause = Boolean(json.canPause);
|
|
||||||
this.canSeek = Boolean(json.canSeek);
|
|
||||||
this.canControl = Boolean(json.canControl);
|
|
||||||
if (json.metadata != undefined) {
|
|
||||||
this.metadata = new Metadata(json.metadata);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.metadata = new Metadata({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loopStatus;
|
|
||||||
shuffle;
|
|
||||||
volume;
|
|
||||||
rate;
|
|
||||||
playbackStatus;
|
|
||||||
position;
|
|
||||||
minimumRate;
|
|
||||||
maximumRate;
|
|
||||||
canGoNext = false;
|
|
||||||
canGoPrevious = false;
|
|
||||||
canPlay = false;
|
|
||||||
canPause = false;
|
|
||||||
canSeek = false;
|
|
||||||
canControl = false;
|
|
||||||
metadata;
|
|
||||||
}
|
|
||||||
class Stream {
|
|
||||||
constructor(json) {
|
|
||||||
this.fromJson(json);
|
|
||||||
}
|
|
||||||
fromJson(json) {
|
|
||||||
this.id = json.id;
|
|
||||||
this.status = json.status;
|
|
||||||
if (json.properties != undefined) {
|
|
||||||
this.properties = new Properties(json.properties);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.properties = new Properties({});
|
|
||||||
}
|
|
||||||
let juri = json.uri;
|
|
||||||
this.uri = { raw: juri.raw, scheme: juri.scheme, host: juri.host, path: juri.path, fragment: juri.fragment, query: juri.query };
|
|
||||||
}
|
|
||||||
id = "";
|
|
||||||
status = "";
|
|
||||||
uri;
|
|
||||||
properties;
|
|
||||||
}
|
|
||||||
class Server {
|
|
||||||
constructor(json) {
|
|
||||||
if (json)
|
|
||||||
this.fromJson(json);
|
|
||||||
}
|
|
||||||
fromJson(json) {
|
|
||||||
this.groups = [];
|
|
||||||
for (let jgroup of json.groups)
|
|
||||||
this.groups.push(new Group(jgroup));
|
|
||||||
let jsnapserver = json.server.snapserver;
|
|
||||||
this.server = { host: new Host(json.server.host), snapserver: { controlProtocolVersion: jsnapserver.controlProtocolVersion, name: jsnapserver.name, protocolVersion: jsnapserver.protocolVersion, version: jsnapserver.version } };
|
|
||||||
this.streams = [];
|
|
||||||
for (let jstream of json.streams) {
|
|
||||||
this.streams.push(new Stream(jstream));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groups = [];
|
|
||||||
server;
|
|
||||||
streams = [];
|
|
||||||
getClient(id) {
|
|
||||||
for (let group of this.groups) {
|
|
||||||
let client = group.getClient(id);
|
|
||||||
if (client)
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
getGroup(id) {
|
|
||||||
for (let group of this.groups) {
|
|
||||||
if (group.id == id)
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
getStream(id) {
|
|
||||||
for (let stream of this.streams) {
|
|
||||||
if (stream.id == id)
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class SnapControl {
|
|
||||||
constructor(baseUrl) {
|
|
||||||
this.server = new Server();
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
this.msg_id = 0;
|
|
||||||
this.status_req_id = -1;
|
|
||||||
this.connect();
|
|
||||||
}
|
|
||||||
connect() {
|
|
||||||
this.connection = new WebSocket(this.baseUrl + '/jsonrpc');
|
|
||||||
this.connection.onmessage = (msg) => this.onMessage(msg.data);
|
|
||||||
this.connection.onopen = () => { this.status_req_id = this.sendRequest('Server.GetStatus'); };
|
|
||||||
this.connection.onerror = (ev) => { console.error('error:', ev); };
|
|
||||||
this.connection.onclose = () => {
|
|
||||||
console.info('connection lost, reconnecting in 1s');
|
|
||||||
setTimeout(() => this.connect(), 1000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
onNotification(notification) {
|
|
||||||
let stream;
|
|
||||||
switch (notification.method) {
|
|
||||||
case 'Client.OnVolumeChanged':
|
|
||||||
let client = this.getClient(notification.params.id);
|
|
||||||
client.config.volume = notification.params.volume;
|
|
||||||
updateGroupVolume(this.getGroupFromClient(client.id));
|
|
||||||
return true;
|
|
||||||
case 'Client.OnLatencyChanged':
|
|
||||||
this.getClient(notification.params.id).config.latency = notification.params.latency;
|
|
||||||
return false;
|
|
||||||
case 'Client.OnNameChanged':
|
|
||||||
this.getClient(notification.params.id).config.name = notification.params.name;
|
|
||||||
return true;
|
|
||||||
case 'Client.OnConnect':
|
|
||||||
case 'Client.OnDisconnect':
|
|
||||||
this.getClient(notification.params.client.id).fromJson(notification.params.client);
|
|
||||||
return true;
|
|
||||||
case 'Group.OnMute':
|
|
||||||
this.getGroup(notification.params.id).muted = Boolean(notification.params.mute);
|
|
||||||
return true;
|
|
||||||
case 'Group.OnStreamChanged':
|
|
||||||
this.getGroup(notification.params.id).stream_id = notification.params.stream_id;
|
|
||||||
this.updateProperties(notification.params.stream_id);
|
|
||||||
return true;
|
|
||||||
case 'Stream.OnUpdate':
|
|
||||||
stream = this.getStream(notification.params.id);
|
|
||||||
stream.fromJson(notification.params.stream);
|
|
||||||
this.updateProperties(stream.id);
|
|
||||||
return true;
|
|
||||||
case 'Server.OnUpdate':
|
|
||||||
this.server.fromJson(notification.params.server);
|
|
||||||
this.updateProperties(this.getMyStreamId());
|
|
||||||
return true;
|
|
||||||
case 'Stream.OnProperties':
|
|
||||||
stream = this.getStream(notification.params.id);
|
|
||||||
stream.properties.fromJson(notification.params.properties);
|
|
||||||
if (this.getMyStreamId() == stream.id)
|
|
||||||
this.updateProperties(stream.id);
|
|
||||||
return false;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateProperties(stream_id) {
|
|
||||||
if (!('mediaSession' in navigator)) {
|
|
||||||
console.log('updateProperties: mediaSession not supported');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stream_id != this.getMyStreamId()) {
|
|
||||||
console.log('updateProperties: not my stream id: ' + stream_id + ', mine: ' + this.getMyStreamId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let props;
|
|
||||||
let metadata;
|
|
||||||
try {
|
|
||||||
props = this.getStreamFromClient(SnapStream.getClientId()).properties;
|
|
||||||
metadata = this.getStreamFromClient(SnapStream.getClientId()).properties.metadata;
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.log('updateProperties failed: ' + e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// https://developers.google.com/web/updates/2017/02/media-session
|
|
||||||
// https://github.com/googlechrome/samples/tree/gh-pages/media-session
|
|
||||||
// https://googlechrome.github.io/samples/media-session/audio.html
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/MediaSession/setActionHandler#seekto
|
|
||||||
console.log('updateProperties: ', props);
|
|
||||||
let play_state = "none";
|
|
||||||
if (props.playbackStatus != undefined) {
|
|
||||||
if (props.playbackStatus == "playing") {
|
|
||||||
audio.play();
|
|
||||||
play_state = "playing";
|
|
||||||
}
|
|
||||||
else if (props.playbackStatus == "paused") {
|
|
||||||
audio.pause();
|
|
||||||
play_state = "paused";
|
|
||||||
}
|
|
||||||
else if (props.playbackStatus == "stopped") {
|
|
||||||
audio.pause();
|
|
||||||
play_state = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mediaSession = navigator.mediaSession;
|
|
||||||
mediaSession.playbackState = play_state;
|
|
||||||
console.log('updateProperties playbackState: ', navigator.mediaSession.playbackState);
|
|
||||||
// if (props.canGoNext == undefined || !props.canGoNext!)
|
|
||||||
mediaSession.setActionHandler('play', () => {
|
|
||||||
props.canPlay ?
|
|
||||||
this.sendRequest('Stream.Control', { id: stream_id, command: 'play' }) : null;
|
|
||||||
});
|
|
||||||
mediaSession.setActionHandler('pause', () => {
|
|
||||||
props.canPause ?
|
|
||||||
this.sendRequest('Stream.Control', { id: stream_id, command: 'pause' }) : null;
|
|
||||||
});
|
|
||||||
mediaSession.setActionHandler('previoustrack', () => {
|
|
||||||
props.canGoPrevious ?
|
|
||||||
this.sendRequest('Stream.Control', { id: stream_id, command: 'previous' }) : null;
|
|
||||||
});
|
|
||||||
mediaSession.setActionHandler('nexttrack', () => {
|
|
||||||
props.canGoNext ?
|
|
||||||
this.sendRequest('Stream.Control', { id: stream_id, command: 'next' }) : null;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
mediaSession.setActionHandler('stop', () => {
|
|
||||||
props.canControl ?
|
|
||||||
this.sendRequest('Stream.Control', { id: stream_id, command: 'stop' }) : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.log('Warning! The "stop" media session action is not supported.');
|
|
||||||
}
|
|
||||||
let defaultSkipTime = 10; // Time to skip in seconds by default
|
|
||||||
mediaSession.setActionHandler('seekbackward', (event) => {
|
|
||||||
let offset = (event.seekOffset || defaultSkipTime) * -1;
|
|
||||||
if (props.position != undefined)
|
|
||||||
Math.max(props.position + offset, 0);
|
|
||||||
props.canSeek ?
|
|
||||||
this.sendRequest('Stream.Control', { id: stream_id, command: 'seek', params: { 'offset': offset } }) : null;
|
|
||||||
});
|
|
||||||
mediaSession.setActionHandler('seekforward', (event) => {
|
|
||||||
let offset = event.seekOffset || defaultSkipTime;
|
|
||||||
if ((metadata.duration != undefined) && (props.position != undefined))
|
|
||||||
Math.min(props.position + offset, metadata.duration);
|
|
||||||
props.canSeek ?
|
|
||||||
this.sendRequest('Stream.Control', { id: stream_id, command: 'seek', params: { 'offset': offset } }) : null;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
mediaSession.setActionHandler('seekto', (event) => {
|
|
||||||
let position = event.seekTime || 0;
|
|
||||||
if (metadata.duration != undefined)
|
|
||||||
Math.min(position, metadata.duration);
|
|
||||||
props.canSeek ?
|
|
||||||
this.sendRequest('Stream.Control', { id: stream_id, command: 'setPosition', params: { 'position': position } }) : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.log('Warning! The "seekto" media session action is not supported.');
|
|
||||||
}
|
|
||||||
if ((metadata.duration != undefined) && (props.position != undefined) && (props.position <= metadata.duration)) {
|
|
||||||
if ('setPositionState' in mediaSession) {
|
|
||||||
console.log('Updating position state: ' + props.position + '/' + metadata.duration);
|
|
||||||
mediaSession.setPositionState({
|
|
||||||
duration: metadata.duration,
|
|
||||||
playbackRate: 1.0,
|
|
||||||
position: props.position
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
mediaSession.setPositionState({
|
|
||||||
duration: 0,
|
|
||||||
playbackRate: 1.0,
|
|
||||||
position: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log('updateMetadata: ', metadata);
|
|
||||||
// https://github.com/Microsoft/TypeScript/issues/19473
|
|
||||||
let title = metadata.title || "Unknown Title";
|
|
||||||
let artist = (metadata.artist != undefined) ? metadata.artist.join(', ') : "Unknown Artist";
|
|
||||||
let album = metadata.album || "";
|
|
||||||
let artwork = [{ src: 'snapcast-512.png', sizes: '512x512', type: 'image/png' }];
|
|
||||||
if (metadata.artUrl != undefined) {
|
|
||||||
artwork = [
|
|
||||||
{ src: metadata.artUrl, sizes: '96x96', type: 'image/png' },
|
|
||||||
{ src: metadata.artUrl, sizes: '128x128', type: 'image/png' },
|
|
||||||
{ src: metadata.artUrl, sizes: '192x192', type: 'image/png' },
|
|
||||||
{ src: metadata.artUrl, sizes: '256x256', type: 'image/png' },
|
|
||||||
{ src: metadata.artUrl, sizes: '384x384', type: 'image/png' },
|
|
||||||
{ src: metadata.artUrl, sizes: '512x512', type: 'image/png' },
|
|
||||||
];
|
|
||||||
} // || 'snapcast-512.png';
|
|
||||||
console.log('Metadata title: ' + title + ', artist: ' + artist + ', album: ' + album + ", artwork: " + artwork);
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
|
||||||
title: title,
|
|
||||||
artist: artist,
|
|
||||||
album: album,
|
|
||||||
artwork: artwork
|
|
||||||
});
|
|
||||||
// mediaSession.setActionHandler('seekbackward', function () { });
|
|
||||||
// mediaSession.setActionHandler('seekforward', function () { });
|
|
||||||
}
|
|
||||||
getClient(client_id) {
|
|
||||||
let client = this.server.getClient(client_id);
|
|
||||||
if (client == null) {
|
|
||||||
throw new Error(`client ${client_id} was null`);
|
|
||||||
}
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
getGroup(group_id) {
|
|
||||||
let group = this.server.getGroup(group_id);
|
|
||||||
if (group == null) {
|
|
||||||
throw new Error(`group ${group_id} was null`);
|
|
||||||
}
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
getGroupVolume(group, online) {
|
|
||||||
if (group.clients.length == 0)
|
|
||||||
return 0;
|
|
||||||
let group_vol = 0;
|
|
||||||
let client_count = 0;
|
|
||||||
for (let client of group.clients) {
|
|
||||||
if (online && !client.connected)
|
|
||||||
continue;
|
|
||||||
group_vol += client.config.volume.percent;
|
|
||||||
++client_count;
|
|
||||||
}
|
|
||||||
if (client_count == 0)
|
|
||||||
return 0;
|
|
||||||
return group_vol / client_count;
|
|
||||||
}
|
|
||||||
getGroupFromClient(client_id) {
|
|
||||||
for (let group of this.server.groups)
|
|
||||||
for (let client of group.clients)
|
|
||||||
if (client.id == client_id)
|
|
||||||
return group;
|
|
||||||
throw new Error(`group for client ${client_id} was null`);
|
|
||||||
}
|
|
||||||
getStreamFromClient(client_id) {
|
|
||||||
let group = this.getGroupFromClient(client_id);
|
|
||||||
return this.getStream(group.stream_id);
|
|
||||||
}
|
|
||||||
getMyStreamId() {
|
|
||||||
try {
|
|
||||||
let group = this.getGroupFromClient(SnapStream.getClientId());
|
|
||||||
return this.getStream(group.stream_id).id;
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getStream(stream_id) {
|
|
||||||
let stream = this.server.getStream(stream_id);
|
|
||||||
if (stream == null) {
|
|
||||||
throw new Error(`stream ${stream_id} was null`);
|
|
||||||
}
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
setVolume(client_id, percent, mute) {
|
|
||||||
percent = Math.max(0, Math.min(100, percent));
|
|
||||||
let client = this.getClient(client_id);
|
|
||||||
client.config.volume.percent = percent;
|
|
||||||
if (mute != undefined)
|
|
||||||
client.config.volume.muted = mute;
|
|
||||||
this.sendRequest('Client.SetVolume', { id: client_id, volume: { muted: client.config.volume.muted, percent: client.config.volume.percent } });
|
|
||||||
}
|
|
||||||
setClientName(client_id, name) {
|
|
||||||
let client = this.getClient(client_id);
|
|
||||||
let current_name = (client.config.name != "") ? client.config.name : client.host.name;
|
|
||||||
if (name != current_name) {
|
|
||||||
this.sendRequest('Client.SetName', { id: client_id, name: name });
|
|
||||||
client.config.name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setClientLatency(client_id, latency) {
|
|
||||||
let client = this.getClient(client_id);
|
|
||||||
let current_latency = client.config.latency;
|
|
||||||
if (latency != current_latency) {
|
|
||||||
this.sendRequest('Client.SetLatency', { id: client_id, latency: latency });
|
|
||||||
client.config.latency = latency;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deleteClient(client_id) {
|
|
||||||
this.sendRequest('Server.DeleteClient', { id: client_id });
|
|
||||||
this.server.groups.forEach((g, gi) => {
|
|
||||||
g.clients.forEach((c, ci) => {
|
|
||||||
if (c.id == client_id) {
|
|
||||||
this.server.groups[gi].clients.splice(ci, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.server.groups.forEach((g, gi) => {
|
|
||||||
if (g.clients.length == 0) {
|
|
||||||
this.server.groups.splice(gi, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
setStream(group_id, stream_id) {
|
|
||||||
this.getGroup(group_id).stream_id = stream_id;
|
|
||||||
this.updateProperties(stream_id);
|
|
||||||
this.sendRequest('Group.SetStream', { id: group_id, stream_id: stream_id });
|
|
||||||
}
|
|
||||||
setClients(group_id, clients) {
|
|
||||||
this.status_req_id = this.sendRequest('Group.SetClients', { id: group_id, clients: clients });
|
|
||||||
}
|
|
||||||
muteGroup(group_id, mute) {
|
|
||||||
this.getGroup(group_id).muted = mute;
|
|
||||||
this.sendRequest('Group.SetMute', { id: group_id, mute: mute });
|
|
||||||
}
|
|
||||||
sendRequest(method, params) {
|
|
||||||
let msg = {
|
|
||||||
id: ++this.msg_id,
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: method
|
|
||||||
};
|
|
||||||
if (params)
|
|
||||||
msg.params = params;
|
|
||||||
let msgJson = JSON.stringify(msg);
|
|
||||||
console.log("Sending: " + msgJson);
|
|
||||||
this.connection.send(msgJson);
|
|
||||||
return this.msg_id;
|
|
||||||
}
|
|
||||||
onMessage(msg) {
|
|
||||||
let json_msg = JSON.parse(msg);
|
|
||||||
let is_response = (json_msg.id != undefined);
|
|
||||||
console.log("Received " + (is_response ? "response" : "notification") + ", json: " + JSON.stringify(json_msg));
|
|
||||||
if (is_response) {
|
|
||||||
if (json_msg.id == this.status_req_id) {
|
|
||||||
this.server = new Server(json_msg.result.server);
|
|
||||||
this.updateProperties(this.getMyStreamId());
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
let refresh = false;
|
|
||||||
if (Array.isArray(json_msg)) {
|
|
||||||
for (let notification of json_msg) {
|
|
||||||
refresh = this.onNotification(notification) || refresh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
refresh = this.onNotification(json_msg);
|
|
||||||
}
|
|
||||||
// TODO: don't update everything, but only the changed,
|
|
||||||
// e.g. update the values for the volume sliders
|
|
||||||
if (refresh)
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseUrl;
|
|
||||||
connection;
|
|
||||||
server;
|
|
||||||
msg_id;
|
|
||||||
status_req_id;
|
|
||||||
}
|
|
||||||
let snapcontrol;
|
|
||||||
let snapstream = null;
|
|
||||||
let hide_offline = true;
|
|
||||||
let autoplay_done = false;
|
|
||||||
let audio = document.createElement('audio');
|
|
||||||
function autoplayRequested() {
|
|
||||||
return document.location.hash.match(/autoplay/) !== null;
|
|
||||||
}
|
|
||||||
function show() {
|
|
||||||
// Render the page
|
|
||||||
const versionElem = document.getElementsByTagName("meta").namedItem("version");
|
|
||||||
console.log("Snapweb version " + (versionElem ? versionElem.content : "null"));
|
|
||||||
let play_img;
|
|
||||||
if (snapstream) {
|
|
||||||
play_img = 'stop.png';
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
play_img = 'play.png';
|
|
||||||
}
|
|
||||||
let content = "";
|
|
||||||
content += "<div class='navbar'>Snapcast";
|
|
||||||
let serverVersion = snapcontrol.server.server.snapserver.version.split('.');
|
|
||||||
if ((serverVersion.length >= 2) && (+serverVersion[1] >= 21)) {
|
|
||||||
content += " <img src='" + play_img + "' class='play-button' id='play-button'></a>";
|
|
||||||
// Stream became ready and was not playing. If autoplay is requested, start playing.
|
|
||||||
if (!snapstream && !autoplay_done && autoplayRequested()) {
|
|
||||||
autoplay_done = true;
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content += "</div>";
|
|
||||||
content += "<div class='content'>";
|
|
||||||
let server = snapcontrol.server;
|
|
||||||
for (let group of server.groups) {
|
|
||||||
if (hide_offline) {
|
|
||||||
let groupActive = false;
|
|
||||||
for (let client of group.clients) {
|
|
||||||
if (client.connected) {
|
|
||||||
groupActive = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!groupActive)
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Set mute variables
|
|
||||||
let classgroup;
|
|
||||||
let muted;
|
|
||||||
let mute_img;
|
|
||||||
if (group.muted == true) {
|
|
||||||
classgroup = 'group muted';
|
|
||||||
muted = true;
|
|
||||||
mute_img = 'mute_icon.png';
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
classgroup = 'group';
|
|
||||||
muted = false;
|
|
||||||
mute_img = 'speaker_icon.png';
|
|
||||||
}
|
|
||||||
// Start group div
|
|
||||||
content += "<div id='g_" + group.id + "' class='" + classgroup + "'>";
|
|
||||||
// Create stream selection dropdown
|
|
||||||
let streamselect = "<select id='stream_" + group.id + "' onchange='setStream(\"" + group.id + "\")' class='stream'>";
|
|
||||||
for (let i_stream = 0; i_stream < server.streams.length; i_stream++) {
|
|
||||||
let streamselected = "";
|
|
||||||
if (group.stream_id == server.streams[i_stream].id) {
|
|
||||||
streamselected = 'selected';
|
|
||||||
}
|
|
||||||
streamselect += "<option value='" + server.streams[i_stream].id + "' " + streamselected + ">" + server.streams[i_stream].id + ": " + server.streams[i_stream].status + "</option>";
|
|
||||||
}
|
|
||||||
streamselect += "</select>";
|
|
||||||
// Group mute and refresh button
|
|
||||||
content += "<div class='groupheader'>";
|
|
||||||
content += streamselect;
|
|
||||||
// let cover_img: string = server.getStream(group.stream_id)!.properties.metadata.artUrl || "snapcast-512.png";
|
|
||||||
// content += "<img src='" + cover_img + "' class='cover-img' id='cover_" + group.id + "'>";
|
|
||||||
let clientCount = 0;
|
|
||||||
for (let client of group.clients)
|
|
||||||
if (!hide_offline || client.connected)
|
|
||||||
clientCount++;
|
|
||||||
if (clientCount > 1) {
|
|
||||||
let volume = snapcontrol.getGroupVolume(group, hide_offline);
|
|
||||||
// content += "<div class='client'>";
|
|
||||||
content += "<a href=\"javascript:setMuteGroup('" + group.id + "'," + !muted + ");\"><img src='" + mute_img + "' class='mute-button'></a>";
|
|
||||||
content += "<div class='slidergroupdiv'>";
|
|
||||||
content += " <input type='range' draggable='false' min=0 max=100 step=1 id='vol_" + group.id + "' oninput='javascript:setGroupVolume(\"" + group.id + "\")' value=" + volume + " class='slider'>";
|
|
||||||
// content += " <input type='range' min=0 max=100 step=1 id='vol_" + group.id + "' oninput='javascript:setVolume(\"" + client.id + "\"," + client.config.volume.muted + ")' value=" + client.config.volume.percent + " class='" + sliderclass + "'>";
|
|
||||||
content += "</div>";
|
|
||||||
// content += "</div>";
|
|
||||||
}
|
|
||||||
// transparent placeholder edit icon
|
|
||||||
content += "<div class='edit-group-icon'>✎</div>";
|
|
||||||
content += "</div>";
|
|
||||||
content += "<hr class='groupheader-separator'>";
|
|
||||||
// Create clients in group
|
|
||||||
for (let client of group.clients) {
|
|
||||||
if (!client.connected && hide_offline)
|
|
||||||
continue;
|
|
||||||
// Set name and connection state vars, start client div
|
|
||||||
let name;
|
|
||||||
let clas = 'client';
|
|
||||||
if (client.config.name != "") {
|
|
||||||
name = client.config.name;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
name = client.host.name;
|
|
||||||
}
|
|
||||||
if (client.connected == false) {
|
|
||||||
clas = 'client disconnected';
|
|
||||||
}
|
|
||||||
content += "<div id='c_" + client.id + "' class='" + clas + "'>";
|
|
||||||
// Client mute status vars
|
|
||||||
let muted;
|
|
||||||
let mute_img;
|
|
||||||
let sliderclass;
|
|
||||||
if (client.config.volume.muted == true) {
|
|
||||||
muted = true;
|
|
||||||
sliderclass = 'slider muted';
|
|
||||||
mute_img = 'mute_icon.png';
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
sliderclass = 'slider';
|
|
||||||
muted = false;
|
|
||||||
mute_img = 'speaker_icon.png';
|
|
||||||
}
|
|
||||||
// Populate client div
|
|
||||||
content += "<a href=\"javascript:setVolume('" + client.id + "'," + !muted + ");\"><img src='" + mute_img + "' class='mute-button'></a>";
|
|
||||||
content += " <div class='sliderdiv'>";
|
|
||||||
content += " <input type='range' min=0 max=100 step=1 id='vol_" + client.id + "' oninput='javascript:setVolume(\"" + client.id + "\"," + client.config.volume.muted + ")' value=" + client.config.volume.percent + " class='" + sliderclass + "'>";
|
|
||||||
content += " </div>";
|
|
||||||
content += " <span class='edit-icons'>";
|
|
||||||
content += " <a href=\"javascript:openClientSettings('" + client.id + "');\" class='edit-icon'>✎</a>";
|
|
||||||
if (client.connected == false) {
|
|
||||||
content += " <a href=\"javascript:deleteClient('" + client.id + "');\" class='delete-icon'>🗑</a>";
|
|
||||||
content += " </span>";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
content += "</span>";
|
|
||||||
}
|
|
||||||
content += " <div class='name'>" + name + "</div>";
|
|
||||||
content += "</div>";
|
|
||||||
}
|
|
||||||
content += "</div>";
|
|
||||||
}
|
|
||||||
content += "</div>"; // content
|
|
||||||
content += "<div id='client-settings' class='client-settings'>";
|
|
||||||
content += " <div class='client-setting-content'>";
|
|
||||||
content += " <form action='javascript:closeClientSettings()'>";
|
|
||||||
content += " <label for='client-name'>Name</label>";
|
|
||||||
content += " <input type='text' class='client-input' id='client-name' name='client-name' placeholder='Client name..'>";
|
|
||||||
content += " <label for='client-latency'>Latency</label>";
|
|
||||||
content += " <input type='number' class='client-input' min='-10000' max='10000' id='client-latency' name='client-latency' placeholder='Latency in ms..'>";
|
|
||||||
content += " <label for='client-group'>Group</label>";
|
|
||||||
content += " <select id='client-group' class='client-input' name='client-group'>";
|
|
||||||
content += " </select>";
|
|
||||||
content += " <input type='submit' value='Submit'>";
|
|
||||||
content += " </form>";
|
|
||||||
content += " </div>";
|
|
||||||
content += "</div>";
|
|
||||||
// Pad then update page
|
|
||||||
content = content + "<br><br>";
|
|
||||||
document.getElementById('show').innerHTML = content;
|
|
||||||
let playElem = document.getElementById('play-button');
|
|
||||||
playElem.onclick = () => {
|
|
||||||
play();
|
|
||||||
};
|
|
||||||
for (let group of snapcontrol.server.groups) {
|
|
||||||
if (group.clients.length > 1) {
|
|
||||||
let slider = document.getElementById("vol_" + group.id);
|
|
||||||
if (slider == null)
|
|
||||||
continue;
|
|
||||||
slider.addEventListener('pointerdown', function () {
|
|
||||||
groupVolumeEnter(group.id);
|
|
||||||
});
|
|
||||||
slider.addEventListener('touchstart', function () {
|
|
||||||
groupVolumeEnter(group.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function updateGroupVolume(group) {
|
|
||||||
let group_vol = snapcontrol.getGroupVolume(group, hide_offline);
|
|
||||||
let slider = document.getElementById("vol_" + group.id);
|
|
||||||
if (slider == null)
|
|
||||||
return;
|
|
||||||
console.log("updateGroupVolume group: " + group.id + ", volume: " + group_vol + ", slider: " + (slider != null));
|
|
||||||
slider.value = String(group_vol);
|
|
||||||
}
|
|
||||||
let client_volumes;
|
|
||||||
let group_volume;
|
|
||||||
function setGroupVolume(group_id) {
|
|
||||||
let group = snapcontrol.getGroup(group_id);
|
|
||||||
let percent = document.getElementById('vol_' + group.id).valueAsNumber;
|
|
||||||
console.log("setGroupVolume id: " + group.id + ", volume: " + percent);
|
|
||||||
// show()
|
|
||||||
let delta = percent - group_volume;
|
|
||||||
let ratio;
|
|
||||||
if (delta < 0)
|
|
||||||
ratio = (group_volume - percent) / group_volume;
|
|
||||||
else
|
|
||||||
ratio = (percent - group_volume) / (100 - group_volume);
|
|
||||||
for (let i = 0; i < group.clients.length; ++i) {
|
|
||||||
let new_volume = client_volumes[i];
|
|
||||||
if (delta < 0)
|
|
||||||
new_volume -= ratio * client_volumes[i];
|
|
||||||
else
|
|
||||||
new_volume += ratio * (100 - client_volumes[i]);
|
|
||||||
let client_id = group.clients[i].id;
|
|
||||||
// TODO: use batch request to update all client volumes at once
|
|
||||||
snapcontrol.setVolume(client_id, new_volume);
|
|
||||||
let slider = document.getElementById('vol_' + client_id);
|
|
||||||
if (slider)
|
|
||||||
slider.value = String(new_volume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function groupVolumeEnter(group_id) {
|
|
||||||
let group = snapcontrol.getGroup(group_id);
|
|
||||||
let percent = document.getElementById('vol_' + group.id).valueAsNumber;
|
|
||||||
console.log("groupVolumeEnter id: " + group.id + ", volume: " + percent);
|
|
||||||
group_volume = percent;
|
|
||||||
client_volumes = [];
|
|
||||||
for (let i = 0; i < group.clients.length; ++i) {
|
|
||||||
client_volumes.push(group.clients[i].config.volume.percent);
|
|
||||||
}
|
|
||||||
// show()
|
|
||||||
}
|
|
||||||
function setVolume(id, mute) {
|
|
||||||
console.log("setVolume id: " + id + ", mute: " + mute);
|
|
||||||
let percent = document.getElementById('vol_' + id).valueAsNumber;
|
|
||||||
let client = snapcontrol.getClient(id);
|
|
||||||
let needs_update = (mute != client.config.volume.muted);
|
|
||||||
snapcontrol.setVolume(id, percent, mute);
|
|
||||||
let group = snapcontrol.getGroupFromClient(id);
|
|
||||||
updateGroupVolume(group);
|
|
||||||
if (needs_update)
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
function play() {
|
|
||||||
if (snapstream) {
|
|
||||||
snapstream.stop();
|
|
||||||
snapstream = null;
|
|
||||||
audio.pause();
|
|
||||||
audio.src = '';
|
|
||||||
document.body.removeChild(audio);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
snapstream = new SnapStream(config.baseUrl);
|
|
||||||
// User interacted with the page. Let's play audio...
|
|
||||||
document.body.appendChild(audio);
|
|
||||||
audio.src = "10-seconds-of-silence.mp3";
|
|
||||||
audio.loop = true;
|
|
||||||
audio.play().then(() => {
|
|
||||||
snapcontrol.updateProperties(snapcontrol.getMyStreamId());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function setMuteGroup(id, mute) {
|
|
||||||
snapcontrol.muteGroup(id, mute);
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
function setStream(id) {
|
|
||||||
snapcontrol.setStream(id, document.getElementById('stream_' + id).value);
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
function setGroup(client_id, group_id) {
|
|
||||||
console.log("setGroup id: " + client_id + ", group: " + group_id);
|
|
||||||
let server = snapcontrol.server;
|
|
||||||
// Get client group id
|
|
||||||
let current_group = snapcontrol.getGroupFromClient(client_id);
|
|
||||||
// Get
|
|
||||||
// List of target group's clients
|
|
||||||
// OR
|
|
||||||
// List of current group's other clients
|
|
||||||
let send_clients = [];
|
|
||||||
for (let i_group = 0; i_group < server.groups.length; i_group++) {
|
|
||||||
if (server.groups[i_group].id == group_id || (group_id == "new" && server.groups[i_group].id == current_group.id)) {
|
|
||||||
for (let i_client = 0; i_client < server.groups[i_group].clients.length; i_client++) {
|
|
||||||
if (group_id == "new" && server.groups[i_group].clients[i_client].id == client_id) { }
|
|
||||||
else {
|
|
||||||
send_clients[send_clients.length] = server.groups[i_group].clients[i_client].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (group_id == "new")
|
|
||||||
group_id = current_group.id;
|
|
||||||
else
|
|
||||||
send_clients[send_clients.length] = client_id;
|
|
||||||
snapcontrol.setClients(group_id, send_clients);
|
|
||||||
}
|
|
||||||
function setName(id) {
|
|
||||||
// Get current name and lacency
|
|
||||||
let client = snapcontrol.getClient(id);
|
|
||||||
let current_name = (client.config.name != "") ? client.config.name : client.host.name;
|
|
||||||
let current_latency = client.config.latency;
|
|
||||||
let new_name = window.prompt("New Name", current_name);
|
|
||||||
let new_latency = Number(window.prompt("New Latency", String(current_latency)));
|
|
||||||
if (new_name != null)
|
|
||||||
snapcontrol.setClientName(id, new_name);
|
|
||||||
if (new_latency != null)
|
|
||||||
snapcontrol.setClientLatency(id, new_latency);
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
function openClientSettings(id) {
|
|
||||||
let modal = document.getElementById("client-settings");
|
|
||||||
let client = snapcontrol.getClient(id);
|
|
||||||
let current_name = (client.config.name != "") ? client.config.name : client.host.name;
|
|
||||||
let name = document.getElementById("client-name");
|
|
||||||
name.name = id;
|
|
||||||
name.value = current_name;
|
|
||||||
let latency = document.getElementById("client-latency");
|
|
||||||
latency.valueAsNumber = client.config.latency;
|
|
||||||
let group = snapcontrol.getGroupFromClient(id);
|
|
||||||
let group_input = document.getElementById("client-group");
|
|
||||||
while (group_input.length > 0)
|
|
||||||
group_input.remove(0);
|
|
||||||
let group_num = 0;
|
|
||||||
for (let ogroup of snapcontrol.server.groups) {
|
|
||||||
let option = document.createElement('option');
|
|
||||||
option.value = ogroup.id;
|
|
||||||
option.text = "Group " + (group_num + 1) + " (" + ogroup.clients.length + " Clients)";
|
|
||||||
group_input.add(option);
|
|
||||||
if (ogroup == group) {
|
|
||||||
console.log("Selected: " + group_num);
|
|
||||||
group_input.selectedIndex = group_num;
|
|
||||||
}
|
|
||||||
++group_num;
|
|
||||||
}
|
|
||||||
let option = document.createElement('option');
|
|
||||||
option.value = option.text = "new";
|
|
||||||
group_input.add(option);
|
|
||||||
modal.style.display = "block";
|
|
||||||
}
|
|
||||||
function closeClientSettings() {
|
|
||||||
let name = document.getElementById("client-name");
|
|
||||||
let id = name.name;
|
|
||||||
console.log("onclose " + id + ", value: " + name.value);
|
|
||||||
snapcontrol.setClientName(id, name.value);
|
|
||||||
let latency = document.getElementById("client-latency");
|
|
||||||
snapcontrol.setClientLatency(id, latency.valueAsNumber);
|
|
||||||
let group_input = document.getElementById("client-group");
|
|
||||||
let option = group_input.options[group_input.selectedIndex];
|
|
||||||
setGroup(id, option.value);
|
|
||||||
let modal = document.getElementById("client-settings");
|
|
||||||
modal.style.display = "none";
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
function deleteClient(id) {
|
|
||||||
if (confirm('Are you sure?')) {
|
|
||||||
snapcontrol.deleteClient(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.onload = function () {
|
|
||||||
snapcontrol = new SnapControl(config.baseUrl);
|
|
||||||
};
|
|
||||||
// When the user clicks anywhere outside of the modal, close it
|
|
||||||
window.onclick = function (event) {
|
|
||||||
let modal = document.getElementById("client-settings");
|
|
||||||
if (event.target == modal) {
|
|
||||||
modal.style.display = "none";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=snapcontrol.js.map
|
|
|
@ -1,919 +0,0 @@
|
||||||
"use strict";
|
|
||||||
function setCookie(key, value, exdays = -1) {
|
|
||||||
let d = new Date();
|
|
||||||
if (exdays < 0)
|
|
||||||
exdays = 10 * 365;
|
|
||||||
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
|
|
||||||
let expires = "expires=" + d.toUTCString();
|
|
||||||
document.cookie = key + "=" + value + ";" + expires + ";sameSite=Strict;path=/";
|
|
||||||
}
|
|
||||||
function getPersistentValue(key, defaultValue = "") {
|
|
||||||
if (!!window.localStorage) {
|
|
||||||
const value = window.localStorage.getItem(key);
|
|
||||||
if (value !== null) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
window.localStorage.setItem(key, defaultValue);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
// Fallback to cookies if localStorage is not available.
|
|
||||||
let name = key + "=";
|
|
||||||
let decodedCookie = decodeURIComponent(document.cookie);
|
|
||||||
let ca = decodedCookie.split(';');
|
|
||||||
for (let c of ca) {
|
|
||||||
c = c.trimLeft();
|
|
||||||
if (c.indexOf(name) == 0) {
|
|
||||||
return c.substring(name.length, c.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setCookie(key, defaultValue);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
function getChromeVersion() {
|
|
||||||
const raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
|
||||||
return raw ? parseInt(raw[2]) : null;
|
|
||||||
}
|
|
||||||
function uuidv4() {
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
||||||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
class Tv {
|
|
||||||
constructor(sec, usec) {
|
|
||||||
this.sec = sec;
|
|
||||||
this.usec = usec;
|
|
||||||
}
|
|
||||||
setMilliseconds(ms) {
|
|
||||||
this.sec = Math.floor(ms / 1000);
|
|
||||||
this.usec = Math.floor(ms * 1000) % 1000000;
|
|
||||||
}
|
|
||||||
getMilliseconds() {
|
|
||||||
return this.sec * 1000 + this.usec / 1000;
|
|
||||||
}
|
|
||||||
sec = 0;
|
|
||||||
usec = 0;
|
|
||||||
}
|
|
||||||
class BaseMessage {
|
|
||||||
constructor(_buffer) {
|
|
||||||
}
|
|
||||||
deserialize(buffer) {
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
this.type = view.getUint16(0, true);
|
|
||||||
this.id = view.getUint16(2, true);
|
|
||||||
this.refersTo = view.getUint16(4, true);
|
|
||||||
this.received = new Tv(view.getInt32(6, true), view.getInt32(10, true));
|
|
||||||
this.sent = new Tv(view.getInt32(14, true), view.getInt32(18, true));
|
|
||||||
this.size = view.getUint32(22, true);
|
|
||||||
}
|
|
||||||
serialize() {
|
|
||||||
this.size = 26 + this.getSize();
|
|
||||||
let buffer = new ArrayBuffer(this.size);
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
view.setUint16(0, this.type, true);
|
|
||||||
view.setUint16(2, this.id, true);
|
|
||||||
view.setUint16(4, this.refersTo, true);
|
|
||||||
view.setInt32(6, this.sent.sec, true);
|
|
||||||
view.setInt32(10, this.sent.usec, true);
|
|
||||||
view.setInt32(14, this.received.sec, true);
|
|
||||||
view.setInt32(18, this.received.usec, true);
|
|
||||||
view.setUint32(22, this.size, true);
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
getSize() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
type = 0;
|
|
||||||
id = 0;
|
|
||||||
refersTo = 0;
|
|
||||||
received = new Tv(0, 0);
|
|
||||||
sent = new Tv(0, 0);
|
|
||||||
size = 0;
|
|
||||||
}
|
|
||||||
class CodecMessage extends BaseMessage {
|
|
||||||
constructor(buffer) {
|
|
||||||
super(buffer);
|
|
||||||
this.payload = new ArrayBuffer(0);
|
|
||||||
if (buffer) {
|
|
||||||
this.deserialize(buffer);
|
|
||||||
}
|
|
||||||
this.type = 1;
|
|
||||||
}
|
|
||||||
deserialize(buffer) {
|
|
||||||
super.deserialize(buffer);
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
let codecSize = view.getInt32(26, true);
|
|
||||||
let decoder = new TextDecoder("utf-8");
|
|
||||||
this.codec = decoder.decode(buffer.slice(30, 30 + codecSize));
|
|
||||||
let payloadSize = view.getInt32(30 + codecSize, true);
|
|
||||||
console.log("payload size: " + payloadSize);
|
|
||||||
this.payload = buffer.slice(34 + codecSize, 34 + codecSize + payloadSize);
|
|
||||||
console.log("payload: " + this.payload);
|
|
||||||
}
|
|
||||||
codec = "";
|
|
||||||
payload;
|
|
||||||
}
|
|
||||||
class TimeMessage extends BaseMessage {
|
|
||||||
constructor(buffer) {
|
|
||||||
super(buffer);
|
|
||||||
if (buffer) {
|
|
||||||
this.deserialize(buffer);
|
|
||||||
}
|
|
||||||
this.type = 4;
|
|
||||||
}
|
|
||||||
deserialize(buffer) {
|
|
||||||
super.deserialize(buffer);
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
this.latency = new Tv(view.getInt32(26, true), view.getInt32(30, true));
|
|
||||||
}
|
|
||||||
serialize() {
|
|
||||||
let buffer = super.serialize();
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
view.setInt32(26, this.latency.sec, true);
|
|
||||||
view.setInt32(30, this.latency.usec, true);
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
getSize() {
|
|
||||||
return 8;
|
|
||||||
}
|
|
||||||
latency = new Tv(0, 0);
|
|
||||||
}
|
|
||||||
class JsonMessage extends BaseMessage {
|
|
||||||
constructor(buffer) {
|
|
||||||
super(buffer);
|
|
||||||
if (buffer) {
|
|
||||||
this.deserialize(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deserialize(buffer) {
|
|
||||||
super.deserialize(buffer);
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
let size = view.getUint32(26, true);
|
|
||||||
let decoder = new TextDecoder();
|
|
||||||
this.json = JSON.parse(decoder.decode(buffer.slice(30, 30 + size)));
|
|
||||||
}
|
|
||||||
serialize() {
|
|
||||||
let buffer = super.serialize();
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
let jsonStr = JSON.stringify(this.json);
|
|
||||||
view.setUint32(26, jsonStr.length, true);
|
|
||||||
let encoder = new TextEncoder();
|
|
||||||
let encoded = encoder.encode(jsonStr);
|
|
||||||
for (let i = 0; i < encoded.length; ++i)
|
|
||||||
view.setUint8(30 + i, encoded[i]);
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
getSize() {
|
|
||||||
let encoder = new TextEncoder();
|
|
||||||
let encoded = encoder.encode(JSON.stringify(this.json));
|
|
||||||
return encoded.length + 4;
|
|
||||||
// return JSON.stringify(this.json).length;
|
|
||||||
}
|
|
||||||
json;
|
|
||||||
}
|
|
||||||
class HelloMessage extends JsonMessage {
|
|
||||||
constructor(buffer) {
|
|
||||||
super(buffer);
|
|
||||||
if (buffer) {
|
|
||||||
this.deserialize(buffer);
|
|
||||||
}
|
|
||||||
this.type = 5;
|
|
||||||
}
|
|
||||||
deserialize(buffer) {
|
|
||||||
super.deserialize(buffer);
|
|
||||||
this.mac = this.json["MAC"];
|
|
||||||
this.hostname = this.json["HostName"];
|
|
||||||
this.version = this.json["Version"];
|
|
||||||
this.clientName = this.json["ClientName"];
|
|
||||||
this.os = this.json["OS"];
|
|
||||||
this.arch = this.json["Arch"];
|
|
||||||
this.instance = this.json["Instance"];
|
|
||||||
this.uniqueId = this.json["ID"];
|
|
||||||
this.snapStreamProtocolVersion = this.json["SnapStreamProtocolVersion"];
|
|
||||||
}
|
|
||||||
serialize() {
|
|
||||||
this.json = { "MAC": this.mac, "HostName": this.hostname, "Version": this.version, "ClientName": this.clientName, "OS": this.os, "Arch": this.arch, "Instance": this.instance, "ID": this.uniqueId, "SnapStreamProtocolVersion": this.snapStreamProtocolVersion };
|
|
||||||
return super.serialize();
|
|
||||||
}
|
|
||||||
mac = "";
|
|
||||||
hostname = "";
|
|
||||||
version = "0.0.0";
|
|
||||||
clientName = "Snapweb";
|
|
||||||
os = "";
|
|
||||||
arch = "web";
|
|
||||||
instance = 1;
|
|
||||||
uniqueId = "";
|
|
||||||
snapStreamProtocolVersion = 2;
|
|
||||||
}
|
|
||||||
class ServerSettingsMessage extends JsonMessage {
|
|
||||||
constructor(buffer) {
|
|
||||||
super(buffer);
|
|
||||||
if (buffer) {
|
|
||||||
this.deserialize(buffer);
|
|
||||||
}
|
|
||||||
this.type = 3;
|
|
||||||
}
|
|
||||||
deserialize(buffer) {
|
|
||||||
super.deserialize(buffer);
|
|
||||||
this.bufferMs = this.json["bufferMs"];
|
|
||||||
this.latency = this.json["latency"];
|
|
||||||
this.volumePercent = this.json["volume"];
|
|
||||||
this.muted = this.json["muted"];
|
|
||||||
}
|
|
||||||
serialize() {
|
|
||||||
this.json = { "bufferMs": this.bufferMs, "latency": this.latency, "volume": this.volumePercent, "muted": this.muted };
|
|
||||||
return super.serialize();
|
|
||||||
}
|
|
||||||
bufferMs = 0;
|
|
||||||
latency = 0;
|
|
||||||
volumePercent = 0;
|
|
||||||
muted = false;
|
|
||||||
}
|
|
||||||
class PcmChunkMessage extends BaseMessage {
|
|
||||||
constructor(buffer, sampleFormat) {
|
|
||||||
super(buffer);
|
|
||||||
this.deserialize(buffer);
|
|
||||||
this.sampleFormat = sampleFormat;
|
|
||||||
this.type = 2;
|
|
||||||
}
|
|
||||||
deserialize(buffer) {
|
|
||||||
super.deserialize(buffer);
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
this.timestamp = new Tv(view.getInt32(26, true), view.getInt32(30, true));
|
|
||||||
// this.payloadSize = view.getUint32(34, true);
|
|
||||||
this.payload = buffer.slice(38); //, this.payloadSize + 38));// , this.payloadSize);
|
|
||||||
// console.log("ts: " + this.timestamp.sec + " " + this.timestamp.usec + ", payload: " + this.payloadSize + ", len: " + this.payload.byteLength);
|
|
||||||
}
|
|
||||||
readFrames(frames) {
|
|
||||||
let frameCnt = frames;
|
|
||||||
let frameSize = this.sampleFormat.frameSize();
|
|
||||||
if (this.idx + frames > this.payloadSize() / frameSize)
|
|
||||||
frameCnt = (this.payloadSize() / frameSize) - this.idx;
|
|
||||||
let begin = this.idx * frameSize;
|
|
||||||
this.idx += frameCnt;
|
|
||||||
let end = begin + frameCnt * frameSize;
|
|
||||||
// console.log("readFrames: " + frames + ", result: " + frameCnt + ", begin: " + begin + ", end: " + end + ", payload: " + this.payload.byteLength);
|
|
||||||
return this.payload.slice(begin, end);
|
|
||||||
}
|
|
||||||
getFrameCount() {
|
|
||||||
return (this.payloadSize() / this.sampleFormat.frameSize());
|
|
||||||
}
|
|
||||||
isEndOfChunk() {
|
|
||||||
return this.idx >= this.getFrameCount();
|
|
||||||
}
|
|
||||||
startMs() {
|
|
||||||
return this.timestamp.getMilliseconds() + 1000 * (this.idx / this.sampleFormat.rate);
|
|
||||||
}
|
|
||||||
duration() {
|
|
||||||
return 1000 * ((this.getFrameCount() - this.idx) / this.sampleFormat.rate);
|
|
||||||
}
|
|
||||||
payloadSize() {
|
|
||||||
return this.payload.byteLength;
|
|
||||||
}
|
|
||||||
clearPayload() {
|
|
||||||
this.payload = new ArrayBuffer(0);
|
|
||||||
}
|
|
||||||
addPayload(buffer) {
|
|
||||||
let payload = new ArrayBuffer(this.payload.byteLength + buffer.byteLength);
|
|
||||||
let view = new DataView(payload);
|
|
||||||
let viewOld = new DataView(this.payload);
|
|
||||||
let viewNew = new DataView(buffer);
|
|
||||||
for (let i = 0; i < viewOld.byteLength; ++i) {
|
|
||||||
view.setInt8(i, viewOld.getInt8(i));
|
|
||||||
}
|
|
||||||
for (let i = 0; i < viewNew.byteLength; ++i) {
|
|
||||||
view.setInt8(i + viewOld.byteLength, viewNew.getInt8(i));
|
|
||||||
}
|
|
||||||
this.payload = payload;
|
|
||||||
}
|
|
||||||
timestamp = new Tv(0, 0);
|
|
||||||
// payloadSize: number = 0;
|
|
||||||
payload = new ArrayBuffer(0);
|
|
||||||
idx = 0;
|
|
||||||
sampleFormat;
|
|
||||||
}
|
|
||||||
class AudioStream {
|
|
||||||
timeProvider;
|
|
||||||
sampleFormat;
|
|
||||||
bufferMs;
|
|
||||||
constructor(timeProvider, sampleFormat, bufferMs) {
|
|
||||||
this.timeProvider = timeProvider;
|
|
||||||
this.sampleFormat = sampleFormat;
|
|
||||||
this.bufferMs = bufferMs;
|
|
||||||
}
|
|
||||||
chunks = new Array();
|
|
||||||
setVolume(percent, muted) {
|
|
||||||
// let base = 10;
|
|
||||||
this.volume = percent / 100; // (Math.pow(base, percent / 100) - 1) / (base - 1);
|
|
||||||
console.log("setVolume: " + percent + " => " + this.volume + ", muted: " + this.muted);
|
|
||||||
this.muted = muted;
|
|
||||||
}
|
|
||||||
addChunk(chunk) {
|
|
||||||
this.chunks.push(chunk);
|
|
||||||
// let oldest = this.timeProvider.serverNow() - this.chunks[0].timestamp.getMilliseconds();
|
|
||||||
// let newest = this.timeProvider.serverNow() - this.chunks[this.chunks.length - 1].timestamp.getMilliseconds();
|
|
||||||
// console.debug("chunks: " + this.chunks.length + ", oldest: " + oldest.toFixed(2) + ", newest: " + newest.toFixed(2));
|
|
||||||
while (this.chunks.length > 0) {
|
|
||||||
let age = this.timeProvider.serverNow() - this.chunks[0].timestamp.getMilliseconds();
|
|
||||||
// todo: consider buffer ms
|
|
||||||
if (age > 5000 + this.bufferMs) {
|
|
||||||
this.chunks.shift();
|
|
||||||
console.log("Dropping old chunk: " + age.toFixed(2) + ", left: " + this.chunks.length);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getNextBuffer(buffer, playTimeMs) {
|
|
||||||
if (!this.chunk) {
|
|
||||||
this.chunk = this.chunks.shift();
|
|
||||||
}
|
|
||||||
// let age = this.timeProvider.serverTime(this.playTime * 1000) - startMs;
|
|
||||||
let frames = buffer.length;
|
|
||||||
// console.debug("getNextBuffer: " + frames + ", play time: " + playTimeMs.toFixed(2));
|
|
||||||
let left = new Float32Array(frames);
|
|
||||||
let right = new Float32Array(frames);
|
|
||||||
let read = 0;
|
|
||||||
let pos = 0;
|
|
||||||
// let volume = this.muted ? 0 : this.volume;
|
|
||||||
let serverPlayTimeMs = this.timeProvider.serverTime(playTimeMs);
|
|
||||||
if (this.chunk) {
|
|
||||||
let age = serverPlayTimeMs - this.chunk.startMs(); // - 500;
|
|
||||||
let reqChunkDuration = frames / this.sampleFormat.msRate();
|
|
||||||
let secs = Math.floor(Date.now() / 1000);
|
|
||||||
if (this.lastLog != secs) {
|
|
||||||
this.lastLog = secs;
|
|
||||||
console.log("age: " + age.toFixed(2) + ", req: " + reqChunkDuration);
|
|
||||||
}
|
|
||||||
if (age < -reqChunkDuration) {
|
|
||||||
console.log("age: " + age.toFixed(2) + " < req: " + reqChunkDuration * -1 + ", chunk.startMs: " + this.chunk.startMs().toFixed(2) + ", timestamp: " + this.chunk.timestamp.getMilliseconds().toFixed(2));
|
|
||||||
console.log("Chunk too young, returning silence");
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (Math.abs(age) > 5) {
|
|
||||||
// We are 5ms apart, do a hard sync, i.e. don't play faster/slower,
|
|
||||||
// but seek to the desired position instead
|
|
||||||
while (this.chunk && age > this.chunk.duration()) {
|
|
||||||
console.log("Chunk too old, dropping (age: " + age.toFixed(2) + " > " + this.chunk.duration().toFixed(2) + ")");
|
|
||||||
this.chunk = this.chunks.shift();
|
|
||||||
if (!this.chunk)
|
|
||||||
break;
|
|
||||||
age = serverPlayTimeMs - this.chunk.startMs();
|
|
||||||
}
|
|
||||||
if (this.chunk) {
|
|
||||||
if (age > 0) {
|
|
||||||
console.log("Fast forwarding " + age.toFixed(2) + "ms");
|
|
||||||
this.chunk.readFrames(Math.floor(age * this.chunk.sampleFormat.msRate()));
|
|
||||||
}
|
|
||||||
else if (age < 0) {
|
|
||||||
console.log("Playing silence " + -age.toFixed(2) + "ms");
|
|
||||||
let silentFrames = Math.floor(-age * this.chunk.sampleFormat.msRate());
|
|
||||||
left.fill(0, 0, silentFrames);
|
|
||||||
right.fill(0, 0, silentFrames);
|
|
||||||
read = silentFrames;
|
|
||||||
pos = silentFrames;
|
|
||||||
}
|
|
||||||
age = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// else if (age > 0.1) {
|
|
||||||
// let rate = age * 0.0005;
|
|
||||||
// rate = 1.0 - Math.min(rate, 0.0005);
|
|
||||||
// console.debug("Age > 0, rate: " + rate);
|
|
||||||
// // we are late (age > 0), this means we are not playing fast enough
|
|
||||||
// // => the real sample rate seems to be lower, we have to drop some frames
|
|
||||||
// this.setRealSampleRate(this.sampleFormat.rate * rate); // 0.9999);
|
|
||||||
// }
|
|
||||||
// else if (age < -0.1) {
|
|
||||||
// let rate = -age * 0.0005;
|
|
||||||
// rate = 1.0 + Math.min(rate, 0.0005);
|
|
||||||
// console.debug("Age < 0, rate: " + rate);
|
|
||||||
// // we are early (age > 0), this means we are playing too fast
|
|
||||||
// // => the real sample rate seems to be higher, we have to insert some frames
|
|
||||||
// this.setRealSampleRate(this.sampleFormat.rate * rate); // 0.9999);
|
|
||||||
// }
|
|
||||||
// else {
|
|
||||||
// this.setRealSampleRate(this.sampleFormat.rate);
|
|
||||||
// }
|
|
||||||
let addFrames = 0;
|
|
||||||
let everyN = 0;
|
|
||||||
if (age > 0.1) {
|
|
||||||
addFrames = Math.ceil(age); // / 5);
|
|
||||||
}
|
|
||||||
else if (age < -0.1) {
|
|
||||||
addFrames = Math.floor(age); // / 5);
|
|
||||||
}
|
|
||||||
// addFrames = -2;
|
|
||||||
let readFrames = frames + addFrames - read;
|
|
||||||
if (addFrames != 0)
|
|
||||||
everyN = Math.ceil((frames + addFrames - read) / (Math.abs(addFrames) + 1));
|
|
||||||
// addFrames = 0;
|
|
||||||
// console.debug("frames: " + frames + ", readFrames: " + readFrames + ", addFrames: " + addFrames + ", everyN: " + everyN);
|
|
||||||
while ((read < readFrames) && this.chunk) {
|
|
||||||
let pcmChunk = this.chunk;
|
|
||||||
let pcmBuffer = pcmChunk.readFrames(readFrames - read);
|
|
||||||
let payload = new Int16Array(pcmBuffer);
|
|
||||||
// console.debug("readFrames: " + (frames - read) + ", read: " + pcmBuffer.byteLength + ", payload: " + payload.length);
|
|
||||||
// read += (pcmBuffer.byteLength / this.sampleFormat.frameSize());
|
|
||||||
for (let i = 0; i < payload.length; i += 2) {
|
|
||||||
read++;
|
|
||||||
left[pos] = (payload[i] / 32768); // * volume;
|
|
||||||
right[pos] = (payload[i + 1] / 32768); // * volume;
|
|
||||||
if ((everyN != 0) && (read % everyN == 0)) {
|
|
||||||
if (addFrames > 0) {
|
|
||||||
pos--;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
left[pos + 1] = left[pos];
|
|
||||||
right[pos + 1] = right[pos];
|
|
||||||
pos++;
|
|
||||||
// console.log("Add: " + pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pos++;
|
|
||||||
}
|
|
||||||
if (pcmChunk.isEndOfChunk()) {
|
|
||||||
this.chunk = this.chunks.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (addFrames != 0)
|
|
||||||
console.debug("Pos: " + pos + ", frames: " + frames + ", add: " + addFrames + ", everyN: " + everyN);
|
|
||||||
if (read == readFrames)
|
|
||||||
read = frames;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (read < frames) {
|
|
||||||
console.log("Failed to get chunk, read: " + read + "/" + frames + ", chunks left: " + this.chunks.length);
|
|
||||||
left.fill(0, pos);
|
|
||||||
right.fill(0, pos);
|
|
||||||
}
|
|
||||||
// copyToChannel is not supported by Safari
|
|
||||||
buffer.getChannelData(0).set(left);
|
|
||||||
buffer.getChannelData(1).set(right);
|
|
||||||
}
|
|
||||||
// setRealSampleRate(sampleRate: number) {
|
|
||||||
// if (sampleRate == this.sampleFormat.rate) {
|
|
||||||
// this.correctAfterXFrames = 0;
|
|
||||||
// }
|
|
||||||
// else {
|
|
||||||
// this.correctAfterXFrames = Math.ceil((this.sampleFormat.rate / sampleRate) / (this.sampleFormat.rate / sampleRate - 1.));
|
|
||||||
// console.debug("setRealSampleRate: " + sampleRate + ", correct after X: " + this.correctAfterXFrames);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
chunk = undefined;
|
|
||||||
volume = 1;
|
|
||||||
muted = false;
|
|
||||||
lastLog = 0;
|
|
||||||
}
|
|
||||||
class TimeProvider {
|
|
||||||
constructor(ctx = undefined) {
|
|
||||||
if (ctx) {
|
|
||||||
this.setAudioContext(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setAudioContext(ctx) {
|
|
||||||
this.ctx = ctx;
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
reset() {
|
|
||||||
this.diffBuffer.length = 0;
|
|
||||||
this.diff = 0;
|
|
||||||
}
|
|
||||||
setDiff(c2s, s2c) {
|
|
||||||
if (this.now() == 0) {
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (this.diffBuffer.push((c2s - s2c) / 2) > 100)
|
|
||||||
this.diffBuffer.shift();
|
|
||||||
let sorted = [...this.diffBuffer];
|
|
||||||
sorted.sort();
|
|
||||||
this.diff = sorted[Math.floor(sorted.length / 2)];
|
|
||||||
}
|
|
||||||
// console.debug("c2s: " + c2s.toFixed(2) + ", s2c: " + s2c.toFixed(2) + ", diff: " + this.diff.toFixed(2) + ", now: " + this.now().toFixed(2) + ", server.now: " + this.serverNow().toFixed(2) + ", win.now: " + window.performance.now().toFixed(2));
|
|
||||||
// console.log("now: " + this.now() + "\t" + this.now() + "\t" + this.now());
|
|
||||||
}
|
|
||||||
now() {
|
|
||||||
if (!this.ctx) {
|
|
||||||
return window.performance.now();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Use the more accurate getOutputTimestamp if available, fallback to ctx.currentTime otherwise.
|
|
||||||
const contextTime = !!this.ctx.getOutputTimestamp ? this.ctx.getOutputTimestamp().contextTime : undefined;
|
|
||||||
return (contextTime !== undefined ? contextTime : this.ctx.currentTime) * 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nowSec() {
|
|
||||||
return this.now() / 1000;
|
|
||||||
}
|
|
||||||
serverNow() {
|
|
||||||
return this.serverTime(this.now());
|
|
||||||
}
|
|
||||||
serverTime(localTimeMs) {
|
|
||||||
return localTimeMs + this.diff;
|
|
||||||
}
|
|
||||||
diffBuffer = new Array();
|
|
||||||
diff = 0;
|
|
||||||
ctx;
|
|
||||||
}
|
|
||||||
class SampleFormat {
|
|
||||||
rate = 48000;
|
|
||||||
channels = 2;
|
|
||||||
bits = 16;
|
|
||||||
msRate() {
|
|
||||||
return this.rate / 1000;
|
|
||||||
}
|
|
||||||
toString() {
|
|
||||||
return this.rate + ":" + this.bits + ":" + this.channels;
|
|
||||||
}
|
|
||||||
sampleSize() {
|
|
||||||
if (this.bits == 24) {
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
return this.bits / 8;
|
|
||||||
}
|
|
||||||
frameSize() {
|
|
||||||
return this.channels * this.sampleSize();
|
|
||||||
}
|
|
||||||
durationMs(bytes) {
|
|
||||||
return (bytes / this.frameSize()) * this.msRate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class Decoder {
|
|
||||||
setHeader(_buffer) {
|
|
||||||
return new SampleFormat();
|
|
||||||
}
|
|
||||||
decode(_chunk) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class OpusDecoder extends Decoder {
|
|
||||||
setHeader(buffer) {
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
let ID_OPUS = 0x4F505553;
|
|
||||||
if (buffer.byteLength < 12) {
|
|
||||||
console.error("Opus header too small: " + buffer.byteLength);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
else if (view.getUint32(0, true) != ID_OPUS) {
|
|
||||||
console.error("Opus header too small: " + buffer.byteLength);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let format = new SampleFormat();
|
|
||||||
format.rate = view.getUint32(4, true);
|
|
||||||
format.bits = view.getUint16(8, true);
|
|
||||||
format.channels = view.getUint16(10, true);
|
|
||||||
console.log("Opus samplerate: " + format.toString());
|
|
||||||
return format;
|
|
||||||
}
|
|
||||||
decode(_chunk) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class FlacDecoder extends Decoder {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.decoder = Flac.create_libflac_decoder(true);
|
|
||||||
if (this.decoder) {
|
|
||||||
let init_status = Flac.init_decoder_stream(this.decoder, this.read_callback_fn.bind(this), this.write_callback_fn.bind(this), this.error_callback_fn.bind(this), this.metadata_callback_fn.bind(this), false);
|
|
||||||
console.log("Flac init: " + init_status);
|
|
||||||
Flac.setOptions(this.decoder, { analyseSubframes: true, analyseResiduals: true });
|
|
||||||
}
|
|
||||||
this.sampleFormat = new SampleFormat();
|
|
||||||
this.flacChunk = new ArrayBuffer(0);
|
|
||||||
// this.pcmChunk = new PcmChunkMessage();
|
|
||||||
// Flac.setOptions(this.decoder, {analyseSubframes: analyse_frames, analyseResiduals: analyse_residuals});
|
|
||||||
// flac_ok &= init_status == 0;
|
|
||||||
// console.log("flac init : " + flac_ok);//DEBUG
|
|
||||||
}
|
|
||||||
decode(chunk) {
|
|
||||||
// console.log("Flac decode: " + chunk.payload.byteLength);
|
|
||||||
this.flacChunk = chunk.payload.slice(0);
|
|
||||||
this.pcmChunk = chunk;
|
|
||||||
this.pcmChunk.clearPayload();
|
|
||||||
this.cacheInfo = { cachedBlocks: 0, isCachedChunk: true };
|
|
||||||
// console.log("Flac len: " + this.flacChunk.byteLength);
|
|
||||||
while (this.flacChunk.byteLength && Flac.FLAC__stream_decoder_process_single(this.decoder)) {
|
|
||||||
Flac.FLAC__stream_decoder_get_state(this.decoder);
|
|
||||||
// let state = Flac.FLAC__stream_decoder_get_state(this.decoder);
|
|
||||||
// console.log("State: " + state);
|
|
||||||
}
|
|
||||||
// console.log("Pcm payload: " + this.pcmChunk!.payloadSize());
|
|
||||||
if (this.cacheInfo.cachedBlocks > 0) {
|
|
||||||
let diffMs = this.cacheInfo.cachedBlocks / this.sampleFormat.msRate();
|
|
||||||
// console.log("Cached: " + this.cacheInfo.cachedBlocks + ", " + diffMs + "ms");
|
|
||||||
this.pcmChunk.timestamp.setMilliseconds(this.pcmChunk.timestamp.getMilliseconds() - diffMs);
|
|
||||||
}
|
|
||||||
return this.pcmChunk;
|
|
||||||
}
|
|
||||||
read_callback_fn(bufferSize) {
|
|
||||||
// console.log(' decode read callback, buffer bytes max=', bufferSize);
|
|
||||||
if (this.header) {
|
|
||||||
console.log(" header: " + this.header.byteLength);
|
|
||||||
let data = new Uint8Array(this.header);
|
|
||||||
this.header = null;
|
|
||||||
return { buffer: data, readDataLength: data.byteLength, error: false };
|
|
||||||
}
|
|
||||||
else if (this.flacChunk) {
|
|
||||||
// console.log(" flacChunk: " + this.flacChunk.byteLength);
|
|
||||||
// a fresh read => next call to write will not be from cached data
|
|
||||||
this.cacheInfo.isCachedChunk = false;
|
|
||||||
let data = new Uint8Array(this.flacChunk.slice(0, Math.min(bufferSize, this.flacChunk.byteLength)));
|
|
||||||
this.flacChunk = this.flacChunk.slice(data.byteLength);
|
|
||||||
return { buffer: data, readDataLength: data.byteLength, error: false };
|
|
||||||
}
|
|
||||||
return { buffer: new Uint8Array(0), readDataLength: 0, error: false };
|
|
||||||
}
|
|
||||||
write_callback_fn(data, frameInfo) {
|
|
||||||
// console.log(" write frame metadata: " + frameInfo + ", len: " + data.length);
|
|
||||||
if (this.cacheInfo.isCachedChunk) {
|
|
||||||
// there was no call to read, so it's some cached data
|
|
||||||
this.cacheInfo.cachedBlocks += frameInfo.blocksize;
|
|
||||||
}
|
|
||||||
let payload = new ArrayBuffer((frameInfo.bitsPerSample / 8) * frameInfo.channels * frameInfo.blocksize);
|
|
||||||
let view = new DataView(payload);
|
|
||||||
for (let channel = 0; channel < frameInfo.channels; ++channel) {
|
|
||||||
let channelData = new DataView(data[channel].buffer, 0, data[channel].buffer.byteLength);
|
|
||||||
// console.log("channelData: " + channelData.byteLength + ", blocksize: " + frameInfo.blocksize);
|
|
||||||
for (let i = 0; i < frameInfo.blocksize; ++i) {
|
|
||||||
view.setInt16(2 * (frameInfo.channels * i + channel), channelData.getInt16(2 * i, true), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.pcmChunk.addPayload(payload);
|
|
||||||
// console.log("write: " + payload.byteLength + ", len: " + this.pcmChunk!.payloadSize());
|
|
||||||
}
|
|
||||||
/** @memberOf decode */
|
|
||||||
metadata_callback_fn(data) {
|
|
||||||
console.info('meta data: ', data);
|
|
||||||
// let view = new DataView(data);
|
|
||||||
this.sampleFormat.rate = data.sampleRate;
|
|
||||||
this.sampleFormat.channels = data.channels;
|
|
||||||
this.sampleFormat.bits = data.bitsPerSample;
|
|
||||||
console.log("metadata_callback_fn, sampleformat: " + this.sampleFormat.toString());
|
|
||||||
}
|
|
||||||
/** @memberOf decode */
|
|
||||||
error_callback_fn(err, errMsg) {
|
|
||||||
console.error('decode error callback', err, errMsg);
|
|
||||||
}
|
|
||||||
setHeader(buffer) {
|
|
||||||
this.header = buffer.slice(0);
|
|
||||||
Flac.FLAC__stream_decoder_process_until_end_of_metadata(this.decoder);
|
|
||||||
return this.sampleFormat;
|
|
||||||
}
|
|
||||||
sampleFormat;
|
|
||||||
decoder;
|
|
||||||
header = null;
|
|
||||||
flacChunk;
|
|
||||||
pcmChunk;
|
|
||||||
cacheInfo = { isCachedChunk: false, cachedBlocks: 0 };
|
|
||||||
}
|
|
||||||
class PlayBuffer {
|
|
||||||
constructor(buffer, playTime, source, destination) {
|
|
||||||
this.buffer = buffer;
|
|
||||||
this.playTime = playTime;
|
|
||||||
this.source = source;
|
|
||||||
this.source.buffer = this.buffer;
|
|
||||||
this.source.connect(destination);
|
|
||||||
this.onended = (_playBuffer) => { };
|
|
||||||
}
|
|
||||||
onended;
|
|
||||||
start() {
|
|
||||||
this.source.onended = () => {
|
|
||||||
this.onended(this);
|
|
||||||
};
|
|
||||||
this.source.start(this.playTime);
|
|
||||||
}
|
|
||||||
buffer;
|
|
||||||
playTime;
|
|
||||||
source;
|
|
||||||
num = 0;
|
|
||||||
}
|
|
||||||
class PcmDecoder extends Decoder {
|
|
||||||
setHeader(buffer) {
|
|
||||||
let sampleFormat = new SampleFormat();
|
|
||||||
let view = new DataView(buffer);
|
|
||||||
sampleFormat.channels = view.getUint16(22, true);
|
|
||||||
sampleFormat.rate = view.getUint32(24, true);
|
|
||||||
sampleFormat.bits = view.getUint16(34, true);
|
|
||||||
return sampleFormat;
|
|
||||||
}
|
|
||||||
decode(chunk) {
|
|
||||||
return chunk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class SnapStream {
|
|
||||||
constructor(baseUrl) {
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
this.timeProvider = new TimeProvider();
|
|
||||||
if (this.setupAudioContext()) {
|
|
||||||
this.connect();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
alert("Sorry, but the Web Audio API is not supported by your browser");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setupAudioContext() {
|
|
||||||
let AudioContext = window.AudioContext // Default
|
|
||||||
|| window.webkitAudioContext // Safari and old versions of Chrome
|
|
||||||
|| false;
|
|
||||||
if (AudioContext) {
|
|
||||||
let options;
|
|
||||||
options = { latencyHint: "playback", sampleRate: this.sampleFormat ? this.sampleFormat.rate : undefined };
|
|
||||||
const chromeVersion = getChromeVersion();
|
|
||||||
if ((chromeVersion !== null && chromeVersion < 55) || !window.AudioContext) {
|
|
||||||
// Some older browsers won't decode the stream if options are provided.
|
|
||||||
options = undefined;
|
|
||||||
}
|
|
||||||
this.ctx = new AudioContext(options);
|
|
||||||
this.gainNode = this.ctx.createGain();
|
|
||||||
this.gainNode.connect(this.ctx.destination);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Web Audio API is not supported
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
static getClientId() {
|
|
||||||
return getPersistentValue("uniqueId", uuidv4());
|
|
||||||
}
|
|
||||||
connect() {
|
|
||||||
this.streamsocket = new WebSocket(this.baseUrl + '/stream');
|
|
||||||
this.streamsocket.binaryType = "arraybuffer";
|
|
||||||
this.streamsocket.onmessage = (ev) => this.onMessage(ev);
|
|
||||||
this.streamsocket.onopen = () => {
|
|
||||||
console.log("on open");
|
|
||||||
let hello = new HelloMessage();
|
|
||||||
hello.mac = "00:00:00:00:00:00";
|
|
||||||
hello.arch = "web";
|
|
||||||
hello.os = navigator.platform;
|
|
||||||
hello.hostname = "Snapweb client";
|
|
||||||
hello.uniqueId = SnapStream.getClientId();
|
|
||||||
const versionElem = document.getElementsByTagName("meta").namedItem("version");
|
|
||||||
hello.version = versionElem ? versionElem.content : "0.0.0";
|
|
||||||
this.sendMessage(hello);
|
|
||||||
this.syncTime();
|
|
||||||
this.syncHandle = window.setInterval(() => this.syncTime(), 1000);
|
|
||||||
};
|
|
||||||
this.streamsocket.onerror = (ev) => { console.error('error:', ev); };
|
|
||||||
this.streamsocket.onclose = () => {
|
|
||||||
window.clearInterval(this.syncHandle);
|
|
||||||
console.info('connection lost, reconnecting in 1s');
|
|
||||||
setTimeout(() => this.connect(), 1000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
onMessage(msg) {
|
|
||||||
let view = new DataView(msg.data);
|
|
||||||
let type = view.getUint16(0, true);
|
|
||||||
if (type == 1) {
|
|
||||||
let codec = new CodecMessage(msg.data);
|
|
||||||
console.log("Codec: " + codec.codec);
|
|
||||||
if (codec.codec == "flac") {
|
|
||||||
this.decoder = new FlacDecoder();
|
|
||||||
}
|
|
||||||
else if (codec.codec == "pcm") {
|
|
||||||
this.decoder = new PcmDecoder();
|
|
||||||
}
|
|
||||||
else if (codec.codec == "opus") {
|
|
||||||
this.decoder = new OpusDecoder();
|
|
||||||
alert("Codec not supported: " + codec.codec);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
alert("Codec not supported: " + codec.codec);
|
|
||||||
}
|
|
||||||
if (this.decoder) {
|
|
||||||
this.sampleFormat = this.decoder.setHeader(codec.payload);
|
|
||||||
console.log("Sampleformat: " + this.sampleFormat.toString());
|
|
||||||
if ((this.sampleFormat.channels != 2) || (this.sampleFormat.bits != 16)) {
|
|
||||||
alert("Stream must be stereo with 16 bit depth, actual format: " + this.sampleFormat.toString());
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (this.bufferDurationMs != 0) {
|
|
||||||
this.bufferFrameCount = Math.floor(this.bufferDurationMs * this.sampleFormat.msRate());
|
|
||||||
}
|
|
||||||
if (window.AudioContext) {
|
|
||||||
// we are not using webkitAudioContext, so it's safe to setup a new AudioContext with the new samplerate
|
|
||||||
// since this code is not triggered by direct user input, we cannt create a webkitAudioContext here
|
|
||||||
this.stopAudio();
|
|
||||||
this.setupAudioContext();
|
|
||||||
}
|
|
||||||
this.ctx.resume();
|
|
||||||
this.timeProvider.setAudioContext(this.ctx);
|
|
||||||
this.gainNode.gain.value = this.serverSettings.muted ? 0 : this.serverSettings.volumePercent / 100;
|
|
||||||
// this.timeProvider = new TimeProvider(this.ctx);
|
|
||||||
this.stream = new AudioStream(this.timeProvider, this.sampleFormat, this.bufferMs);
|
|
||||||
this.latency = (this.ctx.baseLatency !== undefined ? this.ctx.baseLatency : 0) + (this.ctx.outputLatency !== undefined ? this.ctx.outputLatency : 0);
|
|
||||||
console.log("Base latency: " + this.ctx.baseLatency + ", output latency: " + this.ctx.outputLatency + ", latency: " + this.latency);
|
|
||||||
this.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (type == 2) {
|
|
||||||
let pcmChunk = new PcmChunkMessage(msg.data, this.sampleFormat);
|
|
||||||
if (this.decoder) {
|
|
||||||
let decoded = this.decoder.decode(pcmChunk);
|
|
||||||
if (decoded) {
|
|
||||||
this.stream.addChunk(decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (type == 3) {
|
|
||||||
this.serverSettings = new ServerSettingsMessage(msg.data);
|
|
||||||
this.gainNode.gain.value = this.serverSettings.muted ? 0 : this.serverSettings.volumePercent / 100;
|
|
||||||
this.bufferMs = this.serverSettings.bufferMs - this.serverSettings.latency;
|
|
||||||
console.log("ServerSettings bufferMs: " + this.serverSettings.bufferMs + ", latency: " + this.serverSettings.latency + ", volume: " + this.serverSettings.volumePercent + ", muted: " + this.serverSettings.muted);
|
|
||||||
}
|
|
||||||
else if (type == 4) {
|
|
||||||
if (this.timeProvider) {
|
|
||||||
let time = new TimeMessage(msg.data);
|
|
||||||
this.timeProvider.setDiff(time.latency.getMilliseconds(), this.timeProvider.now() - time.sent.getMilliseconds());
|
|
||||||
}
|
|
||||||
// console.log("Time sec: " + time.latency.sec + ", usec: " + time.latency.usec + ", diff: " + this.timeProvider.diff);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.info("Message not handled, type: " + type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sendMessage(msg) {
|
|
||||||
msg.sent = new Tv(0, 0);
|
|
||||||
msg.sent.setMilliseconds(this.timeProvider.now());
|
|
||||||
msg.id = ++this.msgId;
|
|
||||||
if (this.streamsocket.readyState == this.streamsocket.OPEN) {
|
|
||||||
this.streamsocket.send(msg.serialize());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncTime() {
|
|
||||||
let t = new TimeMessage();
|
|
||||||
t.latency.setMilliseconds(this.timeProvider.now());
|
|
||||||
this.sendMessage(t);
|
|
||||||
// console.log("prepareSource median: " + Math.round(this.median * 10) / 10);
|
|
||||||
}
|
|
||||||
stopAudio() {
|
|
||||||
// if (this.ctx) {
|
|
||||||
// this.ctx.close();
|
|
||||||
// }
|
|
||||||
this.ctx.suspend();
|
|
||||||
while (this.audioBuffers.length > 0) {
|
|
||||||
let buffer = this.audioBuffers.pop();
|
|
||||||
buffer.onended = () => { };
|
|
||||||
buffer.source.stop();
|
|
||||||
}
|
|
||||||
while (this.freeBuffers.length > 0) {
|
|
||||||
this.freeBuffers.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stop() {
|
|
||||||
window.clearInterval(this.syncHandle);
|
|
||||||
this.stopAudio();
|
|
||||||
if ([WebSocket.OPEN, WebSocket.CONNECTING].includes(this.streamsocket.readyState)) {
|
|
||||||
this.streamsocket.onclose = () => { };
|
|
||||||
this.streamsocket.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
play() {
|
|
||||||
this.playTime = this.timeProvider.nowSec() + 0.1;
|
|
||||||
for (let i = 1; i <= this.audioBufferCount; ++i) {
|
|
||||||
this.playNext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
playNext() {
|
|
||||||
let buffer = this.freeBuffers.pop() || this.ctx.createBuffer(this.sampleFormat.channels, this.bufferFrameCount, this.sampleFormat.rate);
|
|
||||||
let playTimeMs = (this.playTime + this.latency) * 1000 - this.bufferMs;
|
|
||||||
this.stream.getNextBuffer(buffer, playTimeMs);
|
|
||||||
let source = this.ctx.createBufferSource();
|
|
||||||
let playBuffer = new PlayBuffer(buffer, this.playTime, source, this.gainNode);
|
|
||||||
this.audioBuffers.push(playBuffer);
|
|
||||||
playBuffer.num = ++this.bufferNum;
|
|
||||||
playBuffer.onended = (buffer) => {
|
|
||||||
// let diff = this.timeProvider.nowSec() - buffer.playTime;
|
|
||||||
this.freeBuffers.push(this.audioBuffers.splice(this.audioBuffers.indexOf(buffer), 1)[0].buffer);
|
|
||||||
// console.debug("PlayBuffer " + playBuffer.num + " ended after: " + (diff * 1000) + ", in flight: " + this.audioBuffers.length);
|
|
||||||
this.playNext();
|
|
||||||
};
|
|
||||||
playBuffer.start();
|
|
||||||
this.playTime += this.bufferFrameCount / this.sampleFormat.rate;
|
|
||||||
}
|
|
||||||
baseUrl;
|
|
||||||
streamsocket;
|
|
||||||
playTime = 0;
|
|
||||||
msgId = 0;
|
|
||||||
bufferDurationMs = 80; // 0;
|
|
||||||
bufferFrameCount = 3844; // 9600; // 2400;//8192;
|
|
||||||
syncHandle = -1;
|
|
||||||
// ageBuffer: Array<number>;
|
|
||||||
audioBuffers = new Array();
|
|
||||||
freeBuffers = new Array();
|
|
||||||
timeProvider;
|
|
||||||
stream;
|
|
||||||
ctx; // | undefined;
|
|
||||||
gainNode;
|
|
||||||
serverSettings;
|
|
||||||
decoder;
|
|
||||||
sampleFormat;
|
|
||||||
// median: number = 0;
|
|
||||||
audioBufferCount = 3;
|
|
||||||
bufferMs = 1000;
|
|
||||||
bufferNum = 0;
|
|
||||||
latency = 0;
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=snapstream.js.map
|
|
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 441 B |
|
@ -1,315 +0,0 @@
|
||||||
body {
|
|
||||||
background-color: rgb(246, 246, 246);
|
|
||||||
color: rgb(255, 255, 255);
|
|
||||||
font-family: 'Arial', sans-serif;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* width */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track */
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #1f1f1f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle */
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle on hover */
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea, button, select, a {
|
|
||||||
-webkit-tap-highlight-color: rgba(0,0,0,0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: #607d8b;
|
|
||||||
z-index: 1; /* Sit on top */
|
|
||||||
padding: 13px;
|
|
||||||
color: white;
|
|
||||||
position: fixed; /* Set the navbar to fixed position */
|
|
||||||
top: 0; /* Position the navbar at the top of the page */
|
|
||||||
width: 100%; /* Full width */
|
|
||||||
font-size: 21px;
|
|
||||||
font-weight: 500;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
right: 34px;
|
|
||||||
top: 5px;
|
|
||||||
height: 40px;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
margin-top: 62px
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
float: none;
|
|
||||||
background-color: white;
|
|
||||||
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.2);
|
|
||||||
clear: both;
|
|
||||||
padding: 8px;
|
|
||||||
margin: 10px 15px 10px 15px;
|
|
||||||
overflow: auto;
|
|
||||||
width: auto;
|
|
||||||
border-radius: 3px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group.muted {
|
|
||||||
opacity: 0.27;
|
|
||||||
}
|
|
||||||
|
|
||||||
.groupheader {
|
|
||||||
/* margin: 10px; */
|
|
||||||
width: auto;
|
|
||||||
height: fit-content;
|
|
||||||
/* padding: 10px; */
|
|
||||||
padding-bottom: 0px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: min-content auto min-content;
|
|
||||||
grid-template-rows: min-content min-content;
|
|
||||||
grid-gap: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.groupheader-separator {
|
|
||||||
height: 1px;
|
|
||||||
margin: 8px 0px;
|
|
||||||
border-width: 0px;
|
|
||||||
color: lightgray;
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stream {
|
|
||||||
color: #686868;
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 1/3;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 0px;
|
|
||||||
width: 150px;
|
|
||||||
font-size: 20px;
|
|
||||||
color: #e3e3e3;
|
|
||||||
-moz-appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slidergroupdiv {
|
|
||||||
/* background: greenyellow; */
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client {
|
|
||||||
/* text-align: left; */
|
|
||||||
/* margin: 10px; */
|
|
||||||
width: auto;
|
|
||||||
height: fit-content;
|
|
||||||
/* padding: 10px; */
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: min-content auto min-content;
|
|
||||||
grid-template-rows: min-content min-content;
|
|
||||||
grid-gap: 0px;
|
|
||||||
/* align-items: center;*/
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .client:hover {
|
|
||||||
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
|
|
||||||
} */
|
|
||||||
|
|
||||||
.client.disconnected {
|
|
||||||
opacity: 0.27;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
color: #686868;
|
|
||||||
user-select: none;
|
|
||||||
/* background: red; */
|
|
||||||
padding-top: 5px;
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 1/3;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editdiv {
|
|
||||||
background: violet;
|
|
||||||
grid-row: 0/4;
|
|
||||||
grid-column: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-icon {
|
|
||||||
color: #686868;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-icon {
|
|
||||||
color: #ff4081;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-icons {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
grid-row: 1/3;
|
|
||||||
grid-column: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-group-icon {
|
|
||||||
display: flex;
|
|
||||||
color: transparent;
|
|
||||||
align-items: center;
|
|
||||||
grid-row: 1/3;
|
|
||||||
grid-column: 3;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mute-button {
|
|
||||||
color: #686868;
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 1;
|
|
||||||
/* top: 50%;*/
|
|
||||||
height: 25px;
|
|
||||||
width: 25px;
|
|
||||||
padding-left: 10px;
|
|
||||||
padding-right: 10px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
.cover-img {
|
|
||||||
color: #686868;
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 1;
|
|
||||||
height: 50px;
|
|
||||||
width: 50px;
|
|
||||||
padding: 5px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
.sliderdiv {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 2;
|
|
||||||
/* padding-left: 40px; */
|
|
||||||
/* display: inline-block;
|
|
||||||
text-align: left;
|
|
||||||
width: 250px; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
writing-mode: bt-lr;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background: #dbdbdb;
|
|
||||||
outline: none;
|
|
||||||
-webkit-transition: .2s;
|
|
||||||
transition: opacity .2s;
|
|
||||||
height: 2px;
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-moz-range-track {
|
|
||||||
padding: 6px;
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
height: 12px;
|
|
||||||
width: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ff4081;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-moz-range-thumb {
|
|
||||||
height: 12px;
|
|
||||||
width: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ff4081;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider.muted {
|
|
||||||
opacity: 0.27;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-settings {
|
|
||||||
display: none; /* Hidden by default */
|
|
||||||
position: fixed; /* Stay in place */
|
|
||||||
z-index: 1; /* Sit on top */
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%; /* Full width */
|
|
||||||
height: 100%; /* Full height */
|
|
||||||
overflow: auto; /* Enable scroll if needed */
|
|
||||||
background-color: rgb(0,0,0); /* Fallback color */
|
|
||||||
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-setting-content {
|
|
||||||
background-color: #fefefe;
|
|
||||||
color: #686868;
|
|
||||||
margin: 15% auto; /* 15% from the top and centered */
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #888;
|
|
||||||
width: 80%; /* Could be more or less, depending on screen size */
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-input {
|
|
||||||
color: #686868;
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 20px;
|
|
||||||
margin: 8px 0;
|
|
||||||
display: block;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=submit] {
|
|
||||||
width: 100%;
|
|
||||||
background-color: #4CAF50;
|
|
||||||
color: white;
|
|
||||||
padding: 14px 20px;
|
|
||||||
margin: 8px 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=submit]:hover {
|
|
||||||
background-color: #45a049;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.container {
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: #f2f2f2;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|