Working player, some other fixes

This commit is contained in:
2025-08-11 13:23:47 +09:30
parent a5f00515a5
commit 7773d7c3ea
9 changed files with 3378 additions and 602 deletions

3300
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"astro": "^5.12.8", "astro": "^5.12.8",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"get-audio-duration": "^4.0.1",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,4 +1,10 @@
--- ---
export interface Track {
src: string;
metadata: Record<string, any>;
duration: number;
}
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
interface Props { interface Props {
@@ -6,6 +12,28 @@ interface Props {
} }
const { height = "h-28" } = Astro.props as Props; const { height = "h-28" } = Astro.props as Props;
import { getAudioDurationInSeconds } from "get-audio-duration";
import { join } from "path";
import { getCollection } from "astro:content";
const autoQueuedTracks = (
await getCollection("tracks", ({ data }) => data.autoQueue)
).sort(
(a, b) => (a.data.autoQueue?.order || -1) - (b.data.autoQueue?.order || 1)
);
const initialQueue = await Promise.all(
autoQueuedTracks.map(async ({ data }) => {
const fullFilePath = join(process.cwd(), "public", data.src);
const duration = await getAudioDurationInSeconds(fullFilePath);
return {
src: data.src,
metadata: data.metadata,
duration: duration
};
})
);
--- ---
<div <div
@@ -32,13 +60,13 @@ const { height = "h-28" } = Astro.props as Props;
</div> </div>
<div class="p-2 text-white"> <div class="p-2 text-white">
<div class="grid grid-cols-2 text-xs"> <div class="grid grid-cols-2 text-xs">
<span id="player:time:current" class="col-span-1 text-left"></span> <span id="player:times:current" class="col-span-1 text-left">0:00</span>
<span id="player:time:total" class="col-span-1 text-right"></span> <span id="player:times:duration" class="col-span-1 text-right">0:00</span>
</div> </div>
<div class="flex h-full flex-row items-center justify-center align-middle"> <div class="flex h-full flex-row items-center justify-center align-middle">
<div class="m-0 flex grow-0 flex-row space-x-2 p-0 text-3xl"> <div class="m-0 flex grow-0 flex-row space-x-2 p-0 text-3xl">
<span> <span>
<Icon name="fa7-solid:backward" id="player:controls:backward" /> <Icon name="fa7-solid:backward" id="player:controls:previous" />
</span> </span>
<span> <span>
<Icon name="fa7-solid:play" id="player:controls:play" /> <Icon name="fa7-solid:play" id="player:controls:play" />
@@ -49,7 +77,7 @@ const { height = "h-28" } = Astro.props as Props;
/> />
</span> </span>
<span> <span>
<Icon name="fa7-solid:forward" id="player:controls:forward" /> <Icon name="fa7-solid:forward" id="player:controls:next" />
</span> </span>
</div> </div>
<div class="flex flex-grow flex-col text-sm"> <div class="flex flex-grow flex-col text-sm">
@@ -63,363 +91,260 @@ const { height = "h-28" } = Astro.props as Props;
</div> </div>
<div class:list={[height]}></div> <div class:list={[height]}></div>
<script src="/js/howler.min.js" is:inline></script> <script is:inline src="/js/howler.min.js"></script>
<script is:inline define:vars={{ initialQueue }}>
/**
* Player class containing the state of our playlist and where we are in it.
* Includes all methods for playing, skipping, updating the display, etc.
* @param {Array} playlist Array of objects with playlist song details ({title, file, howl}).
*/
class Player {
constructor(playlist) {
this.playlist = window.player?.playlist ?? playlist;
this.index = window.player?.index ?? 0;
<!-- Retrieve elements --> this.UI = {
<script is:inline> controls: {
// numbers play: document.getElementById("player:controls:play"),
const player_playerTimeCurrent = document.getElementById( pause: document.getElementById("player:controls:pause"),
"player:time:current" previous: document.getElementById("player:controls:previous"),
); next: document.getElementById("player:controls:next")
const player_playerTimeTotal = document.getElementById("player:time:total"); },
times: {
current: document.getElementById("player:times:current"),
duration: document.getElementById("player:times:duration")
},
metadata: {
title: document.getElementById("player:metadata:title"),
subtitle: document.getElementById("player:metadata:subtitle"),
extra: document.getElementById("player:metadata:extra")
},
progress: {
bar: document.getElementById("player:time:progress"),
buffered: document.getElementById("player:time:progress:buffered"),
played: document.getElementById("player:time:progress:played")
}
};
// progress bars this.UI.metadata.title.textContent = playlist[this.index].metadata.title;
const player_playerTimeProgress = document.getElementById( this.UI.metadata.subtitle.textContent =
"player:time:progress" playlist[this.index].metadata.subtitle;
); this.UI.metadata.extra.textContent = playlist[this.index].metadata.extra;
const player_playerTimeProgressPlayed = document.getElementById( this.UI.times.duration.textContent = this.formatTime(
"player:time:progress:played" Math.round(playlist[this.index].duration)
);
const player_playerTimeProgressBuffered = document.getElementById(
"player:time:progress:buffered"
); );
// metadata this.UI.controls.play.addEventListener("click", function () {
const player_playerMetadataTitle = document.getElementById( window.player.play();
"player:metadata:title"
);
const player_playerMetadataSubtitle = document.getElementById(
"player:metadata:subtitle"
);
const player_playerMetadataExtra = document.getElementById(
"player:metadata:extra"
);
// controls
const player_playerControlsPlay = document.getElementById(
"player:controls:play"
);
const player_playerControlsPause = document.getElementById(
"player:controls:pause"
);
const player_playerControlsForward = document.getElementById(
"player:controls:forward"
);
const player_playerControlsBackward = document.getElementById(
"player:controls:backward"
);
</script>
<!-- Add listeners -->
<script is:inline>
if (player_playerTimeProgress) {
player_playerTimeProgress.addEventListener("click", function (event) {
const x = event.offsetX;
const percentage =
x / player_playerTimeProgress.getBoundingClientRect().width;
player_seekTrack(percentage);
}); });
} this.UI.controls.pause.addEventListener("click", function () {
window.player.pause();
if (player_playerControlsPlay) {
player_playerControlsPlay.addEventListener("click", function (event) {
player_playPause();
}); });
} this.UI.controls.previous.addEventListener("click", function () {
window.player.skip("prev");
if (player_playerControlsPause) {
player_playerControlsPause.addEventListener("click", function (event) {
player_playPause();
}); });
} this.UI.controls.next.addEventListener("click", function () {
window.player.skip("next");
if (player_playerControlsForward) {
player_playerControlsForward.addEventListener("click", function (event) {
player_playForward();
}); });
}
if (player_playerControlsBackward) { this.UI.progress.bar.addEventListener("click", function (event) {
player_playerControlsBackward.addEventListener("click", function (event) { window.player.seek(event.clientX / window.innerWidth);
player_playBackward();
}); });
}
</script>
<!-- Playing control logic --> window.playerIsInitialised = true;
<script is:inline>
function player_seekTrack(percentage) {
const currentTrack = player_getCurrentTrack();
if (currentTrack) {
const duration = currentTrack.howl.duration();
const seconds = duration * percentage;
const ID = player_getCurrentTrackID();
currentTrack.howl.seek(seconds, ID);
}
} }
function player_playPause() { /**
const currentTrack = player_getCurrentTrack(); * Play a song in the playlist.
if (!currentTrack) { * @param {Number} index Index of the song in the playlist (leave empty to play the first or current).
return; */
} play(index) {
var sound;
const ID = player_getCurrentTrackID(); index = typeof index === "number" ? index : this.index;
var data = this.playlist[index];
if (currentTrack.howl.playing(ID)) { // If we already loaded this track, use the current one.
currentTrack.howl.pause(ID); if (data.howl) {
return; sound = data.howl;
}
// Case 2: Valid ID but not playing
if (typeof ID === "number" && ID >= 0) {
currentTrack.howl.play(ID);
return;
}
// Case 3: No valid ID yet (first play)
player_nextIndex++;
player_currentID = currentTrack.howl.play();
}
function player_playForward() {
player_playNext();
}
function player_playBackward() {
player_playPrevious();
}
</script>
<!-- Handle UI -->
<script is:inline>
function player_formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
// Pad with 0s if less than 10
const paddedMinutes = String(minutes).padStart(2, "0");
const paddedSecs = String(secs).padStart(2, "0");
return `${paddedMinutes}:${paddedSecs}`;
}
function player_getBufferedAmount(track) {
const audioNode = track._sounds[0]._node;
if (audioNode && audioNode.buffered.length) {
// Get the highest buffered point
return audioNode.buffered.end(0);
}
return 0;
}
function player_updateProgressAndDuration(
current,
total,
progress,
buffered
) {
if (player_playerTimeCurrent) {
player_playerTimeCurrent.textContent = player_formatTime(current);
}
if (player_playerTimeTotal) {
player_playerTimeTotal.textContent = player_formatTime(total);
}
if (player_playerTimeProgressPlayed) {
player_playerTimeProgressPlayed.style.width = `${(progress * 100).toString()}%`;
}
if (player_playerTimeProgressBuffered) {
let bufferedPercentage = (total / buffered) * 100;
if (Math.abs(100 - bufferedPercentage) < 1) {
bufferedPercentage = 100;
}
player_playerTimeProgressBuffered.style.width = `${bufferedPercentage.toString()}%`;
}
}
function player_updateMetadata(title, subtitle, extra, artist) {
if (player_playerMetadataTitle) {
player_playerMetadataTitle.textContent = title;
}
if (player_playerMetadataSubtitle) {
player_playerMetadataSubtitle.textContent = subtitle;
}
if (player_playerMetadataExtra) {
player_playerMetadataExtra.textContent = extra;
}
}
function player_updatePlayPause() {
if (player_playerControlsPlay && player_playerControlsPause) {
const currentTrack = player_getCurrentTrack();
if (currentTrack) {
if (currentTrack.howl.playing()) {
player_playerControlsPause.classList.remove("hidden");
player_playerControlsPlay.classList.add("hidden");
} else { } else {
player_playerControlsPause.classList.add("hidden"); sound = data.howl = new Howl({
player_playerControlsPlay.classList.remove("hidden"); src: [data.src],
} autoplay: false,
} html5: true, // Force to HTML5 so that the audio can stream in (best for large files).
onplay: () => {
// Display the duration.
this.UI.times.duration.textContent = this.formatTime(
Math.round(sound.duration())
);
// Start updating the progress of the track.
requestAnimationFrame(this.step.bind(this));
this.UI.controls.pause.classList.remove("hidden");
this.UI.controls.play.classList.add("hidden");
},
// onload: () => {
// loading.style.display = "none";
// },
onend: () => {
this.skip("next");
},
onpause: () => {
this.UI.controls.pause.classList.add("hidden");
this.UI.controls.play.classList.remove("hidden");
},
// onstop: () => {
// },
onseek: () => {
// Start updating the progress of the track.
requestAnimationFrame(this.step.bind(this));
} }
});
} }
function player_updateUIElements() { // Begin playing the sound.
const currentTrack = player_getCurrentTrack(); sound.play();
if (currentTrack) {
// progress bar and times
const current = currentTrack.howl.seek();
const duration = currentTrack.howl.duration();
const progress = current / duration;
const buffered = player_getBufferedAmount(currentTrack.howl);
player_updateProgressAndDuration(current, duration, progress, buffered); // Update the track display.
this.UI.metadata.title.textContent = data.metadata.title;
this.UI.metadata.subtitle.textContent = data.metadata.subtitle;
this.UI.metadata.extra.textContent = data.metadata.extra;
this.UI.times.duration.textContent = this.formatTime(
Math.round(data.duration)
);
// metadata // Keep track of the index we are currently playing.
const title = currentTrack.metadata.title; this.index = index;
const subtitle = currentTrack.metadata.subtitle || "";
const extra = currentTrack.metadata.extra || "";
const artist = currentTrack.metadata.artist || "";
player_updateMetadata(title, subtitle, extra, artist);
player_updatePlayPause();
} }
requestAnimationFrame(player_updateUIElements); /**
* Pause the currently playing track.
*/
pause() {
// Get the Howl we want to manipulate.
var sound = this.playlist[this.index].howl;
sound.pause();
this.UI.controls.pause.classList.add("hidden");
this.UI.controls.play.classList.remove("hidden");
} }
requestAnimationFrame(player_updateUIElements); /**
</script> * Skip to the next or previous track.
* @param {String} direction 'next' or 'prev'.
*/
skip(direction) {
var current = this.playlist[this.index];
var sound = current.howl;
var currentlyPlaying = sound && sound.playing();
<!-- Main logic --> if (direction === "prev" && currentlyPlaying && sound.seek() > 3) {
<script is:inline> sound.seek(0);
const player_playlist = [];
let player_currentID = -999;
let player_nextIndex = 0;
function player_getCurrentTrack() {
if (player_getPlaylistSize() === 0) {
return null;
}
return player_playlist[player_getCurrentTrackIndex()];
}
function player_getCurrentTrackNumber() {
return player_nextIndex;
}
function player_getCurrentTrackIndex() {
if (player_nextIndex === 0) {
return player_nextIndex;
}
return player_nextIndex - 1;
}
function player_getCurrentTrackID() {
return player_currentID;
}
function player_getPlaylistSize() {
return player_playlist.length;
}
function player_playNext() {
if (player_getPlaylistSize() === 0) {
// the playlist is empty, just exit
return; return;
} }
const currentTrack = player_getCurrentTrack(); let index;
const currentID = player_getCurrentTrackID(); if (direction === "prev") {
index = (this.index - 1 + this.playlist.length) % this.playlist.length;
if (currentTrack && currentTrack.howl.playing()) { } else {
currentTrack.howl.stop(currentID); index = (this.index + 1) % this.playlist.length;
currentTrack.howl.seek(0, currentID);
} }
if (player_nextIndex >= player_getPlaylistSize()) { this.skipTo(index);
// wrap around
player_nextIndex = 0;
} }
const newTrack = player_playlist[player_nextIndex]; /**
player_nextIndex++; * Skip to a specific track based on its playlist index.
player_currentID = newTrack.howl.play(); * @param {Number} index Index in the playlist.
*/
skipTo(index) {
// Stop the current track.
if (this.playlist[this.index].howl) {
this.playlist[this.index].howl.stop();
} }
function player_playPrevious() { // Reset progress.
if (player_getPlaylistSize() === 0) { this.UI.progress.played.style.width = "0%";
// the playlist is empty, just exit this.UI.times.current.textContent = this.formatTime(0);
return;
// Play the new track.
this.play(index);
} }
const currentTrack = player_getCurrentTrack(); /**
const ID = player_getCurrentTrackID(); * Seek to a new position in the currently playing track.
* @param {Number} per Percentage through the song to skip.
*/
seek(per) {
// Get the Howl we want to manipulate.
var sound = this.playlist[this.index].howl;
if (sound.playing()) {
// Convert the percent into a seek position.
sound.seek(sound.duration() * per);
}
}
/**
* The step called within requestAnimationFrame to update the playback position.
*/
step() {
// Get the Howl we want to manipulate.
var sound = this.playlist[this.index].howl;
var seek = sound.seek() || 0;
this.UI.times.current.textContent = this.formatTime(Math.round(seek));
this.UI.progress.played.style.width =
((seek / sound.duration()) * 100 || 0) + "%";
// If the sound is still playing, continue stepping.
if (sound.playing()) {
requestAnimationFrame(this.step.bind(this));
}
}
/**
* Format the time from seconds to M:SS.
* @param {Number} secs Seconds to format.
* @return {String} Formatted time.
*/
formatTime(secs) {
var minutes = Math.floor(secs / 60) || 0;
var seconds = secs - minutes * 60 || 0;
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
}
queue(track, immediatePlay = false) {
this.playlist.splice(this.index + 1, 0, track);
if (immediatePlay) {
this.skipTo(this.index + 1);
}
}
addFromTrackCard(track) {
var current = this.playlist[this.index];
if ( if (
currentTrack && track.metadata.title === current.metadata.title &&
currentTrack.howl.playing() && track.metadata.subtitle === current.metadata.subtitle &&
currentTrack.howl.seek() >= 2 track.metadata.extra === current.metadata.extra
) { ) {
// there's currently a track playing and we're more than two seconds into it if (current.howl && current.howl.playing()) {
// play it from the start rather than going back to the previous this.pause();
currentTrack.howl.seek(0, ID);
return;
}
if (currentTrack && currentTrack.howl.playing(ID)) {
currentTrack.howl.stop(ID);
}
// Rewind index
player_nextIndex -= 2;
if (player_nextIndex < 0) {
player_nextIndex = player_getPlaylistSize() - 1;
}
const newTrack = player_playlist[player_nextIndex];
player_nextIndex++;
player_currentID = newTrack.howl.play();
}
</script>
<!-- Player utilities -->
<script is:inline>
function player_constructTrack(trackData) {
trackData.howl = new Howl({
src: [`/audio/${trackData.src}`],
html5: true,
preload: "metadata",
volume: 1,
onend: player_playNext
});
return trackData;
}
function player_addToPlaylist(track, autoPlay = true) {
const currentTrack = player_getCurrentTrack();
if (
currentTrack &&
currentTrack.metadata.title === track.metadata.title &&
currentTrack.metadata.subtitle === track.metadata.subtitle
) {
player_playPause();
return;
}
if (autoPlay) {
player_playlist.splice(player_nextIndex, 0, track);
player_playNext();
} else { } else {
player_playlist.push(track); this.play();
} }
} else {
this.queue(track, true);
}
}
}
if (!window.playerIsInitialised) {
window.player = new Player(initialQueue);
} }
</script> </script>

View File

@@ -9,6 +9,11 @@ interface Props {
const { track, index } = Astro.props as Props; const { track, index } = Astro.props as Props;
import Image from "astro/components/Image.astro"; import Image from "astro/components/Image.astro";
import { getAudioDurationInSeconds } from "get-audio-duration";
import { join } from "path";
const fullFilePath = join(process.cwd(), "public", track.data.src);
const duration = await getAudioDurationInSeconds(fullFilePath);
--- ---
<div <div
@@ -16,7 +21,7 @@ import Image from "astro/components/Image.astro";
"relative flex min-h-42 flex-auto hover:cursor-pointer", "relative flex min-h-42 flex-auto hover:cursor-pointer",
index < 2 ? "lg:w-1/2" : "sm:w-1/2 md:w-1/3 lg:w-1/4" index < 2 ? "lg:w-1/2" : "sm:w-1/2 md:w-1/3 lg:w-1/4"
]} ]}
onclick=`player_addToPlaylist(player_constructTrack(${JSON.stringify(track.data)}))` onclick=`window.player.addFromTrackCard({ src: "${track.data.src}", "metadata": ${JSON.stringify(track.data.metadata)}, "duration": "${duration}" })`
> >
<Image <Image
src={track.data.card.image.src} src={track.data.card.image.src}

View File

@@ -51,7 +51,7 @@ const tracks = defineCollection({
loader: glob({ pattern: "**/*.json", base: "./src/assets/tracks" }), loader: glob({ pattern: "**/*.json", base: "./src/assets/tracks" }),
schema: ({ image }) => schema: ({ image }) =>
z.object({ z.object({
src: z.string(), src: z.preprocess((val) => `/audio/${val}`, z.string()),
metadata: z.object({ metadata: z.object({
title: z.string(), title: z.string(),
subtitle: z.string().optional(), subtitle: z.string().optional(),

2
src/env.d.ts vendored
View File

@@ -9,4 +9,6 @@ interface Window {
getDefaultTheme: () => "auto" | "dark" | "light"; getDefaultTheme: () => "auto" | "dark" | "light";
}; };
navbarDisplay: "normal" | "transparent"; navbarDisplay: "normal" | "transparent";
player: any;
playerIsInitialised: boolean;
} }

View File

@@ -7,14 +7,6 @@ interface Props {
image?: SiteImage; image?: SiteImage;
} }
import "@/styles/global.css";
import { getCollection } from "astro:content";
import Footer from "@components/Footer.astro";
import Navbar from "@components/Navbar.astro";
import Player from "@components/Player.astro";
import MainHead from "@layouts/MainHead.astro";
const { const {
title, title,
subtitle, subtitle,
@@ -23,11 +15,12 @@ const {
image image
} = Astro.props; } = Astro.props;
const autoQueuedTracks = ( import "@/styles/global.css";
await getCollection("tracks", ({ data }) => data.autoQueue)
).sort( import Footer from "@components/Footer.astro";
(a, b) => (a.data.autoQueue?.order || -1) - (b.data.autoQueue?.order || 1) import Navbar from "@components/Navbar.astro";
); import Player from "@components/Player.astro";
import MainHead from "@layouts/MainHead.astro";
--- ---
<!doctype html> <!doctype html>
@@ -42,9 +35,3 @@ const autoQueuedTracks = (
<Player /> <Player />
</body> </body>
</html> </html>
<script define:vars={{ autoQueuedTracks }}>
autoQueuedTracks.map((track, index) => {
player_addToPlaylist(player_constructTrack(track.data), false);
});
</script>

View File

@@ -31,7 +31,9 @@ const hero = getProjectHero(project);
const seoImage: SiteImage = { const seoImage: SiteImage = {
externalURL: await getFullExternalURLOfImage(hero), externalURL: await getFullExternalURLOfImage(hero),
src: hero.src, src: hero.src,
alt: project.data.images.hero.alt alt: project.data.images.hero.alt,
width: hero.width,
height: hero.height
}; };
--- ---

View File

@@ -2,4 +2,6 @@ type SiteImage = {
src: string; src: string;
alt: string; alt: string;
externalURL?: string; externalURL?: string;
width: number;
height: number;
}; };