mini-player: finished implementation, now need to keep trakc of state

This commit is contained in:
Agahnim 2026-03-20 14:18:12 +01:00
parent b4f861e79b
commit 823308d6eb
Signed by: Agahnim
SSH key fingerprint: SHA256:Zj65PJnE0dRYye8Ltk/qDglynyXUxJngQ9qqx/VI+b4
9 changed files with 320 additions and 48 deletions

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use agahnim_web_v2::{ use agahnim_web_v2::{
domain::AppState, domain::AppState,
templates::{index::home, miniplayer::miniplayer, music::music, notfound::notfound}, templates::{home::home, miniplayer::miniplayer, music::music, notfound::notfound},
}; };
use axum::{Router, routing::get}; use axum::{Router, routing::get};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;

View file

@ -5,17 +5,17 @@ use axum::{
}; };
#[derive(Template)] #[derive(Template)]
#[template(path = "index.html")] #[template(path = "home.html")]
struct IndexTemplate; struct HomeTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "partials/index.html")] #[template(path = "partials/home.html")]
struct IndexPartialTemplate; struct HomePartialTemplate;
pub async fn home(headers: HeaderMap) -> impl IntoResponse { pub async fn home(headers: HeaderMap) -> impl IntoResponse {
if headers.contains_key("hx-request") { if headers.contains_key("hx-request") {
Html(IndexPartialTemplate.render().unwrap()) Html(HomePartialTemplate.render().unwrap())
} else { } else {
Html(IndexTemplate.render().unwrap()) Html(HomeTemplate.render().unwrap())
} }
} }

View file

@ -1,4 +1,4 @@
pub mod index; pub mod home;
pub mod miniplayer; pub mod miniplayer;
pub mod music; pub mod music;
pub mod notfound; pub mod notfound;

114
static/miniplayer.js Normal file
View file

@ -0,0 +1,114 @@
function initMiniPlayer() {
const player = document.querySelector("mini-player");
if (!player) return;
const audio = player.querySelector("audio");
const playBtn = player.querySelector('button[data-action="play"]');
const prevBtn = player.querySelector('button[data-action="prev"]');
const nextBtn = player.querySelector('button[data-action="next"]');
const playIcon = playBtn.querySelector(".play-icon");
const pauseIcon = playBtn.querySelector(".pause-icon");
const progressInput = player.querySelector(".progress-input");
const volumeInput = player.querySelector(".volume-input");
const currentTimeEl = player.querySelector("current-time");
const durationTimeEl = player.querySelector("duration-time");
const trackTitleEl = player.querySelector("track-title");
const trackArtistEl = player.querySelector("track-artist");
let currentTrackIndex = 0;
let isPlaying = false;
const sources = audio.querySelectorAll("source");
const tracks = Array.from(sources).map((s) => ({
src: s.getAttribute("src"),
title: s.getAttribute("data-title"),
artist: s.getAttribute("data-artist"),
}));
if (!tracks.length) return;
audio.src = tracks[0].src;
audio.volume = parseFloat(volumeInput.value) || 0.7;
const formatTime = (time) => {
if (isNaN(time)) return "00:00";
const m = Math.floor(time / 60);
const s = Math.floor(time % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
const updatePlayState = () => {
if (isPlaying) {
playIcon.style.display = "none";
pauseIcon.style.display = "";
} else {
playIcon.style.display = "";
pauseIcon.style.display = "none";
}
};
const loadTrack = (index) => {
currentTrackIndex = index;
audio.src = tracks[index].src;
audio.currentTime = 0;
progressInput.value = 0;
currentTimeEl.textContent = "00:00";
if (trackTitleEl) trackTitleEl.textContent = tracks[index].title;
if (trackArtistEl) trackArtistEl.textContent = tracks[index].artist;
if (isPlaying) audio.play();
};
const handlePlayPause = () => {
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
isPlaying = !isPlaying;
updatePlayState();
};
const handlePrev = () => {
const newIndex = currentTrackIndex === 0 ? tracks.length - 1 : currentTrackIndex - 1;
loadTrack(newIndex);
};
const handleNext = () => {
const newIndex = currentTrackIndex === tracks.length - 1 ? 0 : currentTrackIndex + 1;
loadTrack(newIndex);
};
playBtn.addEventListener("click", handlePlayPause);
prevBtn.addEventListener("click", handlePrev);
nextBtn.addEventListener("click", handleNext);
audio.addEventListener("loadeddata", () => {
progressInput.max = audio.duration;
durationTimeEl.textContent = formatTime(audio.duration);
});
audio.addEventListener("timeupdate", () => {
progressInput.value = audio.currentTime;
currentTimeEl.textContent = formatTime(audio.currentTime);
});
audio.addEventListener("ended", handleNext);
progressInput.addEventListener("input", (e) => {
audio.currentTime = parseFloat(e.target.value);
});
volumeInput.addEventListener("input", (e) => {
audio.volume = parseFloat(e.target.value);
});
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
if (e.code === "Space") {
e.preventDefault();
handlePlayPause();
}
});
}
initMiniPlayer();
document.addEventListener("htmx:afterSwap", initMiniPlayer);

View file

@ -84,8 +84,7 @@ navbar {
label { label {
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 4px 8px;
border: 2px solid; border: 2px solid black;
border-color: #ffffff #808080 #808080 #ffffff;
background: var(--win-bg-grey); background: var(--win-bg-grey);
&:has(input:checked) { &:has(input:checked) {
@ -150,44 +149,180 @@ katcenkat {
/* Mini player */ /* Mini player */
miniplayer { mini-player {
position: fixed; position: fixed;
top: 3.5rem; top: 3.5rem;
left: 0; left: 0;
z-index: 100; z-index: 100;
width: min(240px, 100svw);
background-color: var(--win-bg-grey); background-color: var(--win-bg-grey);
border: 2px solid var(--grey-500); border: 2px solid;
border-color: #ffffff #808080 #808080 #ffffff;
box-shadow: 2px 2px 0 #000;
transition: opacity 0.3s,
translate 0.3s;
ominous-message { ominous-message {
display: block;
padding: 0.5rem;
font-style: italic; font-style: italic;
font-size: 0.75rem;
} }
controls { title-bar {
display: flex;
flex-direction: row;
gap: 5rem;
state {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 5px; border-bottom: 2px solid;
border-color: #808080 #ffffff #ffffff #808080;
padding: 0.25rem 0.5rem;
track-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
max-width: 60%;
overflow: hidden;
track-title {
font-weight: bold;
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
volume { track-artist {
font-size: 0.625rem;
color: #555;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
time-display {
font-size: 0.625rem;
color: #333;
}
}
progress-bar-container {
padding: 0.25rem 0.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; justify-content: center;
flex-wrap: nowrap;
.progress-input {
input { display: block;
height: 4px; width: 90%;
width: 5rem; height: 0.2rem;
appearance: none; appearance: none;
background-color: white; background: #fff;
border: 1px solid var(--grey-500); border: 1px solid #808080;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 10px;
height: 10px;
background: var(--win-bg-grey);
border: 2px solid;
border-color: #ffffff #808080 #808080 #ffffff;
cursor: pointer;
}
&::-moz-range-thumb {
width: 10px;
height: 10px;
background: var(--win-bg-grey);
border: 2px solid;
border-color: #ffffff #808080 #808080 #ffffff;
cursor: pointer;
} }
} }
} }
controls-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
transport-controls {
display: flex;
gap: 1px;
button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: var(--win-bg-grey);
border: 2px solid;
border-color: #ffffff #808080 #808080 #ffffff;
cursor: pointer;
&:hover {
background: #0002;
}
&:active {
border-color: #808080 #ffffff #ffffff #808080;
}
&:focus-visible {
outline: 1px solid #000;
}
img {
pointer-events: none;
}
}
}
volume-controls {
display: flex;
align-items: center;
gap: 0.25rem;
.volume-input {
width: 4rem;
height: 4px;
appearance: none;
background: #fff;
border: 1px solid #808080;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 8px;
height: 8px;
background: var(--win-bg-grey);
border: 2px solid;
border-color: #ffffff #808080 #808080 #ffffff;
cursor: pointer;
}
&::-moz-range-thumb {
width: 8px;
height: 8px;
background: var(--win-bg-grey);
border: 2px solid;
border-color: #ffffff #808080 #808080 #ffffff;
cursor: pointer;
}
}
}
}
audio {
display: none;
}
} }

View file

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, interactive-widget=resizes-content" />
<link rel="icon" type="image/gif" href="/static/assets/gifs/pcgif.gif"> <link rel="icon" type="image/gif" href="/static/assets/gifs/pcgif.gif">
<!-- <title></title> --> <!-- <title></title> -->
<title>Agahnim proto</title> <title>Agahnim proto</title>
@ -26,6 +27,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script src="/static/miniplayer.js"></script>
<script> <script>
function updateMiniplayerVisibility() { function updateMiniplayerVisibility() {
const isMusicPage = window.location.pathname === '/music'; const isMusicPage = window.location.pathname === '/music';

View file

@ -2,5 +2,5 @@
{% block content %} {% block content %}
{% include "partials/index.html" %} {% include "partials/home.html" %}
{% endblock %} {% endblock %}

View file

@ -1,23 +1,44 @@
<miniplayer> <mini-player>
{% if tracks.is_empty() %} {% if tracks.is_empty() %}
<ominous-message>You won't hear anything from me</ominous-message> <ominous-message>You won't hear anything from me</ominous-message>
{% else %} {% else %}
<title-bar>
<track-info>
<track-title>{{ tracks[0].title }}</track-title>
<track-artist>{{ tracks[0].artist }}</track-artist>
</track-info>
<time-display>
<current-time>00:00</current-time> / <duration-time>00:00</duration-time>
</time-display>
</title-bar>
<progress-bar-container>
<input class="progress-input" type="range" min="0" max="0" value="0" step="0.01" />
</progress-bar-container>
<controls-bar>
<transport-controls>
<button data-action="prev" aria-label="Previous">
<img src="/static/assets/svgs/skip-back.svg" width="12" height="12" alt="" />
</button>
<button data-action="play" aria-label="Play/Pause">
<img class="play-icon" src="/static/assets/svgs/play.svg" width="12" height="12" alt="" />
<img class="pause-icon" src="/static/assets/svgs/pause.svg" width="12" height="12" alt="" style="display: none;" />
</button>
<button data-action="next" aria-label="Next">
<img src="/static/assets/svgs/skip-forward.svg" width="12" height="12" alt="" />
</button>
</transport-controls>
<volume-controls>
<img src="/static/assets/svgs/volume-2.svg" width="12" height="12" alt="" />
<input class="volume-input" type="range" min="0" max="1" step="0.01" value="0.7" />
</volume-controls>
</controls-bar>
<audio hidden>
{% for track in tracks %} {% for track in tracks %}
<div class="track"> <source src="{{ track.src }}" data-index="{{ loop.index0 }}" data-title="{{ track.title }}" data-artist="{{ track.artist }}" />
<p>{{ track.title }} - {{ track.artist }}</p>
<audio controls src="{{ track.src }}"></audio>
</div>
<controls>
<state>
<img src="/static/assets/svgs/skip-back.svg" width="12" height="12" />
<img src="/static/assets/svgs/play.svg" width="12" height="12" />
<img src="/static/assets/svgs/skip-forward.svg" width="12" height="12" />
</state>
<volume>
<img src="/static/assets/svgs/volume-2.svg" width="12" height="12" />
<input type="range" min="0" max="1" step="0.01" />
</volume>
</controls>
{% endfor %} {% endfor %}
</audio>
{% endif %} {% endif %}
</miniplayer> </mini-player>