(null);
+ const onErrorRef = useRef(onError);
+ onErrorRef.current = onError;
+ const [loading, setLoading] = useState(true);
+ const [youTubeReady, setYouTubeReady] = useState(false);
+
+ useEffect(() => {
+ if (mediaType !== "youtube") {
+ return;
+ }
+
+ void (async () => {
+ try {
+ await import("youtube-video-element");
+ setYouTubeReady(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;
+ mediaRef(el as HTMLMediaElement | null);
+ },
+ [mediaRef],
+ );
+
+ useEffect(() => {
+ const el = mediaElRef.current;
+ if (!el) {
+ return undefined;
+ }
+
+ const handleError = () => onErrorRef.current?.();
+ const handleLoaded = () => setLoading(false);
+
+ // Check if media is already loaded (event fired before listener attached)
+ if ((el as HTMLMediaElement).readyState >= 2) {
+ setLoading(false);
+ }
+
+ el.addEventListener("error", handleError);
+ // youtube-video-element may not fire `loadeddata`; listen for
+ // `loadedmetadata` as well so the spinner always clears.
+ el.addEventListener("loadedmetadata", handleLoaded);
+ el.addEventListener("loadeddata", handleLoaded);
+
+ return () => {
+ el.removeEventListener("error", handleError);
+ el.removeEventListener("loadedmetadata", handleLoaded);
+ el.removeEventListener("loadeddata", handleLoaded);
+ };
+ }, [mediaType, youTubeReady]);
+
+ let mediaElement: React.ReactNode = null;
+ switch (mediaType) {
+ case "youtube": {
+ if (youTubeReady) {
+ mediaElement = (
+
+ );
+ }
+
+ break;
+ }
+
+ case "video": {
+ mediaElement = (
+
+ );
+ break;
+ }
+ }
+
+ return (
+ <>
+ {loading && (
+
+
+
+ )}
+ event.stopPropagation()}
+ className={cn(
+ mediaType === "youtube"
+ ? YOUTUBE_CONTROLLER_CLASS
+ : VIDEO_CONTROLLER_CLASS,
+ loading && "absolute opacity-0",
+ )}
+ >
+ {mediaElement}
+
+
+
+
+ el?.setAttribute("notooltip", "")}>
+
+
+
+
+
+
el?.setAttribute("notooltip", "")}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
el?.setAttribute("notooltip", "")}
+ >
+
+
+
+
+
+
+
+
+
+ {mediaType === "video" && (
+ el?.setAttribute("notooltip", "")}>
+
+
+ )}
+
+ el?.setAttribute("notooltip", "")}
+ >
+
+
+
+
+ >
+ );
+}
+
+export function MediaPlayer({
+ isActive,
+ mediaType,
+ onError,
+ src,
+ title,
+}: MediaPlayerProps) {
+ if (mediaType === "audio") {
+ return (
+
+
+
+ }
+ >
+
+
+ );
+ }
+
+ if (mediaType === "spotify") {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/spotify-embed.tsx b/src/components/spotify-embed.tsx
new file mode 100644
index 000000000..48f989d54
--- /dev/null
+++ b/src/components/spotify-embed.tsx
@@ -0,0 +1,34 @@
+import { getSpotifyEmbedInfo } from "./lightbox/LightboxUtils";
+
+export interface SpotifyEmbedProps {
+ src: string;
+}
+
+export function SpotifyEmbed({ src }: SpotifyEmbedProps) {
+ const embedInfo = getSpotifyEmbedInfo(src);
+
+ if (!embedInfo) {
+ return null;
+ }
+
+ return (
+ event.stopPropagation()}
+ >
+
+
+ );
+}
diff --git a/src/icons/actionIcons/audioIcon.tsx b/src/icons/actionIcons/audioIcon.tsx
deleted file mode 100644
index 3dde68c0a..000000000
--- a/src/icons/actionIcons/audioIcon.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-const AudioIcon = ({ className }: { className: string }) => (
-
-);
-
-export default AudioIcon;
diff --git a/src/styles/globals.css b/src/styles/globals.css
index eed46c241..4c0b70b7d 100755
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -11,6 +11,7 @@
/* Light Gray Shades (from figma variables) */
--color-gray-0: #fff;
--color-gray-50: #fff;
+ --color-gray-75: #f5f5f5;
--color-gray-90: #fff;
--color-gray-100: #f5f5f5;
--color-gray-200: #ededed;
@@ -221,6 +222,7 @@
/* Dark Gray Shades (from figma variables) */
--color-gray-0: #121212;
--color-gray-50: #1c1c1c;
+ --color-gray-75: #0d0d0d;
--color-gray-90: #2d2d2d;
--color-gray-100: #212121;
--color-gray-200: #292929;
@@ -475,6 +477,7 @@
/* Gray colors */
--color-gray-0: var(--color-gray-0);
--color-gray-50: var(--color-gray-50);
+ --color-gray-75: var(--color-gray-75);
--color-gray-90: var(--color-gray-90);
--color-gray-100: var(--color-gray-100);
--color-gray-200: var(--color-gray-200);
diff --git a/src/types/youtube-video-element.d.ts b/src/types/youtube-video-element.d.ts
index 7a747c7f6..35db8e3ea 100644
--- a/src/types/youtube-video-element.d.ts
+++ b/src/types/youtube-video-element.d.ts
@@ -1,5 +1,4 @@
-// eslint-disable-next-line import/no-unassigned-import -- required for module augmentation
-import "react";
+import { type DetailedHTMLProps, type VideoHTMLAttributes } from "react";
declare module "react" {
namespace JSX {
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 8492d7f43..2287e836e 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -26,8 +26,8 @@ export const STORAGE_SCREENSHOT_VIDEOS_PATH =
export const STORAGE_FILES_PATH = FILES_STORAGE_NAME + "/public";
export const STORAGE_USER_PROFILE_PATH = USER_PROFILE_STORAGE_NAME + "/public";
-// Fallback ogImage for audio bookmarks (no cover art)
-export const AUDIO_OG_IMAGE_FALLBACK_URL = `${BASE_URL}/audio-fallback.png`;
+// Fallback ogImage for audio bookmarks; matches waveform in @/icons/audio-icon.tsx
+export const AUDIO_OG_IMAGE_FALLBACK_URL = `${BASE_URL}/audio-icon.svg`;
// Video upload limits
export const VIDEO_DOWNLOAD_TIMEOUT_MS = 60_000;