diff --git a/scripts/generate-site.csx b/scripts/generate-site.csx
index a8775cb..80a0ffc 100755
--- a/scripts/generate-site.csx
+++ b/scripts/generate-site.csx
@@ -312,6 +312,8 @@ private void GenerateSeriesDetailsFragment(Series series) {
foreach (var episode in GetEpisodes(series.Slug)) {
sw.Write(
@$"
diff --git a/site/radiostasis.js b/site/radiostasis.js
index 3bbb993..9653eda 100644
--- a/site/radiostasis.js
+++ b/site/radiostasis.js
@@ -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');
- });
- }
-}
diff --git a/site/radiostasis.new.js b/site/radiostasis.new.js
deleted file mode 100644
index f1de7ba..0000000
--- a/site/radiostasis.new.js
+++ /dev/null
@@ -1,5 +0,0 @@
-"use strict";
-document.addEventListener('DOMContentLoaded', function () {
- htmx.on('blah', function () {
- });
-});
diff --git a/site/style.css b/site/style.css
index f3025c4..3990504 100644
--- a/site/style.css
+++ b/site/style.css
@@ -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 {
diff --git a/src/Model.ts b/src/Model.ts
new file mode 100644
index 0000000..ea112e0
--- /dev/null
+++ b/src/Model.ts
@@ -0,0 +1,12 @@
+type Series = {
+ slug: string,
+ title: string,
+ cover: string,
+}
+
+type Episode = {
+ series: Series,
+ slug: string,
+ title: string,
+ file: string,
+}
diff --git a/src/Player.ts b/src/Player.ts
index ce46f6d..4022ef2 100644
--- a/src/Player.ts
+++ b/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();
}
}
diff --git a/src/Playlist.ts b/src/Playlist.ts
new file mode 100644
index 0000000..e7e8b0f
--- /dev/null
+++ b/src/Playlist.ts
@@ -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';
+ }
+ }
+}
diff --git a/src/Radiostasis.ts b/src/Radiostasis.ts
new file mode 100644
index 0000000..bcf8b44
--- /dev/null
+++ b/src/Radiostasis.ts
@@ -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 = 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 = 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');
+ }
+}
diff --git a/src/radiostasis.ts b/src/index.ts
similarity index 50%
rename from src/radiostasis.ts
rename to src/index.ts
index f14c8e9..403f35f 100644
--- a/src/radiostasis.ts
+++ b/src/index.ts
@@ -1,2 +1,4 @@
document.addEventListener('DOMContentLoaded', () => {
+ // start application
+ new Radiostasis().initialize();
});
diff --git a/src/tsconfig.json b/src/tsconfig.json
index 4571694..d8b7e86 100644
--- a/src/tsconfig.json
+++ b/src/tsconfig.json
@@ -6,7 +6,7 @@
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
- "outFile": "../site/radiostasis.new.js",
+ "outFile": "../site/radiostasis.js",
"lib": ["DOM", "ESNext"],
}
}
diff --git a/src/typings/howler.d.ts b/src/typings/howler.d.ts
new file mode 100644
index 0000000..1d58072
--- /dev/null
+++ b/src/typings/howler.d.ts
@@ -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 | 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;
+}
diff --git a/src/typings/htmx.d.ts b/src/typings/htmx.d.ts
index 38509fd..3ab924f 100644
--- a/src/typings/htmx.d.ts
+++ b/src/typings/htmx.d.ts
@@ -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,
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,
+ },
+ },
+ }
}