From 2755804c60c9312d22e5a3c528fa7991cae8ad6d Mon Sep 17 00:00:00 2001 From: Rudis Muiznieks Date: Sat, 8 Apr 2023 14:32:17 -0500 Subject: [PATCH] finished existing functionality rewrite in typescript --- scripts/generate-site.csx | 2 + site/radiostasis.js | 741 ++++++++++++++++--------------- site/radiostasis.new.js | 5 - site/style.css | 2 +- src/Model.ts | 12 + src/Player.ts | 261 ++++++++++- src/Playlist.ts | 33 ++ src/Radiostasis.ts | 88 ++++ src/{radiostasis.ts => index.ts} | 2 + src/tsconfig.json | 2 +- src/typings/howler.d.ts | 169 +++++++ src/typings/htmx.d.ts | 77 ++-- 12 files changed, 967 insertions(+), 427 deletions(-) delete mode 100644 site/radiostasis.new.js create mode 100644 src/Model.ts create mode 100644 src/Playlist.ts create mode 100644 src/Radiostasis.ts rename src/{radiostasis.ts => index.ts} (50%) create mode 100644 src/typings/howler.d.ts 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, + }, + }, + } }