diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index 8e0a8c6fb..6821e5490 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -10,7 +10,6 @@ anthropics apng appl asteasolutions -Atleast attrelid blks breakpointmd diff --git a/package.json b/package.json index bf1aa21cd..730172821 100644 --- a/package.json +++ b/package.json @@ -137,9 +137,9 @@ "react-toastify": "11.0.5", "resend": "6.9.1", "slugify": "1.6.6", - "spotify-audio-element": "1.0.4", "tailwind-merge": "3.4.0", "uniqid": "5.4.0", + "wavesurfer.js": "7.12.1", "yet-another-react-lightbox": "3.28.0", "youtube-video-element": "1.9.0", "zod": "4.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10eb4e03a..bef49d491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,15 +174,15 @@ importers: slugify: specifier: 1.6.6 version: 1.6.6 - spotify-audio-element: - specifier: 1.0.4 - version: 1.0.4 tailwind-merge: specifier: 3.4.0 version: 3.4.0 uniqid: specifier: 5.4.0 version: 5.4.0 + wavesurfer.js: + specifier: 7.12.1 + version: 7.12.1 yet-another-react-lightbox: specifier: 3.28.0 version: 3.28.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -8594,9 +8594,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - spotify-audio-element@1.0.4: - resolution: {integrity: sha512-QdKrJPkYCzaNwwz2vN2eDGyoW0KmQFmnwVprB41mpMzj4qujbqr6pegEchQeTn0b5PceKiLoVu0pp2QDpTcWnw==} - sshpk@1.18.0: resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} engines: {node: '>=0.10.0'} @@ -9183,6 +9180,9 @@ packages: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} + wavesurfer.js@7.12.1: + resolution: {integrity: sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -19240,8 +19240,6 @@ snapshots: split2@4.2.0: {} - spotify-audio-element@1.0.4: {} - sshpk@1.18.0: dependencies: asn1: 0.2.6 @@ -19851,6 +19849,8 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + wavesurfer.js@7.12.1: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} diff --git a/public/audio-icon.svg b/public/audio-icon.svg new file mode 100644 index 000000000..e8badee14 --- /dev/null +++ b/public/audio-icon.svg @@ -0,0 +1,6 @@ + diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx deleted file mode 100644 index 9424ea263..000000000 --- a/src/components/MediaPlayer.tsx +++ /dev/null @@ -1,294 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - MediaControlBar, - MediaController, - MediaFullscreenButton, - MediaMuteButton, - MediaPipButton, - MediaPlayButton, - MediaPreviewChapterDisplay, - MediaPreviewThumbnail, - MediaPreviewTimeDisplay, - MediaTimeDisplay, - MediaTimeRange, - MediaVolumeRange, -} from "media-chrome/react"; -import { MediaProvider, useMediaRef } from "media-chrome/react/media-store"; -import { - MediaPlaybackRateMenu, - MediaPlaybackRateMenuButton, -} from "media-chrome/react/menu"; - -import { - FullscreenIcon, - MuteIcon, - PipIcon, - PlayPauseIcon, - SettingsIcon, -} from "./media-player-icons"; -import { - AUDIO_CONTROL_BAR_STYLE, - AUDIO_CONTROLLER_STYLE, - CONTROL_BAR_STYLE, - CONTROLLER_STYLE, - MEDIA_STYLE, - YOUTUBE_CONTROLLER_STYLE, -} from "./media-player-theme"; -import { Spinner } from "./spinner"; - -import "./media-player-theme.css"; - -export type MediaType = "audio" | "spotify" | "video" | "youtube"; - -export interface MediaPlayerProps { - isActive?: boolean; - mediaType: MediaType; - onError?: () => void; - src: string; -} - -function isAudioType(mediaType: MediaType): boolean { - return mediaType === "audio" || mediaType === "spotify"; -} - -function getControllerStyle(mediaType: MediaType) { - if (isAudioType(mediaType)) { - return AUDIO_CONTROLLER_STYLE; - } - - if (mediaType === "youtube") { - return YOUTUBE_CONTROLLER_STYLE; - } - - return CONTROLLER_STYLE; -} - -/* ---- Main player ---- */ - -function MediaPlayerInner({ - isActive, - mediaType, - onError, - src, -}: MediaPlayerProps) { - const mediaRef = useMediaRef(); - const mediaElRef = useRef(null); - const onErrorRef = useRef(onError); - onErrorRef.current = onError; - const [loading, setLoading] = useState(true); - const [youTubeReady, setYouTubeReady] = useState(false); - const [spotifyReady, setSpotifyReady] = useState(false); - - const isAudio = isAudioType(mediaType); - - useEffect(() => { - if (mediaType !== "youtube") { - return; - } - - void (async () => { - try { - await import("youtube-video-element"); - setYouTubeReady(true); - } catch { - onErrorRef.current?.(); - } - })(); - }, [mediaType]); - - useEffect(() => { - if (mediaType !== "spotify") { - return; - } - - void (async () => { - try { - await import("spotify-audio-element"); - setSpotifyReady(true); - } catch { - onErrorRef.current?.(); - } - })(); - }, [mediaType]); - - useEffect(() => { - const el = mediaElRef.current; - if (!el || !("play" in el)) { - return; - } - - if (isActive) { - void (el as HTMLMediaElement).play(); - } else { - (el as HTMLMediaElement).pause(); - } - }, [isActive]); - - const ref = useCallback( - (el: HTMLElement | null) => { - mediaElRef.current = el; - // Custom elements (spotify-audio) implement the HTMLMediaElement API - // at runtime but extend HTMLElement, so cast for media-chrome - mediaRef(el as HTMLMediaElement | null); - - if (!el) { - return undefined; - } - - const handleError = () => onErrorRef.current?.(); - const handleLoaded = () => setLoading(false); - el.addEventListener("error", handleError); - el.addEventListener("loadeddata", handleLoaded); - return () => { - el.removeEventListener("error", handleError); - el.removeEventListener("loadeddata", handleLoaded); - mediaRef(null); - }; - }, - [mediaRef], - ); - - let mediaElement: React.ReactNode = null; - switch (mediaType) { - case "youtube": { - if (youTubeReady) { - mediaElement = ( - - ); - } - - break; - } - - case "spotify": { - if (spotifyReady) { - mediaElement = ; - } - - break; - } - - case "audio": { - mediaElement = ( - - ); - break; - } - - case "video": { - mediaElement = ( - - ); - break; - } - } - - // Audio types don't need the loading gate — the