From 0ed013c96833b207b6af1fb5c63db7e9b125b8b9 Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Thu, 21 Aug 2025 21:30:53 -0700 Subject: [PATCH] feature: improved audio player UX --- README.md | 2 + public/base.css | 353 ++++++++++++++++++++++++++++++++++++++ public/index.html | 1 + public/js/audioplayer.js | 234 +++++++++++++++++++++++++ public/tabs/talk/talk.css | 8 +- public/tabs/talk/talk.js | 26 ++- 6 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 public/js/audioplayer.js diff --git a/README.md b/README.md index 6c82392..8aee40c 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ feature discussions. - [X] spotify - [ ] youtube (any way to differentiate for yt music?) - [X] direct uploads + - [X] make the direct upload audio player look decent for a first pass + - [ ] make the display cooler - clicking the spectrum should toggle to different display modes - [ ] add action buttons to embeds - [ ] copy original link (hopefully just a button with some onclick we can slap next to the iframe and style?) - [ ] toggle embed (toggle between showing the embed and the original link) diff --git a/public/base.css b/public/base.css index 721f3f5..d94292e 100644 --- a/public/base.css +++ b/public/base.css @@ -206,6 +206,185 @@ body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] { height: unset; } +.audio-container { + position: relative; + width: 100%; + max-width: 800px; + overflow: hidden; + font-size: small; + padding: 0.25rem; + border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent); + white-space-collapse: discard; +} + +.audio-container audio { + width: 100%; + margin-bottom: -5px; +} + +.audio-container .audio-player-display-container { + width: 100%; + height: 3rem; + margin-bottom: 0.5rem; + display: flex; + justify-content: center; + align-items: start; +} + +.audio-container .audio-player-display-container canvas { + width: 100%; + height: 100%; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 20%, transparent); +} + +.audio-container .enhanced-audio-player-container { + opacity: 0; + visibility: hidden; + display: none; +} + +.audio-container[data-audio-player] .enhanced-audio-player-container { + opacity: 1; + visibility: visible; + display: block; +} + +.audio-container .audio-controls-container .blank { + width: 100%; + max-width: 100px; +} + +.audio-container .audio-controls-container .progress-container { + width: 100%; + display: flex; + justify-content: space-between; + text-align: center; + padding: 0 0.5rem; +} + +.audio-container .audio-controls-container .progress-container label[for="progress"] { + display: block; + margin-top: -0.5rem; + margin-bottom: 0.25rem; +} + +.audio-container .audio-controls-container .progress-container .time-container { + text-align: center; +} + +.audio-container .audio-controls-container .progress-container .slider-container { + width: 80%; + padding: 0 1rem; + max-width: 800px; +} + +.audio-container .audio-controls-container .progress-container .slider-container input[name="progress"] { + width: 100%; +} + +.audio-container .audio-controls-container .buttons-container { + display: flex; + justify-content: space-between; + padding: 0 0.5rem; +} + +.audio-container .audio-controls-container .volume { + position: relative; + width: 100%; + max-width: 100px; + overflow: hidden; +} +@media screen and (max-width: 480px) { + .audio-container .audio-controls-container .blank { + max-width: 0; + } + + .audio-container .audio-controls-container .volume { + max-width: 60px; + } +} + +.audio-container .audio-controls-container input[type="range"] { + --c: var(--accent); /* active color */ + --g: 4px; /* the gap */ + --l: 2px; /* line thickness*/ + --s: 15px; /* thumb size*/ + + width: 100%; + height: var(--s); /* needed for Firefox*/ + --_c: color-mix(in srgb, var(--c), #000 var(--p,0%)); + -webkit-appearance :none; + -moz-appearance :none; + appearance :none; + background: none; + cursor: pointer; + overflow: hidden; +} +.audio-container .audio-controls-container input[type="range"]:focus-visible, +.audio-container .audio-controls-container input[type="range"]:hover { + --p: 25%; +} +.audio-container .audio-controls-container input[type="range"]:active, +.audio-container .audio-controls-container input[type="range"]:focus-visible{ + --_b: var(--s) +} +/* chromium */ +.audio-container .audio-controls-container input[type="range"]::-webkit-slider-thumb{ + height: var(--s); + aspect-ratio: 1; + border-radius: 50%; + box-shadow: 0 0 0 var(--_b,var(--l)) inset var(--_c); + border-image: linear-gradient(90deg,var(--_c) 50%,#ababab 0) 0 1/calc(50% - var(--l)/2) 100vw/0 calc(100vw + var(--g)); + -webkit-appearance: none; + appearance: none; + transition: .2s; +} +/* Firefox */ +.audio-container .audio-controls-container input[type="range"]::-moz-range-thumb { + height: var(--s); + width: var(--s); + background: none; + border-radius: 50%; + box-shadow: 0 0 0 var(--_b,var(--l)) inset var(--_c); + border-image: linear-gradient(90deg,var(--_c) 50%,#ababab 0) 0 1/calc(50% - var(--l)/2) 100vw/0 calc(100vw + var(--g)); + -moz-appearance: none; + appearance: none; + transition: .2s; +} +@supports not (color: color-mix(in srgb,red,red)) { + .audio-container .audio-controls-container input[type="range"] { + --_c: var(--c); + } +} + +.audio-container .audio-controls-container .volume label[for="volume"] { + position: absolute; + left: 0; +} + +.audio-container .audio-controls-container .audio-control { + cursor: pointer; +} + +.audio-container .audio-controls-container .audio-control .icon.play { + opacity: 1; + display: block; +} +.audio-container .audio-controls-container .audio-control .icon.pause { + opacity: 0; + display: none; +} + +.audio-container[data-playing] .audio-controls-container .audio-control .icon.play { + opacity: 0; + display: none; +} +.audio-container[data-playing] .audio-controls-container .audio-control .icon.pause { + opacity: 1; + display: block; +} + /* ICONS */ .icon { width: 24px; @@ -313,6 +492,7 @@ body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] { border-top: 4px solid; border-radius: 3px; } + .icon.calendar::before { content: ""; position: absolute; @@ -790,3 +970,176 @@ body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] { .icon.left::after { left: 11px; } + + +/* AUDIO PLAYER ICONS */ +.icon.skip-back { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--ggs, 1)); + width: 22px; + height: 22px; + border: 2px solid; + border-radius: 4px; +} +.icon.skip-back::after, +.icon.skip-back::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + height: 8px; + top: 5px; +} +.icon.skip-back::before { + width: 2px; + border-radius: 2px; + right: 11px; + background: currentColor; +} +.icon.skip-back::after { + width: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-right: 5px solid; + right: 5px; +} + +.icon.rewind { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--ggs, 1)); + border: 2px solid; + border-radius: 4px; + width: 22px; + height: 22px; +} +.icon.rewind::after, +.icon.rewind::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 6px; + height: 6px; + border-left: 2px solid; + border-bottom: 2px solid; + transform: rotate(45deg); + top: 6px; + left: 5px; +} +.icon.rewind::after { + left: 9px; +} + + +.icon.play { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--ggs, 1)); + width: 22px; + height: 22px; + border: 2px solid; + border-radius: 4px; +} +.icon.play::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 0; + height: 10px; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 6px solid; + top: 4px; + left: 7px; +} + +.icon.pause { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--ggs, 1)); + width: 22px; + height: 22px; + border: 2px solid; + border-radius: 4px; +} +.icon.pause::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 6px; + height: 6px; + left: 6px; + top: 6px; + border-left: 2px solid; + border-right: 2px solid; +} + +.icon.fastforward { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--ggs, 1)); + border: 2px solid; + border-radius: 4px; + width: 22px; + height: 22px; +} +.icon.fastforward::after, +.icon.fastforward::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 6px; + height: 6px; + border-right: 2px solid; + border-top: 2px solid; + transform: rotate(45deg); + top: 6px; + right: 5px; +} +.icon.fastforward::after { + right: 9px; +} + + +.icon.skip-forward { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--ggs, 1)); + width: 22px; + height: 22px; + border: 2px solid; + border-radius: 4px; +} +.icon.skip-forward::after, +.icon.skip-forward::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + height: 8px; + top: 5px; +} +.icon.skip-forward::before { + width: 2px; + border-radius: 2px; + left: 11px; + background: currentColor; +} +.icon.skip-forward::after { + width: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid; + left: 5px; +} diff --git a/public/index.html b/public/index.html index 1ff9da7..0fa8238 100644 --- a/public/index.html +++ b/public/index.html @@ -11,6 +11,7 @@ + diff --git a/public/js/audioplayer.js b/public/js/audioplayer.js new file mode 100644 index 0000000..a8737d5 --- /dev/null +++ b/public/js/audioplayer.js @@ -0,0 +1,234 @@ +// adapted from: https://codepen.io/uixamp / https://arisetyo.github.io + +function human_readable_time(duration) { + return new Date(duration * 1000).toISOString().slice(duration > 3600 ? 11 : 14, 19); +} + +const FREQUENCY_SAMPLES = 512; +const BAR_WIDTH = 4; +const PROGRESS_TICKS = 1000; +const MS_BETWEEN_PROGRESS_UPDATES = 200; + +new MutationObserver((mutations) => { + const uninitialized_audio_elements = document.querySelectorAll( + ".audio-container:not([data-audio-player])", + ); + for (const player of uninitialized_audio_elements) { + const CURRENT_TIMES = player.querySelectorAll( + ".audio-controls-container .time-container .current", + ); + const DURATIONS = player.querySelectorAll( + ".audio-controls-container .time-container .duration", + ); + + let progress_being_changed_by_user = false; + + const AUDIO = player.querySelector("audio"); + AUDIO.controls = false; + AUDIO.addEventListener("ended", () => { + delete player.dataset.playing; + }); + AUDIO.addEventListener("loadedmetadata", () => { + for (const duration of DURATIONS) { + duration.innerHTML = human_readable_time(AUDIO.duration); + } + }); + AUDIO.addEventListener("timeupdate", () => { + if (!progress_being_changed_by_user) { + PROGRESS.value = parseInt((AUDIO.currentTime / AUDIO.duration) * PROGRESS_TICKS); + } + + for (const current of CURRENT_TIMES) { + current.innerHTML = human_readable_time(AUDIO.currentTime); + } + }); + AUDIO.addEventListener("volumechange", () => { + if (!volume_being_changed_by_user) { + VOLUME.value = parseInt(AUDIO.volume * 100); + } + + for (const volume_display of VOLUME_DISPLAYS) { + volume_display.innerHTML = parseInt(AUDIO.volume * 100); + } + }); + + player.addEventListener("keydown", (event) => { + const key = event.which ?? event.keyCode; + + console.dir({ + key, + playing: player.dataset.playing, + }); + + switch (key) { + case 32: // space + event.preventDefault(); + if (player.dataset.playing) { + AUDIO.pause(); + delete player.dataset.playing; + } else { + AUDIO.play(); + player.dataset.playing = true; + } + break; + case 37: + event.preventDefault(); + AUDIO.currentTime -= 5; + break; + case 39: + event.preventDefault(); + AUDIO.currentTime += 5; + break; + default: + break; + } + }); + + const VOLUME = player.querySelector('.audio-controls-container input[name="volume"]'); + const VOLUME_DISPLAYS = player.querySelectorAll( + ".audio-controls-container .volume-display", + ); + + let volume_being_changed_by_user = false; + + VOLUME.addEventListener("pointerdown", (event) => { + volume_being_changed_by_user = true; + }); + VOLUME.addEventListener("pointerup", () => { + volume_being_changed_by_user = false; + }); + + function on_volume_changed() { + if (volume_being_changed_by_user) { + AUDIO.volume = Math.min(1.0, Math.max(VOLUME.value / 100, 0)); + } + + for (const volume_display of VOLUME_DISPLAYS) { + volume_display.innerHTML = parseInt(AUDIO.volume * 100); + } + } + VOLUME.addEventListener("change", on_volume_changed); + VOLUME.addEventListener("input", on_volume_changed); + + VOLUME.value = Math.min(100, Math.max(AUDIO.volume * 100, 0)); + + const PROGRESS = player.querySelector('.audio-controls-container input[name="progress"]'); + PROGRESS.min = 0; + PROGRESS.max = PROGRESS_TICKS; + + PROGRESS.addEventListener("pointerdown", (event) => { + progress_being_changed_by_user = true; + }); + PROGRESS.addEventListener("pointerup", () => { + progress_being_changed_by_user = false; + }); + + function on_progress_changed() { + if (progress_being_changed_by_user) { + AUDIO.currentTime = (PROGRESS.value / PROGRESS_TICKS) * AUDIO.duration; + } + } + + PROGRESS.addEventListener("change", on_progress_changed); + PROGRESS.addEventListener("input", on_progress_changed); + + let CANVAS; + let ACTX; + + let ANALYSER; + let DATA; + let SOURCE; + + let CTX; + + let VIZ_RGB; + + function init() { + CANVAS = CANVAS ?? player.querySelector("canvas"); + VIZ_RGB = window + .getComputedStyle(CANVAS) + .color.slice(4, -1) + .split(",") + .map((v) => parseInt(v)); + + ACTX = ACTX ?? new AudioContext(); + + if (!ANALYSER) { + ANALYSER = ACTX.createAnalyser(); + ANALYSER.fftSize = 4 * FREQUENCY_SAMPLES; + ANALYSER.smoothingTimeConstant = 0.8; + } + + DATA = DATA ?? new Uint8Array(ANALYSER.frequencyBinCount); + + if (!SOURCE) { + SOURCE = ACTX.createMediaElementSource(AUDIO); + SOURCE.connect(ANALYSER); + SOURCE.connect(ACTX.destination); + } + + CTX = CTX ?? CANVAS.getContext("2d"); + + for (const current of CURRENT_TIMES) { + current.innerHTML = human_readable_time(AUDIO.currentTime); + } + } + + function draw() { + if (!player.dataset.playing) { + return; + } + + ANALYSER.getByteFrequencyData(DATA); + + // draw on the canvas element + CTX.clearRect(0, 0, CANVAS.width, CANVAS.height); + + for (let i = 0; i < DATA.length; i = i + BAR_WIDTH) { + // normalize the value + const value = DATA[i] / 255; + const y = CANVAS.height - CANVAS.height * value; + CTX.fillStyle = `rgb(${Math.min(255, parseInt(value * VIZ_RGB[0] + VIZ_RGB[0] * 0.25))}, ${Math.min(255, parseInt(value * VIZ_RGB[1] + VIZ_RGB[1] * 0.25))}, ${Math.min(255, parseInt(value * VIZ_RGB[2] + VIZ_RGB[2] * 0.25))})`; + CTX.fillRect(i, y, 2, 8); + } + + requestAnimationFrame(draw); + } + + player.querySelector(".audio-control.skip-back")?.addEventListener("click", (event) => { + init(); + AUDIO.currentTime = 0; + }); + + player + .querySelector(".audio-control.play-pause-toggle") + .addEventListener("click", (event) => { + init(); + + const is_playing = player.dataset.playing; + if (is_playing) { + AUDIO.pause(); + delete player.dataset.playing; + // CTX.clearRect(0, 0, canvasEl.width, canvasEl.height); + return; + } + + AUDIO.play(); + player.dataset.playing = true; + draw(); + }); + + player.querySelector(".audio-control.skip-forward")?.addEventListener("click", (event) => { + init(); + + AUDIO.currentTime = AUDIO.duration; + AUDIO.pause(); + delete player.dataset.playing; + }); + + player.dataset.audioPlayer = true; + } +}).observe(document, { + subtree: true, + childList: true, +}); diff --git a/public/tabs/talk/talk.css b/public/tabs/talk/talk.css index 233ee78..ecd6d1e 100644 --- a/public/tabs/talk/talk.css +++ b/public/tabs/talk/talk.css @@ -274,11 +274,13 @@ } #talk .embed-container.short { - height: 0; - min-height: 40px; + height: 120px; overflow: hidden; overflow-y: auto; - padding-bottom: 7.5%; +} + +#talk .embed-container.tidal { + border-radius: 12px; } #talk .embed-container.vertical { diff --git a/public/tabs/talk/talk.js b/public/tabs/talk/talk.js index 9573fe1..da5a073 100644 --- a/public/tabs/talk/talk.js +++ b/public/tabs/talk/talk.js @@ -182,11 +182,35 @@ const URL_MATCH_HANDLERS = [ if (mime_types[0].indexOf("audio") === 0) { return ` -
+
+
+
+ +
+
+
+
00:00
+
+ + +
+
00:00
+
+
+
+
+
+
+
+ +
+
+
+
`; } }