finished existing functionality rewrite in typescript
This commit is contained in:
parent
d7148137d6
commit
2755804c60
12 changed files with 967 additions and 427 deletions
|
@ -312,6 +312,8 @@ private void GenerateSeriesDetailsFragment(Series series) {
|
|||
foreach (var episode in GetEpisodes(series.Slug)) {
|
||||
sw.Write(
|
||||
@$"<li class='episode' title='{episode.TitleEncoded}'
|
||||
data-slug='{episode.SlugEncoded}'
|
||||
data-sslug='{series.SlugEncoded}'
|
||||
data-cover='/cover/sm/{series.SlugEncoded}.jpg'
|
||||
data-series='{series.TitleEncoded}'
|
||||
data-file='{episode.FileNameEncoded}'>
|
||||
|
|
|
@ -1,373 +1,378 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// initialize audio player
|
||||
var player = new Player();
|
||||
player.initialize();
|
||||
let lastSearch;
|
||||
|
||||
var queue = document.getElementById('queue-container');
|
||||
var overlay = document.getElementById('overlay');
|
||||
queue.getElementsByTagName('h2')[0].addEventListener('click', () => {
|
||||
if (queue.style.height !== 'calc(100% - 6ex)') {
|
||||
queue.style.height = 'calc(100% - 6ex)';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||||
overlay.style.backdropFilter = 'blur(5px)';
|
||||
overlay.style.pointerEvents = 'auto';
|
||||
} else {
|
||||
queue.style.height = '10ex';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.0)';
|
||||
overlay.style.backdropFilter = 'none';
|
||||
overlay.style.pointerEvents = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', () => {
|
||||
queue.style.height = '10ex';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.0)';
|
||||
overlay.style.backdropFilter = 'none';
|
||||
overlay.style.pointerEvents = 'none';
|
||||
});
|
||||
|
||||
// function that gets run on content load
|
||||
var wirePage = (e) => {
|
||||
// wire up episode items to play
|
||||
const episodes = e.detail.elt.getElementsByClassName('episode');
|
||||
for (var episode of episodes) {
|
||||
const title = episode.getAttribute('title');
|
||||
const series = episode.dataset.series;
|
||||
const cover = episode.dataset.cover;
|
||||
const file = episode.dataset.file;
|
||||
var pbtn = episode.getElementsByTagName('a')[0];
|
||||
var qbtn = episode.getElementsByTagName('a')[1];
|
||||
pbtn.addEventListener('click', (e) => {
|
||||
player.playEpisode(cover, series, title, file);
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
// wire up series filter inputs
|
||||
var filter = e.detail.elt.getElementsByClassName('filter').item(0);
|
||||
let debouncer;
|
||||
if (filter) {
|
||||
var allSeries = e.detail.elt.getElementsByTagName('section');
|
||||
// show all series by default when the page loads
|
||||
filter.addEventListener('input', ev => {
|
||||
clearTimeout(debouncer);
|
||||
debouncer = setTimeout(() => {
|
||||
lastSearch = ev.target.value.toLowerCase()
|
||||
var terms = lastSearch.split(' ');
|
||||
for (var series of allSeries) {
|
||||
var match = true;
|
||||
for (var term of terms) {
|
||||
if (term.length > 0 && series.dataset.filter.indexOf(term) < 0) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
||||
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
if (match) {
|
||||
series.classList.remove('no-match');
|
||||
} else {
|
||||
series.classList.add('no-match');
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var Player = /** @class */ (function () {
|
||||
function Player() {
|
||||
var _this = this;
|
||||
var controls = document.getElementById('controls');
|
||||
var timeVolume = document.getElementById('timeVolume');
|
||||
this.nowPlaying = document.getElementById('nowPlaying');
|
||||
this.rewButton = controls.getElementsByTagName('button').item(0);
|
||||
this.playButton = controls.getElementsByTagName('button').item(1);
|
||||
this.playButtonPath = this.playButton.getElementsByTagName('path').item(0);
|
||||
this.ffwButton = controls.getElementsByTagName('button').item(2);
|
||||
this.cover = this.nowPlaying.getElementsByTagName('img').item(0);
|
||||
this.seriesName = this.nowPlaying.getElementsByTagName('span').item(0);
|
||||
this.episodeName = this.nowPlaying.getElementsByTagName('span').item(1);
|
||||
this.timeDisplay = timeVolume.getElementsByTagName('span').item(0);
|
||||
this.volumeSlider = timeVolume.getElementsByTagName('input').item(0);
|
||||
this.progress = document.getElementById('progress');
|
||||
this.ticker = null;
|
||||
this.howl = null;
|
||||
this.episode = null;
|
||||
// initialize to stopped state
|
||||
this.stopPlaybackAndResetUi();
|
||||
// wire up static ui elements
|
||||
this.playButton.addEventListener('click', function () { return _this.playPause(); });
|
||||
this.rewButton.addEventListener('click', function () { return _this.rewind(); });
|
||||
this.ffwButton.addEventListener('click', function () { return _this.fastForward(); });
|
||||
this.volumeSlider.addEventListener('change', function () { return _this.setVolume(); });
|
||||
// wire up mediaSession events
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.setActionHandler('pause', function () { return _this.playPause(); });
|
||||
navigator.mediaSession.setActionHandler('play', function () { return _this.playPause(); });
|
||||
navigator.mediaSession.setActionHandler('stop', function () {
|
||||
return _this.stopPlaybackAndResetUi();
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('seekforward', function () {
|
||||
return _this.fastForward();
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('seekbackward', function () {
|
||||
return _this.rewind();
|
||||
});
|
||||
// don't support previous track yet, queue removes them once finished
|
||||
// wire this up to rewind instead
|
||||
navigator.mediaSession.setActionHandler('previoustrack', function () {
|
||||
return _this.rewind();
|
||||
});
|
||||
}
|
||||
}
|
||||
Player.prototype.setErrorUI = function (message) {
|
||||
this.stopPlaybackAndResetUi();
|
||||
this.seriesName.innerHTML = 'Error playing episode';
|
||||
this.episodeName.innerHTML = message;
|
||||
this.nowPlaying.classList.add('error');
|
||||
};
|
||||
Player.prototype.playEpisode = function (episode) {
|
||||
var _this = this;
|
||||
this.stopPlaybackAndResetUi();
|
||||
this.setPauseButtonUI();
|
||||
this.episode = episode;
|
||||
this.updateNowPlayingUI(true);
|
||||
fetch("/api/r/".concat(episode.file)).then(function (res) { return __awaiter(_this, void 0, void 0, function () {
|
||||
var link;
|
||||
var _this = this;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
if (!res.ok) {
|
||||
this.setErrorUI("API returned ".concat(res.status));
|
||||
return [2 /*return*/];
|
||||
}
|
||||
return [4 /*yield*/, res.json()];
|
||||
case 1:
|
||||
link = _a.sent();
|
||||
this.howl = new Howl({
|
||||
src: "".concat(link.url, "?Authorization=").concat(link.token),
|
||||
html5: true,
|
||||
autoplay: true,
|
||||
volume: this.getVolume(),
|
||||
onload: function () {
|
||||
_this.updateTimeUI();
|
||||
_this.sendMediaSessionMetadata();
|
||||
},
|
||||
onplay: function () {
|
||||
_this.updateNowPlayingUI();
|
||||
_this.sendMediaSessionMetadata();
|
||||
_this.setPauseButtonUI();
|
||||
_this.startTicker();
|
||||
},
|
||||
onpause: function () {
|
||||
_this.setPlayButtonUI();
|
||||
_this.stopTicker();
|
||||
},
|
||||
onend: function () { return _this.stopPlaybackAndResetUi(); },
|
||||
onloaderror: function () { return _this.setErrorUI('Playback error'); },
|
||||
});
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); });
|
||||
};
|
||||
Player.prototype.playPause = function () {
|
||||
if (this.howl && this.howl.playing())
|
||||
this.howl.pause();
|
||||
else if (this.howl && !this.howl.playing())
|
||||
this.howl.play();
|
||||
};
|
||||
Player.prototype.rewind = function () {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
var newPos = this.howl.seek() - 10;
|
||||
if (newPos < 0)
|
||||
newPos = 0;
|
||||
this.howl.seek(newPos);
|
||||
}
|
||||
};
|
||||
Player.prototype.fastForward = function () {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
var newPos = this.howl.seek() + 30;
|
||||
if (newPos > this.howl.duration())
|
||||
newPos = this.howl.duration();
|
||||
this.howl.seek(newPos);
|
||||
}
|
||||
};
|
||||
Player.prototype.setPlayButtonUI = function () {
|
||||
this.playButtonPath.setAttribute('d', 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z');
|
||||
};
|
||||
Player.prototype.setPauseButtonUI = function () {
|
||||
this.playButtonPath.setAttribute('d', 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.25 5C5.56 5 5 5.56 5 6.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C7.5 5.56 6.94 5 6.25 5zm3.5 0c-.69 0-1.25.56-1.25 1.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C11 5.56 10.44 5 9.75 5z');
|
||||
};
|
||||
Player.prototype.getVolume = function () {
|
||||
return parseFloat(this.volumeSlider.value) / 100;
|
||||
};
|
||||
Player.prototype.setVolume = function () {
|
||||
var _a;
|
||||
(_a = this.howl) === null || _a === void 0 ? void 0 : _a.volume(this.getVolume());
|
||||
};
|
||||
Player.prototype.updateNowPlayingUI = function (loading) {
|
||||
if (loading === void 0) { loading = false; }
|
||||
this.nowPlaying.classList.remove('error');
|
||||
if (this.episode) {
|
||||
this.seriesName.innerHTML = loading
|
||||
? 'Loading episode'
|
||||
: this.episode.series.title;
|
||||
this.episodeName.innerHTML = this.episode.title;
|
||||
this.cover.src = loading
|
||||
? '/loading.gif'
|
||||
: this.episode.series.cover;
|
||||
this.nowPlaying.title = "".concat(this.episode.series.title, "\n").concat(this.episode.title);
|
||||
}
|
||||
else {
|
||||
this.seriesName.innerHTML = 'No episode playing';
|
||||
this.episodeName.innerHTML = '';
|
||||
this.cover.src = '/transparent.png';
|
||||
this.nowPlaying.title = 'No episode playing';
|
||||
}
|
||||
if (loading || !this.episode) {
|
||||
this.playButton.disabled = true;
|
||||
this.rewButton.disabled = true;
|
||||
this.ffwButton.disabled = true;
|
||||
}
|
||||
else {
|
||||
this.playButton.disabled = false;
|
||||
this.rewButton.disabled = false;
|
||||
this.ffwButton.disabled = false;
|
||||
}
|
||||
};
|
||||
Player.prototype.sendMediaSessionMetadata = function () {
|
||||
if ('mediaSession' in navigator) {
|
||||
if (this.episode) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.episode.title,
|
||||
album: 'Radiostasis',
|
||||
artist: this.episode.series.title,
|
||||
artwork: [{
|
||||
src: this.episode.series.cover,
|
||||
sizes: '256x256',
|
||||
type: 'image/jpeg',
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 499);
|
||||
});
|
||||
else {
|
||||
navigator.mediaSession.metadata = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
Player.prototype.timeToDisplayString = function (time) {
|
||||
var mins = Math.floor(time / 60);
|
||||
var secs = Math.round(time - (mins * 60));
|
||||
var secStr = secs < 10 ? "0".concat(secs) : secs.toString();
|
||||
return "".concat(mins, ":").concat(secStr);
|
||||
};
|
||||
Player.prototype.updateTimeUI = function () {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
var total = this.howl.duration();
|
||||
var current = this.howl.seek();
|
||||
var pct = "".concat(Math.round(current / total * 1000) / 10, "%");
|
||||
var timeStamp = "".concat(this.timeToDisplayString(current), " / ").concat(this.timeToDisplayString(total));
|
||||
// set the new values if they've changed since the last tick
|
||||
if (this.timeDisplay.innerHTML !== timeStamp)
|
||||
this.timeDisplay.innerHTML = timeStamp;
|
||||
if (this.progress.style.width !== pct)
|
||||
this.progress.style.width = pct;
|
||||
}
|
||||
else {
|
||||
this.timeDisplay.innerHTML = '--:-- / --:--';
|
||||
this.progress.style.width = '0%';
|
||||
}
|
||||
};
|
||||
Player.prototype.startTicker = function () {
|
||||
var _this = this;
|
||||
if (!this.ticker) {
|
||||
this.ticker = setInterval(function () { return _this.updateTimeUI(); }, 500);
|
||||
}
|
||||
};
|
||||
Player.prototype.stopTicker = function () {
|
||||
if (this.ticker) {
|
||||
clearInterval(this.ticker);
|
||||
this.ticker = null;
|
||||
}
|
||||
};
|
||||
Player.prototype.stopPlaybackAndResetUi = function () {
|
||||
var _a;
|
||||
(_a = this.howl) === null || _a === void 0 ? void 0 : _a.unload();
|
||||
this.howl = null;
|
||||
this.episode = null;
|
||||
this.setPlayButtonUI();
|
||||
this.updateNowPlayingUI();
|
||||
this.sendMediaSessionMetadata();
|
||||
this.stopTicker();
|
||||
this.updateTimeUI();
|
||||
};
|
||||
return Player;
|
||||
}());
|
||||
var Playlist = /** @class */ (function () {
|
||||
function Playlist(player) {
|
||||
var _this = this;
|
||||
this.queueExpandedHeight = 'calc(100% - 6ex)';
|
||||
this.player = player;
|
||||
this.queueContainer = document.getElementById('queue-container');
|
||||
this.queueInitialHeight = getComputedStyle(this.queueContainer).height;
|
||||
this.queueTab = this.queueContainer.getElementsByTagName('h2').item(0);
|
||||
this.overlay = document.getElementById('overlay');
|
||||
this.queueTab.addEventListener('click', function () { return _this.toggleQueueUI(); });
|
||||
this.overlay.addEventListener('click', function () { return _this.toggleQueueUI(); });
|
||||
}
|
||||
};
|
||||
|
||||
var main = document.getElementsByTagName('main').item(0);
|
||||
|
||||
document.addEventListener('htmx:historyRestore', (e) => {
|
||||
// repopulate filter on history restore
|
||||
var filter = e.detail.elt.getElementsByClassName('filter').item(0);
|
||||
if (lastSearch && filter) {
|
||||
filter.value = lastSearch;
|
||||
lastSearch = null;
|
||||
Playlist.prototype.toggleQueueUI = function () {
|
||||
if (this.queueContainer.style.height !== this.queueExpandedHeight) {
|
||||
this.queueContainer.style.height = this.queueExpandedHeight;
|
||||
this.overlay.style.backgroundColor = 'rgba(255, 255, 255, 0.5)';
|
||||
this.overlay.style.backdropFilter = 'blur(5px)';
|
||||
this.overlay.style.pointerEvents = 'auto';
|
||||
}
|
||||
else {
|
||||
this.queueContainer.style.height = this.queueInitialHeight;
|
||||
this.overlay.style.backgroundColor = 'rgba(255, 255, 255, 0)';
|
||||
this.overlay.style.backdropFilter = 'none';
|
||||
this.overlay.style.pointerEvents = 'none';
|
||||
}
|
||||
};
|
||||
return Playlist;
|
||||
}());
|
||||
var Radiostasis = /** @class */ (function () {
|
||||
function Radiostasis() {
|
||||
var _this = this;
|
||||
this.lastSearch = null;
|
||||
this.debouncer = null;
|
||||
this.player = new Player();
|
||||
this.playlist = new Playlist(this.player);
|
||||
this.main = document.getElementsByTagName('main').item(0);
|
||||
// set up htmx event handlers
|
||||
document.addEventListener('htmx:historyRestore', function () { return _this.wireLoadedFragment(); });
|
||||
document.getElementsByTagName('main').item(0).addEventListener('htmx:afterSwap', function () { return _this.wireLoadedFragment(); });
|
||||
}
|
||||
// then wire up the page
|
||||
wirePage(e);
|
||||
});
|
||||
|
||||
// set up episode links on content swap
|
||||
main.addEventListener('htmx:afterSwap', wirePage);
|
||||
|
||||
// load page
|
||||
const pname = location.pathname == '/'
|
||||
? '/home.html'
|
||||
: location.pathname + '.html';
|
||||
const path = '/partial' + pname;
|
||||
htmx.ajax('GET', path, 'main');
|
||||
Radiostasis.prototype.wireLoadedFragment = function () {
|
||||
var _this = this;
|
||||
// episode play and queue buttons
|
||||
var episodes = this.main.getElementsByClassName('episode');
|
||||
var _loop_1 = function (i) {
|
||||
var el = episodes.item(i);
|
||||
var episode = {
|
||||
slug: el.dataset.slug,
|
||||
title: el.getAttribute('title'),
|
||||
file: el.dataset.file,
|
||||
series: {
|
||||
slug: el.dataset.sslug,
|
||||
title: el.dataset.series,
|
||||
cover: el.dataset.cover,
|
||||
},
|
||||
};
|
||||
// play button
|
||||
el.getElementsByTagName('a')[0].addEventListener('click', function (e) {
|
||||
_this.player.playEpisode(episode);
|
||||
e.preventDefault();
|
||||
});
|
||||
};
|
||||
for (var i = 0; i < episodes.length; i++) {
|
||||
_loop_1(i);
|
||||
}
|
||||
// series filter input
|
||||
var filter = this.main
|
||||
.getElementsByClassName('filter').item(0);
|
||||
if (filter) {
|
||||
if (this.lastSearch) {
|
||||
filter.value = this.lastSearch;
|
||||
this.lastSearch = null;
|
||||
}
|
||||
var allSeries_1 = this.main.getElementsByTagName('section');
|
||||
filter.addEventListener('input', function () {
|
||||
if (_this.debouncer)
|
||||
clearTimeout(_this.debouncer);
|
||||
_this.debouncer = setTimeout(function () {
|
||||
_this.lastSearch = filter.value.toLowerCase();
|
||||
var terms = _this.lastSearch.split(' ');
|
||||
for (var i = 0; i < allSeries_1.length; i++) {
|
||||
var series = allSeries_1.item(i);
|
||||
var match = true;
|
||||
for (var _i = 0, terms_1 = terms; _i < terms_1.length; _i++) {
|
||||
var term = terms_1[_i];
|
||||
if (term.length > 0 && series.dataset.filter.indexOf(term) < 0) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match)
|
||||
series.classList.remove('no-match');
|
||||
else
|
||||
series.classList.add('no-match');
|
||||
}
|
||||
}, 499);
|
||||
});
|
||||
}
|
||||
};
|
||||
Radiostasis.prototype.initialize = function () {
|
||||
var path = location.pathname == '/'
|
||||
? '/partial/home.html'
|
||||
: "/partial/".concat(location.pathname, ".html");
|
||||
htmx.ajax('GET', path, 'main');
|
||||
};
|
||||
return Radiostasis;
|
||||
}());
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// start application
|
||||
new Radiostasis().initialize();
|
||||
});
|
||||
|
||||
class Playlist {
|
||||
constructor() {
|
||||
this.queue = this.load();
|
||||
}
|
||||
|
||||
save() {
|
||||
window.localStorage.setItem('rs_queue', JSON.stringify(this.queue));
|
||||
}
|
||||
|
||||
load() {
|
||||
var queueJson = window.localStorage.getItem('rs_queue');
|
||||
return queueJson ? JSON.parse(queueJson) : {version: 1};
|
||||
}
|
||||
}
|
||||
|
||||
class Player {
|
||||
constructor() {
|
||||
this.playSvg = 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z';
|
||||
this.pauseSvg = 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.25 5C5.56 5 5 5.56 5 6.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C7.5 5.56 6.94 5 6.25 5zm3.5 0c-.69 0-1.25.56-1.25 1.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C11 5.56 10.44 5 9.75 5z';
|
||||
const controls = document.getElementById('controls');
|
||||
const timeVolume = document.getElementById('timeVolume');
|
||||
this.nowPlaying = document.getElementById('nowPlaying');
|
||||
this.rewButton = controls.getElementsByTagName('button').item(0);
|
||||
this.playButton = controls.getElementsByTagName('button').item(1);
|
||||
this.playButtonPath = this.playButton.getElementsByTagName('path').item(0);
|
||||
this.ffwButton = controls.getElementsByTagName('button').item(2);
|
||||
this.nxtButton = controls.getElementsByTagName('button').item(3);
|
||||
this.cover = this.nowPlaying.getElementsByTagName('img').item(0);
|
||||
this.seriesName = this.nowPlaying.getElementsByTagName('span').item(0);
|
||||
this.episodeName = this.nowPlaying.getElementsByTagName('span').item(1);
|
||||
this.timeDisplay = timeVolume.getElementsByTagName('span').item(0);
|
||||
this.progress = document.getElementById('progress');
|
||||
this.volumeSlider = timeVolume.getElementsByTagName('input').item(0);
|
||||
this.howl = null;
|
||||
this.ticker = null;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.reset();
|
||||
const player = this;
|
||||
this.playButton.addEventListener('click', () => {
|
||||
player.playPause();
|
||||
});
|
||||
this.rewButton.addEventListener('click', () => {
|
||||
player.rewind();
|
||||
});
|
||||
this.ffwButton.addEventListener('click', () => {
|
||||
player.fastForward();
|
||||
});
|
||||
this.volumeSlider.addEventListener('change', () => {
|
||||
this.setVolume();
|
||||
});
|
||||
|
||||
// set up mediasession in browsers that support it
|
||||
if ('mediaSession' in navigator) {
|
||||
const player = this;
|
||||
navigator.mediaSession.setActionHandler('pause',
|
||||
() => player.playPause());
|
||||
navigator.mediaSession.setActionHandler('play',
|
||||
() => player.playPause());
|
||||
navigator.mediaSession.setActionHandler('stop',
|
||||
() => player.playPause());
|
||||
navigator.mediaSession.setActionHandler('seekforward',
|
||||
() => player.fastForward());
|
||||
navigator.mediaSession.setActionHandler('seekbackward',
|
||||
() => player.rewind());
|
||||
navigator.mediaSession.setActionHandler('nexttrack',
|
||||
() => player.fastForward());
|
||||
navigator.mediaSession.setActionHandler('previoustrack',
|
||||
() => player.rewind());
|
||||
}
|
||||
}
|
||||
|
||||
startTicker() {
|
||||
if (!this.ticker) {
|
||||
const player = this;
|
||||
this.ticker = setInterval(() => {
|
||||
player.setTime();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
stopTicker() {
|
||||
if (this.ticker) {
|
||||
clearInterval(this.ticker);
|
||||
this.ticker = null;
|
||||
}
|
||||
}
|
||||
|
||||
disableControls(disabled) {
|
||||
this.playButton.disabled = disabled === true;
|
||||
this.rewButton.disabled = disabled === true;
|
||||
this.ffwButton.disabled = disabled === true;
|
||||
this.nxtButton.disabled = disabled === true || true;
|
||||
}
|
||||
|
||||
setPlay() {
|
||||
this.playButtonPath.setAttribute('d', this.playSvg);
|
||||
}
|
||||
|
||||
setPause() {
|
||||
this.playButtonPath.setAttribute('d', this.pauseSvg);
|
||||
}
|
||||
|
||||
setSeries(series, cover) {
|
||||
this.seriesName.innerHTML = series ?? 'No episode playing';
|
||||
this.seriesName.setAttribute('title', series ?? 'No episode playing');
|
||||
this.cover.setAttribute('src', cover ?? '/transparent.png');
|
||||
}
|
||||
|
||||
setEpisode(episode) {
|
||||
this.episodeName.innerHTML = episode ?? '';
|
||||
this.episodeName.setAttribute('title', episode ?? '');
|
||||
}
|
||||
|
||||
setMetadata(series, episode, cover) {
|
||||
if ('mediaSession' in navigator) {
|
||||
this.metadata = new MediaMetadata({
|
||||
title: episode,
|
||||
album: series,
|
||||
artist: 'Radiostasis',
|
||||
artwork: [{
|
||||
src: cover,
|
||||
sizes: '256x256',
|
||||
type: 'image/jpeg',
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sendMetadata() {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = this.metadata;
|
||||
}
|
||||
}
|
||||
|
||||
clearMetadata() {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
_getDisplayTime(time) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.round(time - (minutes * 60));
|
||||
const secStr = seconds < 10 ? '0' + seconds : seconds;
|
||||
return minutes + ':' + secStr;
|
||||
}
|
||||
|
||||
setTime() {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
const total = this.howl.duration();
|
||||
const current = this.howl.seek();
|
||||
const pct = `${Math.round(current / total * 1000) / 10}%`;
|
||||
const content =
|
||||
this._getDisplayTime(current) + ' / ' + this._getDisplayTime(total);
|
||||
if (this.timeDisplay.innerHTML !== content) {
|
||||
this.timeDisplay.innerHTML = content;
|
||||
}
|
||||
if (this.progress.style.width !== pct) {
|
||||
this.progress.style.width = pct;
|
||||
}
|
||||
} else {
|
||||
this.timeDisplay.innerHTML = '--:-- / --:--';
|
||||
this.progress.style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
getVolume() {
|
||||
return parseFloat(this.volumeSlider.value) / 100;
|
||||
}
|
||||
|
||||
setVolume() {
|
||||
if (this.howl) {
|
||||
this.howl.volume(this.getVolume());
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.howl = null;
|
||||
this.setPlay();
|
||||
this.setSeries();
|
||||
this.setEpisode();
|
||||
this.clearMetadata();
|
||||
this.setTime();
|
||||
this.nowPlaying.classList.remove('error');
|
||||
this.disableControls(true);
|
||||
this.stopTicker();
|
||||
this.metadata = null;
|
||||
}
|
||||
|
||||
error(message) {
|
||||
this.reset();
|
||||
this.setSeries('Error playing episode');
|
||||
this.setEpisode(message);
|
||||
this.nowPlaying.classList.add('error');
|
||||
}
|
||||
|
||||
playPause() {
|
||||
if (this.howl && this.howl.playing()) {
|
||||
this.howl.pause();
|
||||
} else if (this.howl && !this.howl.playing()) {
|
||||
this.howl.play();
|
||||
}
|
||||
}
|
||||
|
||||
rewind() {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
let newPos = this.howl.seek() - 10;
|
||||
if (newPos < 0) newPos = 0;
|
||||
this.howl.seek(newPos);
|
||||
}
|
||||
}
|
||||
|
||||
fastForward() {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
let newPos = this.howl.seek() + 30;
|
||||
if (newPos > this.howl.duration()) newPos = this.howl.duration();
|
||||
this.howl.seek(newPos);
|
||||
}
|
||||
}
|
||||
|
||||
playEpisode(cover, series, episode, file) {
|
||||
if (this.howl) {
|
||||
this.howl.stop();
|
||||
}
|
||||
|
||||
this.reset();
|
||||
this.setPause();
|
||||
this.disableControls(true);
|
||||
this.setSeries('Loading episode', '/loading.gif');
|
||||
this.setEpisode(episode);
|
||||
|
||||
fetch('/api/r/' + file).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
this.error('API returned ' + res.status);
|
||||
return;
|
||||
}
|
||||
const link = await res.json();
|
||||
const player = this;
|
||||
this.howl = new Howl({
|
||||
src: link.url + '?Authorization=' + link.token,
|
||||
html5: true,
|
||||
autoplay: true,
|
||||
volume: this.getVolume(),
|
||||
onload: () => {
|
||||
player.setTime();
|
||||
player.setMetadata(series, episode, cover);
|
||||
},
|
||||
onplay: () => {
|
||||
player.sendMetadata();
|
||||
player.setSeries(series, cover);
|
||||
player.setPause();
|
||||
player.disableControls(false);
|
||||
player.startTicker();
|
||||
},
|
||||
onpause: () => {
|
||||
player.setPlay();
|
||||
player.stopTicker();
|
||||
},
|
||||
onend: () => {
|
||||
player.reset();
|
||||
},
|
||||
onloaderror: () => {
|
||||
player.reset();
|
||||
player.error('Playback error');
|
||||
},
|
||||
});
|
||||
}).catch(err => {
|
||||
console.log(err);
|
||||
this.error('API request error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
"use strict";
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
htmx.on('blah', function () {
|
||||
});
|
||||
});
|
|
@ -330,8 +330,8 @@ footer {
|
|||
|
||||
footer > div:first-child {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid black;
|
||||
height: 0.5ex;
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
#progress {
|
||||
|
|
12
src/Model.ts
Normal file
12
src/Model.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
type Series = {
|
||||
slug: string,
|
||||
title: string,
|
||||
cover: string,
|
||||
}
|
||||
|
||||
type Episode = {
|
||||
series: Series,
|
||||
slug: string,
|
||||
title: string,
|
||||
file: string,
|
||||
}
|
261
src/Player.ts
261
src/Player.ts
|
@ -1,34 +1,247 @@
|
|||
class Player {
|
||||
private readonly playSvg = 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z';
|
||||
private readonly pauseSvg = 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.25 5C5.56 5 5 5.56 5 6.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C7.5 5.56 6.94 5 6.25 5zm3.5 0c-.69 0-1.25.56-1.25 1.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C11 5.56 10.44 5 9.75 5z';
|
||||
|
||||
// hold references to all ui elements
|
||||
private readonly nowPlaying: HTMLElement;
|
||||
private readonly rewButton: HTMLElement;
|
||||
private readonly playButton: HTMLElement;
|
||||
private readonly playButtonPath: HTMLElement;
|
||||
private readonly ffwButton: HTMLElement;
|
||||
private readonly cover: HTMLElement;
|
||||
private readonly rewButton: HTMLButtonElement;
|
||||
private readonly playButton: HTMLButtonElement;
|
||||
private readonly playButtonPath: SVGPathElement;
|
||||
private readonly ffwButton: HTMLButtonElement;
|
||||
private readonly cover: HTMLImageElement;
|
||||
private readonly seriesName: HTMLElement;
|
||||
private readonly episodeName: HTMLElement;
|
||||
private readonly timeDisplay: HTMLElement;
|
||||
private readonly volumeSlider: HTMLElement;
|
||||
private ticker: number | undefined;
|
||||
private readonly volumeSlider: HTMLInputElement;
|
||||
private readonly progress: HTMLElement;
|
||||
|
||||
// ticker timer
|
||||
private ticker: number | null;
|
||||
|
||||
// the actual howler that plays the episodes
|
||||
private howl: Howl | null;
|
||||
|
||||
// currently playing episode
|
||||
private episode: Episode | null;
|
||||
|
||||
public constructor() {
|
||||
setInterval
|
||||
const controls = document.getElementById('controls');
|
||||
const timeVolume = document.getElementById('timeVolume');
|
||||
this.nowPlaying = document.getElementById('nowPlaying');
|
||||
this.rewButton = controls.getElementsByTagName('button').item(0);
|
||||
this.playButton = controls.getElementsByTagName('button').item(1);
|
||||
this.playButtonPath = this.playButton.getElementsByTagName('path').item(0);
|
||||
this.ffwButton = controls.getElementsByTagName('button').item(2);
|
||||
this.cover = this.nowPlaying.getElementsByTagName('img').item(0);
|
||||
this.seriesName = this.nowPlaying.getElementsByTagName('span').item(0);
|
||||
this.episodeName = this.nowPlaying.getElementsByTagName('span').item(1);
|
||||
this.timeDisplay = timeVolume.getElementsByTagName('span').item(0);
|
||||
this.volumeSlider = timeVolume.getElementsByTagName('input').item(0);
|
||||
this.howl = null;
|
||||
const controls = document.getElementById('controls')!;
|
||||
const timeVolume = document.getElementById('timeVolume')!;
|
||||
this.nowPlaying = document.getElementById('nowPlaying')!;
|
||||
this.rewButton = controls.getElementsByTagName('button').item(0)!;
|
||||
this.playButton = controls.getElementsByTagName('button').item(1)!;
|
||||
this.playButtonPath = this.playButton.getElementsByTagName('path').item(0)!;
|
||||
this.ffwButton = controls.getElementsByTagName('button').item(2)!;
|
||||
this.cover = this.nowPlaying.getElementsByTagName('img').item(0)!;
|
||||
this.seriesName = this.nowPlaying.getElementsByTagName('span').item(0)!;
|
||||
this.episodeName = this.nowPlaying.getElementsByTagName('span').item(1)!;
|
||||
this.timeDisplay = timeVolume.getElementsByTagName('span').item(0)!;
|
||||
this.volumeSlider = timeVolume.getElementsByTagName('input').item(0)!;
|
||||
this.progress = document.getElementById('progress')!;
|
||||
this.ticker = null;
|
||||
this.howl = null;
|
||||
this.episode = null;
|
||||
|
||||
// initialize to stopped state
|
||||
this.stopPlaybackAndResetUi();
|
||||
|
||||
// wire up static ui elements
|
||||
this.playButton.addEventListener('click', () => this.playPause());
|
||||
this.rewButton.addEventListener('click', () => this.rewind());
|
||||
this.ffwButton.addEventListener('click', () => this.fastForward());
|
||||
this.volumeSlider.addEventListener('change', () => this.setVolume());
|
||||
|
||||
// wire up mediaSession events
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.setActionHandler('pause', () => this.playPause());
|
||||
navigator.mediaSession.setActionHandler('play', () => this.playPause());
|
||||
navigator.mediaSession.setActionHandler('stop', () =>
|
||||
this.stopPlaybackAndResetUi());
|
||||
navigator.mediaSession.setActionHandler('seekforward', () =>
|
||||
this.fastForward());
|
||||
navigator.mediaSession.setActionHandler('seekbackward', () =>
|
||||
this.rewind());
|
||||
// don't support previous track yet, queue removes them once finished
|
||||
// wire this up to rewind instead
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () =>
|
||||
this.rewind());
|
||||
}
|
||||
}
|
||||
|
||||
private setErrorUI(message: string): void {
|
||||
this.stopPlaybackAndResetUi();
|
||||
this.seriesName.innerHTML = 'Error playing episode';
|
||||
this.episodeName.innerHTML = message;
|
||||
this.nowPlaying.classList.add('error');
|
||||
}
|
||||
|
||||
public playEpisode(episode: Episode) {
|
||||
this.stopPlaybackAndResetUi();
|
||||
this.setPauseButtonUI();
|
||||
this.episode = episode;
|
||||
this.updateNowPlayingUI(true);
|
||||
|
||||
fetch(`/api/r/${episode.file}`).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
this.setErrorUI(`API returned ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const link = await res.json();
|
||||
this.howl = new Howl({
|
||||
src: `${link.url}?Authorization=${link.token}`,
|
||||
html5: true,
|
||||
autoplay: true,
|
||||
volume: this.getVolume(),
|
||||
onload: () => {
|
||||
this.updateTimeUI();
|
||||
this.sendMediaSessionMetadata();
|
||||
},
|
||||
onplay: () => {
|
||||
this.updateNowPlayingUI();
|
||||
this.sendMediaSessionMetadata();
|
||||
this.setPauseButtonUI();
|
||||
this.startTicker();
|
||||
},
|
||||
onpause: () => {
|
||||
this.setPlayButtonUI();
|
||||
this.stopTicker();
|
||||
},
|
||||
onend: () => this.stopPlaybackAndResetUi(),
|
||||
onloaderror: () => this.setErrorUI('Playback error'),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private playPause(): void {
|
||||
if (this.howl && this.howl.playing()) this.howl.pause();
|
||||
else if (this.howl && !this.howl.playing()) this.howl.play();
|
||||
}
|
||||
|
||||
private rewind(): void {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
let newPos = this.howl.seek() - 10;
|
||||
if (newPos < 0) newPos = 0;
|
||||
this.howl.seek(newPos);
|
||||
}
|
||||
}
|
||||
|
||||
private fastForward(): void {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
let newPos = this.howl.seek() + 30;
|
||||
if (newPos > this.howl.duration()) newPos = this.howl.duration();
|
||||
this.howl.seek(newPos);
|
||||
}
|
||||
}
|
||||
|
||||
private setPlayButtonUI(): void {
|
||||
this.playButtonPath.setAttribute('d',
|
||||
'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z');
|
||||
}
|
||||
|
||||
private setPauseButtonUI(): void {
|
||||
this.playButtonPath.setAttribute('d',
|
||||
'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.25 5C5.56 5 5 5.56 5 6.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C7.5 5.56 6.94 5 6.25 5zm3.5 0c-.69 0-1.25.56-1.25 1.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C11 5.56 10.44 5 9.75 5z');
|
||||
}
|
||||
|
||||
private getVolume(): number {
|
||||
return parseFloat(this.volumeSlider.value) / 100;
|
||||
}
|
||||
|
||||
private setVolume(): void {
|
||||
this.howl?.volume(this.getVolume());
|
||||
}
|
||||
|
||||
private updateNowPlayingUI(loading: boolean = false) {
|
||||
this.nowPlaying.classList.remove('error');
|
||||
if (this.episode) {
|
||||
this.seriesName.innerHTML = loading
|
||||
? 'Loading episode'
|
||||
: this.episode.series.title;
|
||||
this.episodeName.innerHTML = this.episode.title;
|
||||
this.cover.src = loading
|
||||
? '/loading.gif'
|
||||
: this.episode.series.cover;
|
||||
this.nowPlaying.title = `${this.episode.series.title}\n${this.episode.title}`;
|
||||
} else {
|
||||
this.seriesName.innerHTML = 'No episode playing';
|
||||
this.episodeName.innerHTML = '';
|
||||
this.cover.src = '/transparent.png';
|
||||
this.nowPlaying.title = 'No episode playing';
|
||||
}
|
||||
|
||||
if (loading || !this.episode) {
|
||||
this.playButton.disabled = true;
|
||||
this.rewButton.disabled = true;
|
||||
this.ffwButton.disabled = true;
|
||||
} else {
|
||||
this.playButton.disabled = false;
|
||||
this.rewButton.disabled = false;
|
||||
this.ffwButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sendMediaSessionMetadata() {
|
||||
if ('mediaSession' in navigator) {
|
||||
if (this.episode) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.episode.title,
|
||||
album: 'Radiostasis',
|
||||
artist: this.episode.series.title,
|
||||
artwork: [{
|
||||
src: this.episode.series.cover,
|
||||
sizes: '256x256',
|
||||
type: 'image/jpeg',
|
||||
}],
|
||||
});
|
||||
} else {
|
||||
navigator.mediaSession.metadata = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private timeToDisplayString(time: number): string {
|
||||
const mins = Math.floor(time / 60);
|
||||
const secs = Math.round(time - (mins * 60));
|
||||
const secStr = secs < 10 ? `0${secs}` : secs.toString();
|
||||
return `${mins}:${secStr}`;
|
||||
}
|
||||
|
||||
private updateTimeUI(): void {
|
||||
if (this.howl && this.howl.state() === 'loaded') {
|
||||
const total = this.howl.duration();
|
||||
const current = this.howl.seek();
|
||||
const pct = `${Math.round(current / total * 1000) / 10}%`;
|
||||
const timeStamp =
|
||||
`${this.timeToDisplayString(current)} / ${this.timeToDisplayString(total)}`;
|
||||
|
||||
// set the new values if they've changed since the last tick
|
||||
if (this.timeDisplay.innerHTML !== timeStamp)
|
||||
this.timeDisplay.innerHTML = timeStamp;
|
||||
if (this.progress.style.width !== pct) this.progress.style.width = pct;
|
||||
} else {
|
||||
this.timeDisplay.innerHTML = '--:-- / --:--';
|
||||
this.progress.style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
private startTicker(): void {
|
||||
if (!this.ticker) {
|
||||
this.ticker = setInterval(() => this.updateTimeUI(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
private stopTicker(): void {
|
||||
if (this.ticker) {
|
||||
clearInterval(this.ticker);
|
||||
this.ticker = null;
|
||||
}
|
||||
}
|
||||
|
||||
private stopPlaybackAndResetUi(): void {
|
||||
this.howl?.unload()
|
||||
this.howl = null;
|
||||
this.episode = null;
|
||||
this.setPlayButtonUI();
|
||||
this.updateNowPlayingUI();
|
||||
this.sendMediaSessionMetadata();
|
||||
this.stopTicker();
|
||||
this.updateTimeUI();
|
||||
}
|
||||
}
|
||||
|
|
33
src/Playlist.ts
Normal file
33
src/Playlist.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
class Playlist {
|
||||
private readonly queueContainer: HTMLElement;
|
||||
private readonly queueTab: HTMLElement;
|
||||
private readonly overlay: HTMLElement;
|
||||
private readonly queueInitialHeight: string;
|
||||
private readonly queueExpandedHeight = 'calc(100% - 6ex)';
|
||||
private readonly player : Player;
|
||||
|
||||
constructor(player: Player) {
|
||||
this.player = player;
|
||||
this.queueContainer = document.getElementById('queue-container')!;
|
||||
this.queueInitialHeight = getComputedStyle(this.queueContainer).height;
|
||||
this.queueTab = this.queueContainer.getElementsByTagName('h2').item(0)!;
|
||||
this.overlay = document.getElementById('overlay')!;
|
||||
|
||||
this.queueTab.addEventListener('click', () => this.toggleQueueUI());
|
||||
this.overlay.addEventListener('click', () => this.toggleQueueUI());
|
||||
}
|
||||
|
||||
private toggleQueueUI(): void {
|
||||
if (this.queueContainer.style.height !== this.queueExpandedHeight) {
|
||||
this.queueContainer.style.height = this.queueExpandedHeight;
|
||||
this.overlay.style.backgroundColor = 'rgba(255, 255, 255, 0.5)';
|
||||
this.overlay.style.backdropFilter = 'blur(5px)';
|
||||
this.overlay.style.pointerEvents = 'auto';
|
||||
} else {
|
||||
this.queueContainer.style.height = this.queueInitialHeight;
|
||||
this.overlay.style.backgroundColor = 'rgba(255, 255, 255, 0)';
|
||||
this.overlay.style.backdropFilter = 'none';
|
||||
this.overlay.style.pointerEvents = 'none';
|
||||
}
|
||||
}
|
||||
}
|
88
src/Radiostasis.ts
Normal file
88
src/Radiostasis.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
class Radiostasis {
|
||||
// audio player
|
||||
private readonly player: Player;
|
||||
private readonly playlist: Playlist;
|
||||
|
||||
// ui element
|
||||
private readonly main: HTMLElement;
|
||||
|
||||
private lastSearch: string | null = null;
|
||||
private debouncer: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.player = new Player();
|
||||
this.playlist = new Playlist(this.player);
|
||||
|
||||
this.main = document.getElementsByTagName('main').item(0)!;
|
||||
|
||||
// set up htmx event handlers
|
||||
document.addEventListener('htmx:historyRestore', () => this.wireLoadedFragment());
|
||||
document.getElementsByTagName('main').item(0)!.addEventListener(
|
||||
'htmx:afterSwap', () => this.wireLoadedFragment());
|
||||
}
|
||||
|
||||
private wireLoadedFragment(): void {
|
||||
// episode play and queue buttons
|
||||
const episodes = this.main.getElementsByClassName('episode');
|
||||
for (let i = 0; i < episodes.length; i++) {
|
||||
const el = <HTMLElement>episodes.item(i)!;
|
||||
const episode: Episode = {
|
||||
slug: el.dataset.slug!,
|
||||
title: el.getAttribute('title')!,
|
||||
file: el.dataset.file!,
|
||||
series: {
|
||||
slug: el.dataset.sslug!,
|
||||
title: el.dataset.series!,
|
||||
cover: el.dataset.cover!,
|
||||
},
|
||||
};
|
||||
|
||||
// play button
|
||||
el.getElementsByTagName('a')[0]!.addEventListener('click', (e) => {
|
||||
this.player.playEpisode(episode);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// TODO: queue button
|
||||
}
|
||||
|
||||
// series filter input
|
||||
const filter = <HTMLInputElement>this.main
|
||||
.getElementsByClassName('filter').item(0);
|
||||
|
||||
if (filter) {
|
||||
if (this.lastSearch) {
|
||||
filter.value = this.lastSearch;
|
||||
this.lastSearch = null;
|
||||
}
|
||||
|
||||
const allSeries = this.main.getElementsByTagName('section');
|
||||
filter.addEventListener('input', () => {
|
||||
if (this.debouncer) clearTimeout(this.debouncer);
|
||||
this.debouncer = setTimeout(() => {
|
||||
this.lastSearch = filter.value.toLowerCase();
|
||||
const terms = this.lastSearch.split(' ');
|
||||
for (var i = 0; i < allSeries.length; i++) {
|
||||
const series = allSeries.item(i)!;
|
||||
let match = true;
|
||||
for (let term of terms) {
|
||||
if (term.length > 0 && series.dataset.filter!.indexOf(term) < 0) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) series.classList.remove('no-match');
|
||||
else series.classList.add('no-match');
|
||||
}
|
||||
}, 499);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
const path = location.pathname == '/'
|
||||
? '/partial/home.html'
|
||||
: `/partial/${location.pathname}.html`;
|
||||
htmx.ajax('GET', path, 'main');
|
||||
}
|
||||
}
|
|
@ -1,2 +1,4 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// start application
|
||||
new Radiostasis().initialize();
|
||||
});
|
|
@ -6,7 +6,7 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outFile": "../site/radiostasis.new.js",
|
||||
"outFile": "../site/radiostasis.js",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
}
|
||||
}
|
||||
|
|
169
src/typings/howler.d.ts
vendored
Normal file
169
src/typings/howler.d.ts
vendored
Normal file
|
@ -0,0 +1,169 @@
|
|||
export type HowlCallback = (soundId: number) => void;
|
||||
export type HowlErrorCallback = (soundId: number, error: unknown) => void;
|
||||
export type SpatialOrientation = [number, number, number];
|
||||
export type SpatialPosition = [number, number, number];
|
||||
|
||||
export enum HowlerState {
|
||||
Unloaded = 'unloaded',
|
||||
Loading = 'loading',
|
||||
Loaded = 'loaded',
|
||||
}
|
||||
|
||||
export interface SoundSpriteDefinitions {
|
||||
[name: string]: [number, number] | [number, number, boolean];
|
||||
}
|
||||
|
||||
export interface PannerAttributes {
|
||||
coneInnerAngle?: number | undefined;
|
||||
coneOuterAngle?: number | undefined;
|
||||
coneOuterGain?: number | undefined;
|
||||
distanceModel?: 'inverse' | 'linear';
|
||||
maxDistance?: number;
|
||||
panningModel?: 'HRTF' | 'equalpower';
|
||||
refDistance?: number;
|
||||
rolloffFactor?: number;
|
||||
}
|
||||
|
||||
export interface Sound {
|
||||
(howl: Howl): this;
|
||||
}
|
||||
|
||||
export interface HowlListeners {
|
||||
onstop?: HowlCallback | undefined;
|
||||
onpause?: HowlCallback | undefined;
|
||||
onload?: HowlCallback | undefined;
|
||||
onmute?: HowlCallback | undefined;
|
||||
onvolume?: HowlCallback | undefined;
|
||||
onrate?: HowlCallback | undefined;
|
||||
onseek?: HowlCallback | undefined;
|
||||
onfade?: HowlCallback | undefined;
|
||||
onunlock?: HowlCallback | undefined;
|
||||
onend?: HowlCallback | undefined;
|
||||
onplay?: HowlCallback | undefined;
|
||||
onloaderror?: HowlErrorCallback | undefined;
|
||||
onplayerror?: HowlErrorCallback | undefined;
|
||||
}
|
||||
|
||||
export interface HowlOptions extends HowlListeners {
|
||||
src: string | string[];
|
||||
volume?: number | undefined;
|
||||
html5?: boolean | undefined;
|
||||
loop?: boolean | undefined;
|
||||
preload?: boolean | 'metadata' | undefined;
|
||||
autoplay?: boolean | undefined;
|
||||
mute?: boolean | undefined;
|
||||
sprite?: SoundSpriteDefinitions | undefined;
|
||||
rate?: number | undefined;
|
||||
pool?: number | undefined;
|
||||
format?: string | string[] | undefined;
|
||||
xhr?: {
|
||||
method?: string | undefined;
|
||||
headers?: Record<string, string> | undefined;
|
||||
withCredentials?: boolean | undefined;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
export class Howl {
|
||||
constructor(options: HowlOptions);
|
||||
|
||||
play(spriteOrId?: string | number): number;
|
||||
pause(id?: number): this;
|
||||
stop(id?: number): this;
|
||||
|
||||
mute(): boolean;
|
||||
mute(muted: boolean, id?: number): this;
|
||||
|
||||
volume(): number;
|
||||
volume(idOrSetVolume: number): this | number;
|
||||
volume(volume: number, id: number): this;
|
||||
|
||||
fade(from: number, to: number, duration: number, id?: number): this;
|
||||
|
||||
rate(id?: number): number;
|
||||
rate(rate: number, id?: number): this;
|
||||
|
||||
seek(id?: number): number;
|
||||
seek(seek: number, id?: number): this;
|
||||
|
||||
loop(id?: number): boolean;
|
||||
loop(loop: boolean, id?: number): this;
|
||||
|
||||
playing(id?: number): boolean;
|
||||
duration(id?: number): number;
|
||||
state(): 'unloaded' | 'loading' | 'loaded';
|
||||
load(): this;
|
||||
unload(): null;
|
||||
|
||||
on(event: 'load', callback: () => void, id?: number): this;
|
||||
on(event: 'loaderror' | 'playerror', callback: HowlErrorCallback, id?: number): this;
|
||||
on(
|
||||
event: 'play' | 'end' | 'pause' | 'stop' | 'mute' | 'volume' | 'rate' | 'seek' | 'fade' | 'unlock',
|
||||
callback: HowlCallback,
|
||||
id?: number,
|
||||
): this;
|
||||
on(event: string, callback: HowlCallback | HowlErrorCallback, id?: number): this;
|
||||
|
||||
once(event: 'load', callback: () => void, id?: number): this;
|
||||
once(event: 'loaderror' | 'playerror', callback: HowlErrorCallback, id?: number): this;
|
||||
once(
|
||||
event: 'play' | 'end' | 'pause' | 'stop' | 'mute' | 'volume' | 'rate' | 'seek' | 'fade' | 'unlock',
|
||||
callback: HowlCallback,
|
||||
id?: number,
|
||||
): this;
|
||||
once(event: string, callback: HowlCallback | HowlErrorCallback, id?: number): this;
|
||||
|
||||
off(event: 'load', callback?: () => void, id?: number): this;
|
||||
off(event: 'loaderror' | 'playerror', callback?: HowlErrorCallback, id?: number): this;
|
||||
off(
|
||||
event: 'play' | 'end' | 'pause' | 'stop' | 'mute' | 'volume' | 'rate' | 'seek' | 'fade' | 'unlock',
|
||||
callback?: HowlCallback,
|
||||
id?: number,
|
||||
): this;
|
||||
off(
|
||||
event: 'load' | 'loaderror' | 'playerror' | 'play' | 'end' | 'pause' | 'stop' | 'mute' | 'volume' | 'rate' | 'seek' | 'fade' | 'unlock',
|
||||
id: number,
|
||||
): this;
|
||||
off(event?: string, callback?: HowlCallback | HowlErrorCallback, id?: number): this;
|
||||
|
||||
stereo(): number;
|
||||
stereo(pan: number, id?: number): number | this;
|
||||
|
||||
pos(): SpatialPosition;
|
||||
pos(x: number, y?: number, z?: number, id?: number): this;
|
||||
|
||||
orientation(): SpatialOrientation;
|
||||
orientation(x: number, y?: number, z?: number, id?: number): this;
|
||||
|
||||
pannerAttr(id?: number): PannerAttributes;
|
||||
pannerAttr(options: PannerAttributes, id?: number): this;
|
||||
}
|
||||
|
||||
class HowlerGlobal {
|
||||
mute(muted: boolean): this;
|
||||
stop(): this;
|
||||
|
||||
volume(): number;
|
||||
volume(volume: number): this;
|
||||
|
||||
codecs(ext: string): boolean;
|
||||
unload(): this;
|
||||
usingWebAudio: boolean;
|
||||
html5PoolSize: number;
|
||||
noAudio: boolean;
|
||||
autoUnlock: boolean;
|
||||
autoSuspend: boolean;
|
||||
ctx: AudioContext;
|
||||
masterGain: GainNode;
|
||||
|
||||
stereo(pan: number): this;
|
||||
|
||||
pos(): SpatialPosition;
|
||||
pos(x: number, y?: number, z?: number): this;
|
||||
|
||||
orientation(): SpatialOrientation;
|
||||
orientation(x: number, y?: number, z?: number, xUp?: number, yUp?: number, zUp?: number): this;
|
||||
}
|
||||
|
||||
const Howler: HowlerGlobal;
|
||||
}
|
77
src/typings/htmx.d.ts
vendored
77
src/typings/htmx.d.ts
vendored
|
@ -1,6 +1,6 @@
|
|||
declare namespace htmx {
|
||||
function addClass(elt: HTMLElement, className: string): undefined;
|
||||
function ajax(
|
||||
export class HTMX {
|
||||
addClass(elt: HTMLElement, className: string): undefined;
|
||||
ajax(
|
||||
verb: string,
|
||||
path: string,
|
||||
context?: null | string | HTMLElement | {
|
||||
|
@ -11,8 +11,8 @@ declare namespace htmx {
|
|||
values?: any
|
||||
headers?: any
|
||||
}): void;
|
||||
function closest(elt: HTMLElement, className: string): HTMLElement | void;
|
||||
let config: {
|
||||
closest(elt: HTMLElement, className: string): HTMLElement | void;
|
||||
config: {
|
||||
attributesToSettle: Array<string>,
|
||||
defaultSettleDelay: number,
|
||||
defaultSwapDelay: number,
|
||||
|
@ -31,27 +31,48 @@ declare namespace htmx {
|
|||
refreshOnHistoryMiss: boolean,
|
||||
disableSelector: string,
|
||||
}
|
||||
let createEventSource: (url: string) => EventSource;
|
||||
let createWebSocket: (url: string) => WebSocket;
|
||||
function defineExtension(name: string, ext: any): void;
|
||||
function find(selector: string): HTMLElement | null;
|
||||
function find(elt: HTMLElement, selector: string): HTMLElement | null;
|
||||
function findAll(selector: string): NodeList;
|
||||
function findAll(elt: HTMLElement, selector: string): NodeList;
|
||||
function logAll(): void;
|
||||
let logger: (elt: HTMLElement, event: Event, data: any) => void;
|
||||
function off(eventName: string, listener: EventListener): EventListener;
|
||||
function off(target: string | HTMLElement, eventName: string, listener: EventListener): EventListener;
|
||||
function on(eventName: string, listener: EventListener): EventListener;
|
||||
function on(target: string | HTMLElement, eventName: string, listener: EventListener): EventListener;
|
||||
function onLoad(callback: (elt: HTMLElement) => void): EventListener;
|
||||
function parseInterval(string: string): number;
|
||||
function process(elt: HTMLElement): undefined
|
||||
function remove(elt: HTMLElement): undefined;
|
||||
function removeClass(elt: HTMLElement, className: string): undefined;
|
||||
function removeExtension(name: string): undefined;
|
||||
function takeClass(elt: HTMLElement, className: string): undefined;
|
||||
function toggleClass(elt: HTMLElement, className: string): undefined;
|
||||
function trigger(elt: HTMLElement, name: string, details: any): any;
|
||||
function values(elt: HTMLElement, requestType?: string): object;
|
||||
createEventSource: (url: string) => EventSource;
|
||||
createWebSocket: (url: string) => WebSocket;
|
||||
defineExtension(name: string, ext: any): void;
|
||||
find(selector: string): HTMLElement | null;
|
||||
find(elt: HTMLElement, selector: string): HTMLElement | null;
|
||||
findAll(selector: string): NodeList;
|
||||
findAll(elt: HTMLElement, selector: string): NodeList;
|
||||
logAll(): void;
|
||||
logger: (elt: HTMLElement, event: Event, data: any) => void;
|
||||
off(eventName: string, listener: EventListener): EventListener;
|
||||
off(target: string | HTMLElement, eventName: string, listener: EventListener): EventListener;
|
||||
on(eventName: string, listener: EventListener): EventListener;
|
||||
on(target: string | HTMLElement, eventName: string, listener: EventListener): EventListener;
|
||||
onLoad(callback: (elt: HTMLElement) => void): EventListener;
|
||||
parseInterval(string: string): number;
|
||||
process(elt: HTMLElement): undefined
|
||||
remove(elt: HTMLElement): undefined;
|
||||
removeClass(elt: HTMLElement, className: string): undefined;
|
||||
removeExtension(name: string): undefined;
|
||||
takeClass(elt: HTMLElement, className: string): undefined;
|
||||
toggleClass(elt: HTMLElement, className: string): undefined;
|
||||
trigger(elt: HTMLElement, name: string, details: any): any;
|
||||
values(elt: HTMLElement, requestType?: string): object;
|
||||
}
|
||||
|
||||
declare global {
|
||||
export const htmx: HTMX;
|
||||
export type HtmxEvent = {
|
||||
detail: {
|
||||
elt: HTMLElement,
|
||||
xhr: XMLHttpRequest,
|
||||
target: HtmlElement,
|
||||
requestConfig: {
|
||||
parameters: any,
|
||||
unfilteredParameters: any,
|
||||
headers: {[string]: string},
|
||||
target: any,
|
||||
verb: any,
|
||||
errors: any,
|
||||
path: string,
|
||||
triggeringEvent: any,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue