Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
40174e3
feat(video-player): 🎥 integrate media-chrome for enhanced video playback
rogerantony-dev Feb 22, 2026
96ed2cf
feat(video-player): 🎨 add new icons and enhance video player controls
rogerantony-dev Feb 23, 2026
1963385
fix(video-player): 🛠️ enhance layout and visibility of video player c…
rogerantony-dev Feb 23, 2026
f744741
fix(video-player): 🛠️ prevent slider drags from triggering lightbox n…
rogerantony-dev Feb 23, 2026
9196e6f
chore(cspell): 📝 add new project-specific words to cspell dictionary
rogerantony-dev Feb 23, 2026
c2ff0c2
chore(dependencies): update media-chrome to version 4.18.0 in package…
rogerantony-dev Feb 23, 2026
d052e14
refactor(video-player): 🛠️ remove isActive prop from VideoPlayer
rogerantony-dev Feb 23, 2026
e26c1af
fix(video-player): 🛠️ improve YouTube video loading logic and adjust …
rogerantony-dev Feb 23, 2026
51bdeb3
fix(video-player): 🛠️ enable autoPlay for YouTube and HTML5 videos
rogerantony-dev Feb 23, 2026
fe646ea
style(video-player): 🎨 add invisible bridge for improved layout betwe…
rogerantony-dev Feb 23, 2026
476e8c6
refactor(video-player): 🛠️ simplify SettingsIcon component by removin…
rogerantony-dev Feb 23, 2026
1a3a9ac
fix(video-player): 🛠️ ensure media reference is cleared on error even…
rogerantony-dev Feb 23, 2026
109d6c7
fix(video-player): 🛠️ handle errors during YouTube video import to im…
rogerantony-dev Feb 23, 2026
5edcdf7
feat(video-player): 🎥 add isActive prop to control video playback state
rogerantony-dev Feb 23, 2026
63a8ac5
fix(video-player): 🛠️ update event handler to use onPointerDown for b…
rogerantony-dev Feb 23, 2026
6a31b7e
feat(video-player): 🎨 add MEDIA_STYLE for responsive video element si…
rogerantony-dev Feb 23, 2026
fae593c
style(video-player): 🎨 update PiP visibility comment for clarity on b…
rogerantony-dev Feb 23, 2026
5e5d77d
feat(video-player): 🎥 add loading spinner and bottom gradient
rogerantony-dev Feb 23, 2026
f57a308
style(video-player): 🎨 adjust bottom gradient height
rogerantony-dev Feb 23, 2026
fe985b8
feat(media-player): 🎥 implement MediaPlayer component for audio, vid…
rogerantony-dev Feb 24, 2026
8cbe01c
style(media-player): 🎨 update media control button backgrounds to tra…
rogerantony-dev Feb 27, 2026
c7ce44a
style(media-player): 🎨 add font-family to media time and preview disp…
rogerantony-dev Feb 27, 2026
4fdbcd2
Merge branch 'dev' into feat/media-chrome-video-player
rogerantony-dev Feb 27, 2026
5d12bb2
Merge branch 'dev' into feat/media-chrome-video-player
rogerantony-dev Feb 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .cspell/project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ asteasolutions
Atleast
attrelid
blks
breakpointmd
btrim
BTRIM
catagories
Expand Down Expand Up @@ -56,6 +57,7 @@ infile
jobid
kieran
Loggedin
mediaisfullscreen
micnncim
msclkid
msvideo
Expand All @@ -71,6 +73,7 @@ Navin
noimageindex
normalised
Normalised
notooltip
nuqs
Nuqs
ogimage
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"jwt-decode": "4.0.0",
"lodash": "4.17.23",
"match-sorter": "8.2.0",
"media-chrome": "4.18.0",
"motion": "12.29.2",
"next": "16.1.6",
"next-themes": "0.4.6",
Expand All @@ -134,14 +135,14 @@
"react-infinite-scroll-component": "6.1.1",
"react-mentions": "4.4.10",
"react-modern-drawer": "1.4.0",
"react-player": "3.4.0",
"react-stately": "3.43.0",
"react-toastify": "11.0.5",
"resend": "6.9.1",
"slugify": "1.6.6",
"tailwind-merge": "3.4.0",
"uniqid": "5.4.0",
"yet-another-react-lightbox": "3.28.0",
"youtube-video-element": "1.9.0",
"zod": "4.3.6",
"zustand": "5.0.10"
},
Expand Down
448 changes: 17 additions & 431 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

172 changes: 151 additions & 21 deletions src/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,155 @@
import React, { useRef } from "react";
import ReactPlayer from "react-player";

export const VideoPlayer = ({
src,
isActive,
onError,
}: {
isActive: boolean;
src: string;
"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 "./video-player-icons";
import {
CONTROL_BAR_STYLE,
CONTROLLER_STYLE,
YOUTUBE_CONTROLLER_STYLE,
} from "./video-player-theme";

import "./video-player-theme.css";

export interface VideoPlayerProps {
isYouTube?: boolean;
onError?: () => void;
}) => {
const playerRef = useRef<HTMLVideoElement | null>(null);
src: string;
}

/* ---- Main player ---- */

function VideoPlayerInner({ isYouTube, onError, src }: VideoPlayerProps) {
const mediaRef = useMediaRef();
const onErrorRef = useRef(onError);
onErrorRef.current = onError;

const [youTubeReady, setYouTubeReady] = useState(false);

useEffect(() => {
if (!isYouTube) {
return;
}

void (async () => {
await import("youtube-video-element");
setYouTubeReady(true);
})();
}, [isYouTube]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unhandled failure of youtube-video-element dynamic import — player silently becomes non-functional.

If the dynamic import rejects (network error, etc.), the async IIFE throws inside void (async () => {...})(), the rejection is swallowed, setYouTubeReady(true) is never called, and the user sees an empty/loading MediaController indefinitely with no error feedback or fallback.

🛠️ Proposed fix
-	void (async () => {
-		await import("youtube-video-element");
-		setYouTubeReady(true);
-	})();
+	import("youtube-video-element")
+		.then(() => {
+			setYouTubeReady(true);
+		})
+		.catch(() => {
+			onErrorRef.current?.();
+		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/VideoPlayer.tsx` around lines 59 - 63, The dynamic import of
"youtube-video-element" inside the async IIFE can reject and leaves the player
stuck; wrap the import in a try/catch inside the effect that uses isYouTube, and
on error log the exception and set a failure state so the UI can show a fallback
instead of waiting forever. Concretely, update the block that calls
import("youtube-video-element") / setYouTubeReady to catch rejects, call
setYouTubeReady(false) (and/or a new setYouTubeError state), log the error
(console.error or your app logger), and ensure MediaController/VideoPlayer
renders a graceful fallback or error message when that error state is set.


const ref = useCallback(
(el: HTMLVideoElement | null) => {
mediaRef(el);

if (!el) {
return undefined;
}

const handleError = () => onErrorRef.current?.();
el.addEventListener("error", handleError);
return () => el.removeEventListener("error", handleError);
},
[mediaRef],
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

React 19 ref callback cleanup function returns prevents null call unmount

💡 Result:

In React 19, a callback ref may return a cleanup function:

<div
  ref={(node) => {
    if (!node) return;          // (optional) ignore detach
    // setup using node
    return () => {
      // cleanup using the same node
    };
  }}
/>

What changes vs older React:

  • If your ref callback returns a cleanup function, React will call that cleanup when the ref is detached (including unmount), and it will skip calling your ref callback with null on unmount. This is the behavior you’re referring to (“return prevents the null call”). [1][2]
  • For backwards compatibility, if you don’t return a cleanup function, React still uses the old behavior and calls the ref callback with null when detaching—but React says this null-on-detach behavior will be removed in a future version. [2]
  • React also detaches/attaches when the ref callback identity changes (e.g., inline (node) => {} on every render). If you want to avoid unnecessary detach/attach cycles, keep the ref callback stable (e.g., useCallback). [2]

Sources: React 19 announcement + the ref callback docs on react.dev. [1][2]


🌐 Web query:

media-chrome useMediaRef documentation React cleanup null

💡 Result:

useMediaRef() is a React hook exported from media-chrome/react/media-store and is the intended way to “wire up” your <video>/<audio> (or compatible custom media element) to Media Chrome’s MediaStore (so useMediaSelector()/useMediaDispatch() work). You use it by passing it directly to the element’s ref prop. [1]

If you’re seeing cleanup errors like “cannot read … removeEventListener of null”, the common cause is that ref.current is mutable and may be set to null before your effect cleanup runs. The fix is to capture the element in a local variable inside the effect and use that for both add/remove. [2]

const mediaRef = useMediaRef();

useEffect(() => {
  const el = mediaRef.current; // capture once
  if (!el) return;

  const onTimeUpdate = () => {};
  el.addEventListener("timeupdate", onTimeUpdate);

  return () => {
    el.removeEventListener("timeupdate", onTimeUpdate);
  };
}, []);

Sources: Media Chrome “MediaStore React Hooks” docs (includes useMediaRef() usage) [1]; explanation of ref.current becoming null before cleanup + “capture in effect” pattern [2].


🏁 Script executed:

fd VideoPlayer.tsx

Repository: timelessco/recollect

Length of output: 94


🏁 Script executed:

# Once we have the file path, examine it
if [ -f "src/components/VideoPlayer.tsx" ]; then
  cat -n src/components/VideoPlayer.tsx | head -100
fi

Repository: timelessco/recollect

Length of output: 3003


🏁 Script executed:

# Search for media-chrome imports and useMediaRef usage
rg "useMediaRef|media-chrome" -B2 -A2 --type ts --type tsx

Repository: timelessco/recollect

Length of output: 91


🏁 Script executed:

rg "useMediaRef|from.*media-chrome" -t ts -t js

Repository: timelessco/recollect

Length of output: 360


🌐 Web query:

media-chrome MediaStore useMediaRef cleanup null unregister unmount

💡 Result:

In media-chrome/react/media-store, the intended “cleanup/unregister on unmount” behavior is usually driven by the ref callback receiving null (React calls a callback ref with null when the element unmounts or the ref moves).

So the key is: use the callback ref returned by useMediaRef() directly on your media element, and don’t “hide” it behind another ref pattern that prevents the null call from reaching MediaStore.

import { MediaProvider, useMediaRef } from "media-chrome/react/media-store";

function Player() {
  const mediaRef = useMediaRef(); // callback ref

  return (
    <MediaProvider>
      <video ref={mediaRef} />
    </MediaProvider>
  );
}

Common gotchas that can break cleanup:

  • Don’t convert it to an object ref (useRef) and only copy .current; you’ll miss the null callback.
  • Don’t wrap it in a callback that ignores null:
    const mediaRef = useMediaRef();
    const bad = (el) => { if (el) mediaRef(el); }; // blocks cleanup
  • If you need composition, forward null too:
    const mediaRef = useMediaRef();
    const composed = (el) => { mediaRef(el); /* your logic */ };

If you’re chasing leaks specifically: Media Chrome has been actively patching “disconnect/unmount” cleanup (removing listeners, disconnecting observers, etc.) in recent work (merged Feb 19, 2026). If you’re on an older media-chrome version, upgrading may matter. (github.com)

References:

  • media-chrome exports the React media-store entrypoint (./react/media-store). (app.unpkg.com)
  • Example usage of useMediaRef from media-chrome/react/media-store in the wild. (diceui.com)

Citations:


🌐 Web query:

"media-chrome" "useMediaRef" source code implementation state cleanup

💡 Result:

In media-chrome, the React hook useMediaRef() is used as a ref callback you compose onto your <video>/<audio> element so the Media Store can be told when the backing media element changes (example usage shows const mediaRefCallback = useMediaRef(); ... ref={useComposedRefs(..., mediaRefCallback)}), which in turn triggers the store’s “media element changed” flow. [1]

On the Media Store side, the key “state cleanup” logic for custom state wiring lives in createMediaStore()updateStateOwners() inside src/js/media-store/media-store.ts. Each state in the stateMediator can define stateOwnersUpdateHandlers (setup hooks that may return teardown functions). When any “state owner” changes (media element, textTracks, root node, etc.), the store:

  • decides whether it must teardown and/or setup monitoring (teardownSomething / setupSomething),
  • runs teardowns, then runs setups and stores the returned teardown(s). [2]

A bug existed where only the last teardown returned by stateOwnersUpdateHandlers was being kept/called, leaking earlier listeners. That was fixed and merged Nov 17, 2025 (“Ensure all stateOwnersUpdateHandlers are properly torn down”). The fix:

  • stores all teardown functions (if multiple handlers return them),
  • on subsequent updates, normalizes previous teardown(s) into an array and calls every function,
  • filters out non-functions,
  • stores the new teardown(s) as a single function or an array depending on count. [3][2]

Relevant code (current behavior):

  • teardown all previous teardowns (array-or-single) when teardownSomething is true, then
  • setup new handlers and persist teardowns (single-or-array). [2]

Sources: Dice UI docs showing useMediaRef() ref-callback usage pattern [1]; Media Store implementation showing stateOwnersUpdateHandlers teardown/setup logic [2]; PR #1237 describing/fixing the multi-teardown leak and merged date [3].

[1] (diceui.com)
[2] (raw.githubusercontent.com)
[3] (github.com)

Citations:


React 19 ref cleanup skips mediaRef(null)MediaStore unregister is blocked on unmount.

In React 19, returning a cleanup function from a ref callback causes React to call that cleanup on unmount instead of invoking the callback with null. The returned cleanup at line 73 only removes the error listener — it never calls mediaRef(null). This breaks the contract that useMediaRef() from media-chrome/react/media-store relies on: the callback ref must receive null to trigger proper MediaStore cleanup and unregister the element.

The intended pattern for useMediaRef() is to pass it directly as the ref or forward it in a composed ref. When wrapped in a callback that blocks the null call, the MediaStore retains a stale reference to the detached element.

🛠️ Proposed fix
 const ref = useCallback(
     (el: HTMLVideoElement | null) => {
         mediaRef(el);

         if (!el) {
             return undefined;
         }

         const handleError = () => onErrorRef.current?.();
         el.addEventListener("error", handleError);
-        return () => el.removeEventListener("error", handleError);
+        return () => {
+            el.removeEventListener("error", handleError);
+            mediaRef(null);
+        };
     },
     [mediaRef],
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ref = useCallback(
(el: HTMLVideoElement | null) => {
mediaRef(el);
if (!el) {
return undefined;
}
const handleError = () => onErrorRef.current?.();
el.addEventListener("error", handleError);
return () => el.removeEventListener("error", handleError);
},
[mediaRef],
);
const ref = useCallback(
(el: HTMLVideoElement | null) => {
mediaRef(el);
if (!el) {
return undefined;
}
const handleError = () => onErrorRef.current?.();
el.addEventListener("error", handleError);
return () => {
el.removeEventListener("error", handleError);
mediaRef(null);
};
},
[mediaRef],
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/VideoPlayer.tsx` around lines 63 - 76, The callback ref
returned from useCallback (the ref assigned to the video element) currently
returns a cleanup that only removes the error listener, which in React 19
prevents mediaRef(null) from being called on unmount and blocks MediaStore
unregister; update the ref callback (the function stored in ref) so its cleanup
both removes the "error" listener (handleError) and explicitly calls
mediaRef(null) when cleaning up, and ensure the initial branch that handles el
being null still returns undefined; keep using onErrorRef.current for the
handler reference and preserve the existing listener add/remove logic.


return (
<MediaController
onPointerDownCapture={(event) => event.stopPropagation()}
style={isYouTube ? YOUTUBE_CONTROLLER_STYLE : CONTROLLER_STYLE}
>
{isYouTube ? (
youTubeReady && (
<youtube-video crossOrigin="" ref={ref} slot="media" src={src} />
)
) : (
<video ref={ref} slot="media" src={src}>
<track default kind="captions" label="No captions" srcLang="en" />
</video>
)}

<MediaControlBar style={CONTROL_BAR_STYLE}>
<MediaPlayButton>
<PlayPauseIcon />
</MediaPlayButton>

<div className="mute-group">
<div className="mute-group-inner">
<MediaMuteButton ref={(el) => el?.setAttribute("notooltip", "")}>
<MuteIcon />
</MediaMuteButton>
<div className="vol-wrap">
<MediaVolumeRange />
</div>
</div>
</div>

<MediaTimeDisplay />
<MediaTimeDisplay showDuration />

<MediaTimeRange>
<MediaPreviewThumbnail slot="preview" />
<MediaPreviewChapterDisplay slot="preview" />
<MediaPreviewTimeDisplay slot="preview" />
</MediaTimeRange>

<div className="settings-group">
<div className="settings-group-inner">
<MediaPlaybackRateMenuButton>
<span
className="flex size-full items-center justify-center"
slot="icon"
>
<SettingsIcon slotIcon={false} />
</span>
</MediaPlaybackRateMenuButton>
<div className="settings-menu-wrap">
<MediaPlaybackRateMenu hidden />
</div>
</div>
</div>
{!isYouTube && (
<MediaPipButton>
<PipIcon />
</MediaPipButton>
)}

<MediaFullscreenButton>
<FullscreenIcon />
</MediaFullscreenButton>
</MediaControlBar>
</MediaController>
);
}

export function VideoPlayer(props: VideoPlayerProps) {
return (
<ReactPlayer
height="100%"
playing={isActive}
ref={playerRef}
src={src}
style={{ maxHeight: "80vh", margin: "auto", width: "100%" }}
onError={onError}
/>
<MediaProvider>
<VideoPlayerInner {...props} />
</MediaProvider>
);
};
}
8 changes: 2 additions & 6 deletions src/components/lightbox/LightBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,7 @@ export const CustomLightBox = ({
);
} else {
content = (
<VideoSlide
bookmark={bookmark}
isActive={isActive}
onVideoError={handleVideoError}
/>
<VideoSlide bookmark={bookmark} onVideoError={handleVideoError} />
);
}
}
Expand All @@ -182,7 +178,7 @@ export const CustomLightBox = ({
) {
content = <PDFSlide bookmark={bookmark} />;
} else if (isYouTubeVideo(bookmark?.url)) {
content = <YouTubeSlide bookmark={bookmark} isActive={isActive} />;
content = <YouTubeSlide bookmark={bookmark} />;
} else if (bookmark?.url) {
content = (
<WebEmbedSlide
Expand Down
18 changes: 5 additions & 13 deletions src/components/lightbox/LightboxRenderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,7 @@ export const ImageSlide = ({ bookmark, zoomRef }: SlideProps) => {
* Renders a video slide using the custom VideoPlayer component
* Notifies parent via onVideoError when video fails to load
*/
export const VideoSlide = ({
bookmark,
isActive,
onVideoError,
}: VideoSlideProps) => {
export const VideoSlide = ({ bookmark, onVideoError }: VideoSlideProps) => {
const handleVideoError = useCallback(() => {
if (bookmark?.id && typeof bookmark.id === "number") {
onVideoError?.(bookmark.id);
Expand All @@ -95,11 +91,7 @@ export const VideoSlide = ({
return (
<div className="flex h-full w-full items-center justify-center">
<div className="w-full max-w-[min(1200px,90vw)]">
<VideoPlayer
isActive={isActive ?? false}
onError={handleVideoError}
src={videoSrc}
/>
<VideoPlayer onError={handleVideoError} src={videoSrc} />
</div>
</div>
);
Expand Down Expand Up @@ -165,9 +157,9 @@ export const PDFSlide = ({ bookmark }: SlideProps) => (
/**
* Renders a YouTube video slide
*/
export const YouTubeSlide = ({ bookmark, isActive }: SlideProps) => (
<div className="relative flex h-full max-h-[80vh] w-full max-w-[min(1200px,90vw)] items-end justify-center">
<VideoPlayer isActive={isActive ?? false} src={bookmark?.url ?? ""} />
export const YouTubeSlide = ({ bookmark }: SlideProps) => (
<div className="relative flex h-full max-h-[80vh] w-full max-w-[min(1200px,90vw)] items-center justify-center">
<VideoPlayer isYouTube src={bookmark?.url ?? ""} />
</div>
);

Expand Down
Loading