feature: improved audio player UX

This commit is contained in:
Andy Burke 2025-08-21 21:30:53 -07:00
parent ef98fc754d
commit 0ed013c968
6 changed files with 620 additions and 4 deletions

View file

@ -52,6 +52,8 @@ feature discussions.
- [X] spotify - [X] spotify
- [ ] youtube (any way to differentiate for yt music?) - [ ] youtube (any way to differentiate for yt music?)
- [X] direct uploads - [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 - [ ] add action buttons to embeds
- [ ] copy original link (hopefully just a button with some onclick we can slap next to the iframe and style?) - [ ] 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) - [ ] toggle embed (toggle between showing the embed and the original link)

View file

@ -206,6 +206,185 @@ body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] {
height: unset; 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 */ /* ICONS */
.icon { .icon {
width: 24px; width: 24px;
@ -313,6 +492,7 @@ body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] {
border-top: 4px solid; border-top: 4px solid;
border-radius: 3px; border-radius: 3px;
} }
.icon.calendar::before { .icon.calendar::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -790,3 +970,176 @@ body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] {
.icon.left::after { .icon.left::after {
left: 11px; 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;
}

View file

@ -11,6 +11,7 @@
<link rel="stylesheet" href="./base.css"></link> <link rel="stylesheet" href="./base.css"></link>
<script src="./js/audioplayer.js" type="text/javascript"></script>
<script src="./js/datetimeutils.js" type="text/javascript"></script> <script src="./js/datetimeutils.js" type="text/javascript"></script>
<script src="./js/locationchange.js" type="text/javascript"></script> <script src="./js/locationchange.js" type="text/javascript"></script>
<script src="./js/totp.js" type="text/javascript"></script> <script src="./js/totp.js" type="text/javascript"></script>

234
public/js/audioplayer.js Normal file
View file

@ -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,
});

View file

@ -274,11 +274,13 @@
} }
#talk .embed-container.short { #talk .embed-container.short {
height: 0; height: 120px;
min-height: 40px;
overflow: hidden; overflow: hidden;
overflow-y: auto; overflow-y: auto;
padding-bottom: 7.5%; }
#talk .embed-container.tidal {
border-radius: 12px;
} }
#talk .embed-container.vertical { #talk .embed-container.vertical {

View file

@ -182,11 +182,35 @@ const URL_MATCH_HANDLERS = [
if (mime_types[0].indexOf("audio") === 0) { if (mime_types[0].indexOf("audio") === 0) {
return ` return `
<div class="embed-container short"> <div class="audio-container" tabindex="-1">
<audio controls> <audio controls>
<source src="${link_info.url}" type="${mime_types[0].indexOf("audio/mpeg") === 0 ? "audio/mpeg" : mime_types[0]}"> <source src="${link_info.url}" type="${mime_types[0].indexOf("audio/mpeg") === 0 ? "audio/mpeg" : mime_types[0]}">
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>
<div class="enhanced-audio-player-container">
<div class="audio-player-display-container">
<canvas class="audio-player-display"></canvas>
</div>
<div class="audio-controls-container">
<div class="progress-container">
<div class="time-container"><span class="current">00:00</span></div>
<div class="slider-container">
<input type="range" name="progress" title="" min="0" max="1000" step="1" value="0" />
<label class="time-container" for="progress"><span class="current">00:00</span></label>
</div>
<div class="time-container"><span class="duration">00:00</span></div>
</div>
<div class="buttons-container">
<div class="audio-control blank"></div>
<div class="audio-control skip-back"><div class="icon skip-back"></div></div>
<div class="audio-control play-pause-toggle"><div class="icon play"></div><div class="icon pause"></div></div>
<div class="audio-control skip-forward"><div class="icon skip-forward"></div></div>
<div class="audio-control volume">
<input type="range" name="volume" title="Volume" min="0" max="100" step="1" value="" />
</div>
</div>
</div>
</div>
</div>`; </div>`;
} }
} }