code.pablomurad.com

mymusics

Retro web player for random tracks from the Myspace Dragon Hoard

FIX: player
author
Pablo Murad
date
commit
4cdd32a343ce70f898d7419f498fa4ee76da1dc6
parent
71ad757a42523f92f12dbf1793c009d6f66c717a
changes
5 files +204 −21
@@ -509,15 +509,95 @@ margin-bottom: 0; }
 
 .embed-page {
-  min-height: 100vh;
+  min-height: 0;
   margin: 0;
-  padding: 0.75rem;
+  padding: 0.45rem;
   box-sizing: border-box;
 }
 
 .embed-shell {
-  max-width: 420px;
+  max-width: 380px;
   margin: 0 auto;
+}
+
+.embed-shell .card {
+  padding: 0.65rem 0.75rem 0.75rem;
+}
+
+.embed-shell .card-head {
+  margin-bottom: 0.5rem;
+}
+
+.embed-shell .card h2 {
+  font-size: 1rem;
+}
+
+.embed-shell .track-block .artist {
+  font-size: 1.08rem;
+  margin: 0 0 0.2rem;
+}
+
+.embed-shell .track-block .title {
+  font-size: 0.98rem;
+  margin: 0 0 0.35rem;
+}
+
+.embed-shell .up-next {
+  margin-top: 0.45rem;
+  padding: 0.5rem 0.65rem;
+}
+
+.embed-shell .up-next-label {
+  font-size: 0.78rem;
+  margin: 0 0 0.3rem;
+}
+
+.embed-shell .up-next-track {
+  font-size: 0.84rem;
+}
+
+.embed-shell .player-nook {
+  margin-top: 0.45rem;
+  padding: 0.65rem 0.6rem 0.65rem;
+}
+
+.embed-shell .cozy-player {
+  padding: 0.45rem 0.55rem;
+  gap: 0.45rem;
+}
+
+.embed-shell .cozy-player__play,
+.embed-shell .cozy-player__mute {
+  width: 2.15rem;
+  height: 2.15rem;
+}
+
+.embed-shell .cozy-player__icon {
+  width: 1.15rem;
+  height: 1.15rem;
+}
+
+.embed-shell .cozy-player__time {
+  font-size: 0.72rem;
+}
+
+.embed-shell .actions {
+  margin-top: 0.55rem;
+  gap: 0.45rem;
+}
+
+.embed-shell .btn.primary {
+  padding: 0.5rem 1.1rem;
+  font-size: 0.92rem;
+}
+
+.embed-shell .check {
+  font-size: 0.76rem;
+}
+
+.embed-shell .hint {
+  margin: 0.45rem 0 0;
+  font-size: 0.78rem;
 }
 
 .health-banner--embed {
@@ -1,4 +1,4 @@-import { type ChangeEvent, type RefObject, useCallback, useEffect, useState } from "react";
+import { type FormEvent, type RefObject, useCallback, useEffect, useRef, useState } from "react";
 
 function formatTime(seconds: number): string {
   if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
@@ -7,6 +7,30 @@ const sec = Math.floor(seconds % 60);   return `${m}:${sec.toString().padStart(2, "0")}`;
 }
 
+function formatDurationLabel(seconds: number): string {
+  if (!Number.isFinite(seconds) || seconds <= 0) return "--:--";
+  return formatTime(seconds);
+}
+
+/** Duration from element metadata or seekable ranges (streaming MP3). */
+function safeDuration(el: HTMLAudioElement): number {
+  const d = el.duration;
+  if (Number.isFinite(d) && d > 0 && d !== Number.POSITIVE_INFINITY) {
+    return d;
+  }
+  try {
+    if (el.seekable && el.seekable.length > 0) {
+      const end = el.seekable.end(el.seekable.length - 1);
+      if (Number.isFinite(end) && end > 0 && end !== Number.POSITIVE_INFINITY) {
+        return end;
+      }
+    }
+  } catch {
+    /* ignore */
+  }
+  return 0;
+}
+
 type Props = {
   audioRef: RefObject<HTMLAudioElement | null>;
   /** No track / no usable stream */
@@ -18,46 +42,88 @@ const [currentTime, setCurrentTime] = useState(0);   const [duration, setDuration] = useState(0);
   const [playing, setPlaying] = useState(false);
   const [muted, setMuted] = useState(false);
+  const scrubbingRef = useRef(false);
 
   useEffect(() => {
     const el = audioRef.current;
     if (!el) return;
 
+    const syncDuration = () => {
+      setDuration(safeDuration(el));
+    };
+
     const syncFromElement = () => {
-      setCurrentTime(el.currentTime);
-      const d = el.duration;
-      setDuration(Number.isFinite(d) && d > 0 ? d : 0);
+      if (!scrubbingRef.current) {
+        setCurrentTime(el.currentTime);
+      }
+      syncDuration();
       setPlaying(!el.paused);
       setMuted(el.muted);
     };
 
-    const onTimeUpdate = () => setCurrentTime(el.currentTime);
+    const onTimeUpdate = () => {
+      if (!scrubbingRef.current) {
+        setCurrentTime(el.currentTime);
+      }
+    };
+
     const onLoadedMeta = () => syncFromElement();
+    const onProgress = () => syncDuration();
+    const onLoadedData = () => syncDuration();
+    const onCanPlay = () => syncDuration();
+    const onSeeked = () => {
+      scrubbingRef.current = false;
+      setCurrentTime(el.currentTime);
+      syncDuration();
+    };
+    const onSeeking = () => {
+      setCurrentTime(el.currentTime);
+    };
     const onPlay = () => setPlaying(true);
     const onPause = () => setPlaying(false);
     const onVol = () => setMuted(el.muted);
 
     el.addEventListener("timeupdate", onTimeUpdate);
     el.addEventListener("loadedmetadata", onLoadedMeta);
-    el.addEventListener("durationchange", onLoadedMeta);
+    el.addEventListener("durationchange", syncDuration);
+    el.addEventListener("progress", onProgress);
+    el.addEventListener("loadeddata", onLoadedData);
+    el.addEventListener("canplay", onCanPlay);
+    el.addEventListener("seeked", onSeeked);
+    el.addEventListener("seeking", onSeeking);
     el.addEventListener("play", onPlay);
     el.addEventListener("pause", onPause);
     el.addEventListener("volumechange", onVol);
 
     syncFromElement();
 
+    const endScrub = () => {
+      scrubbingRef.current = false;
+      setCurrentTime(el.currentTime);
+    };
+    window.addEventListener("pointerup", endScrub);
+    window.addEventListener("pointercancel", endScrub);
+
     return () => {
       el.removeEventListener("timeupdate", onTimeUpdate);
       el.removeEventListener("loadedmetadata", onLoadedMeta);
-      el.removeEventListener("durationchange", onLoadedMeta);
+      el.removeEventListener("durationchange", syncDuration);
+      el.removeEventListener("progress", onProgress);
+      el.removeEventListener("loadeddata", onLoadedData);
+      el.removeEventListener("canplay", onCanPlay);
+      el.removeEventListener("seeked", onSeeked);
+      el.removeEventListener("seeking", onSeeking);
       el.removeEventListener("play", onPlay);
       el.removeEventListener("pause", onPause);
       el.removeEventListener("volumechange", onVol);
+      window.removeEventListener("pointerup", endScrub);
+      window.removeEventListener("pointercancel", endScrub);
     };
   }, [audioRef]);
 
+  const dur = duration;
   const pct =
-    duration > 0 && Number.isFinite(duration) ? Math.min(100, (currentTime / duration) * 100) : 0;
+    dur > 0 && Number.isFinite(dur) ? Math.min(100, Math.max(0, (currentTime / dur) * 100)) : 0;
 
   const togglePlay = useCallback(() => {
     const el = audioRef.current;
@@ -66,17 +132,29 @@ if (el.paused) void el.play().catch(() => {});     else el.pause();
   }, [audioRef, disabled]);
 
-  const onSeek = useCallback(
-    (e: ChangeEvent<HTMLInputElement>) => {
+  const applySeekPercent = useCallback(
+    (pctValue: number) => {
       const el = audioRef.current;
-      if (!el || disabled || duration <= 0) return;
-      const next = (parseFloat(e.target.value) / 100) * duration;
+      if (!el || disabled || dur <= 0) return;
+      const clamped = Math.min(100, Math.max(0, pctValue));
+      const next = (clamped / 100) * dur;
       el.currentTime = next;
       setCurrentTime(next);
     },
-    [audioRef, disabled, duration],
+    [audioRef, disabled, dur],
   );
 
+  const onSeek = useCallback(
+    (e: FormEvent<HTMLInputElement>) => {
+      applySeekPercent(parseFloat(e.currentTarget.value));
+    },
+    [applySeekPercent],
+  );
+
+  const startScrub = useCallback(() => {
+    scrubbingRef.current = true;
+  }, []);
+
   const toggleMute = useCallback(() => {
     const el = audioRef.current;
     if (!el || disabled) return;
@@ -84,8 +162,9 @@ el.muted = !el.muted;     setMuted(el.muted);
   }, [audioRef, disabled]);
 
-  const durLabel = formatTime(duration);
+  const durLabel = formatDurationLabel(dur);
   const curLabel = formatTime(currentTime);
+  const seekDisabled = disabled || dur <= 0;
 
   return (
     <div
@@ -119,10 +198,12 @@ type="range"           className="cozy-player__scrub"
           min={0}
           max={100}
-          step={0.25}
+          step={0.05}
           value={pct}
+          onPointerDown={startScrub}
+          onInput={onSeek}
           onChange={onSeek}
-          disabled={disabled || duration <= 0}
+          disabled={seekDisabled}
           aria-label="Seek"
           aria-valuemin={0}
           aria-valuemax={100}
@@ -7,8 +7,8 @@ return `<iframe   src="${src}"
   title="MyMusics"
   width="100%"
-  height="420"
-  style="max-width:420px;border:0;border-radius:12px"
+  height="500"
+  style="max-width:380px;border:0;border-radius:12px"
   loading="lazy"
 ></iframe>`;
 }
@@ -85,3 +85,15 @@ audio:focus-visible {   outline: 2px solid var(--logo-gold);
   outline-offset: 3px;
 }
+
+/* /embed iframe: avoid min-height:100vh forcing scroll inside short iframes */
+html.embed-active,
+html.embed-active body,
+html.embed-active #root {
+  min-height: 0 !important;
+  height: auto;
+}
+
+html.embed-active body {
+  overflow-x: hidden;
+}
@@ -1,8 +1,18 @@+import { useEffect } from "react";
 import { CozyAudioBar } from "../components/CozyAudioBar";
 import { useMyMusicsPlayback } from "../hooks/useMyMusicsPlayback";
 import "../App.css";
+
+const EMBED_ROOT_CLASS = "embed-active";
 
 export default function Embed() {
+  useEffect(() => {
+    document.documentElement.classList.add(EMBED_ROOT_CLASS);
+    return () => {
+      document.documentElement.classList.remove(EMBED_ROOT_CLASS);
+    };
+  }, []);
+
   const {
     audioRef,
     track,