Skip to content

PIP, Cast Feature, Event Logic for automatic stopping#8

Merged
leganz merged 2 commits into
mainfrom
feature/pip-and-event-tracker
Jan 26, 2026
Merged

PIP, Cast Feature, Event Logic for automatic stopping#8
leganz merged 2 commits into
mainfrom
feature/pip-and-event-tracker

Conversation

@leganz

@leganz leganz commented Jan 26, 2026

Copy link
Copy Markdown
Contributor
CleanShot 2026-01-26 at 21 36 08@2x

PiP, Event Tracker & Cast Support

Summary

  • Picture-in-Picture support via native browser PiP API
  • Player Event Bus for PiP, tab visibility and cast events
  • Tab visibility handling (pause/resume, mute/unmute, return ads)
  • Timeline markers with visual progress bar integration
  • Cast support: Chromecast (Chrome/Edge) and AirPlay (Safari)

Cast: Browser Compatibility

Browser API Target Device
Safari webkitShowPlaybackTargetPicker AirPlay (e.g. Apple TV)
Chrome/Edge Remote Playback API (remote.prompt) Chromecast
Firefox not supported

Note: HLS streams via hls.js (MSE-based) may not work with the Remote Playback API in Chrome. Native HLS in Safari is unaffected.

New Files

  • src/hooks/usePictureInPicture.ts — PiP hook
  • src/hooks/useTabVisibility.ts — Tab visibility hook
  • src/hooks/useMarkers.ts — Timeline markers hook
  • src/hooks/useCast.ts — Cast hook (AirPlay + Chromecast)
  • src/utils/PlayerEventBus.ts — Typed event bus
  • src/components/controls/PictureInPictureButton/ — PiP button
  • src/components/controls/CastButton/ — Cast button
  • src/components/markers/MarkerList/ — Marker component

Changed Files

  • src/types/video.tscast in VideoFeatures, isCasting in VideoState, toggleCast in VideoControls
  • src/types/labels.tsstartCast / stopCast labels
  • src/hooks/useVideo.ts — Integration of PiP, cast, tab visibility, markers
  • src/components/VideoPlayer/VideoControls/VideoControls.tsx — PiP & cast buttons
  • src/components/VideoPlayer/VideoPlayer.tsx — Event bus callbacks, cast change handler
  • src/context/VideoContext.tsx — New callbacks passed through to useVideo
  • src/index.ts — Barrel exports for all new modules
  • Barrel exports: src/hooks/index.ts, src/components/controls/index.ts

Storybook

New stories under Components/VideoPlayer:

  • PictureInPicture — Toggle PiP on/off
  • PictureInPictureInteractive — PiP with event log and status
  • TabVisibilityWithReturnAd — Switch tabs to trigger pause/resume + return ad
  • Cast — Cast button with features: { cast: true }
  • CastInteractive — Cast with event log and status indicator

@leganz leganz merged commit 4d700e8 into main Jan 26, 2026
4 of 5 checks passed
@leganz leganz deleted the feature/pip-and-event-tracker branch January 26, 2026 21:11
@coderabbitai

coderabbitai Bot commented Jan 26, 2026

Copy link
Copy Markdown

Walkthrough

This PR introduces Picture-in-Picture (PiP) and device casting capabilities, tab visibility monitoring with return ads, and timeline markers support. It adds new UI components (PictureInPictureButton, CastButton, MarkerList), corresponding React hooks (usePictureInPicture, useCast, useTabVisibility, useMarkers), a PlayerEventBus event system, type definitions for new features, and a CDN loader embed script. Integration includes extending VideoPlayer's prop surface, updating ProgressBar with marker visualization, adding Storybook stories for demonstration, and modifying the build configuration to support a new cdn-loader mode.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #7: Modifies CDN build pipeline configuration (package.json exports/scripts and vite.config CDN modes), which directly relates to the cdn-loader additions in this PR
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title covers the main features added (PIP, Cast) but is vague about 'Event Logic for automatic stopping' which doesn't clearly represent tab visibility, pause/resume, or return ad behavior.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing Picture-in-Picture support, event tracking, tab visibility, markers, and cast functionality with browser compatibility notes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@src/components/controls/CastButton/CastButton.tsx`:
- Around line 26-41: The CastButton component's JSX button currently lacks an
explicit type, causing it to default to "submit" in forms; update the button
element in CastButton.tsx (the JSX button inside the CastButton component where
onClick, disabled, className, aria-label and title are set) to include
type="button" so clicks won't submit surrounding forms unintentionally.

In `@src/components/controls/PictureInPictureButton/PictureInPictureButton.tsx`:
- Around line 26-41: The button in the PictureInPictureButton component
currently lacks an explicit type, causing it to default to "submit" inside
forms; update the <button> element in PictureInPictureButton (the JSX render for
the component) to include type="button" so it won't trigger form submissions
when clicked, keeping all existing props (onClick, disabled, className,
aria-label, title) intact.

In `@src/components/VideoPlayer/VideoPlayer.stories.tsx`:
- Around line 2598-2601: Update the block comment for the "Tab Visibility with
Return Ad demo" to match the actual configured behavior: either change the
phrase “after 3 seconds” to reflect that returnAdMinHiddenDuration is set to 0
(immediate), or adjust the configuration (returnAdMinHiddenDuration) to 3000 if
you intend a 3-second delay; ensure both the comment near the demo header and
the similar comment at lines around the second occurrence (the other comment at
2655-2659) are made consistent with the chosen behavior.

In `@src/embed/loader.ts`:
- Around line 70-75: The code currently injects user-controlled artwork and
title into innerHTML (in the block that checks artwork and creates d with class
'fl__art'), which allows XSS; replace the innerHTML usage with safe DOM APIs:
create an HTMLImageElement (document.createElement('img')), set its src (img.src
= artwork or img.setAttribute('src', artwork)), set img.alt = title || 'Cover
art' (or img.setAttribute('alt', ...)), set img.loading = 'lazy', and append the
img to the container div (d.appendChild(img)) before w.appendChild(d); this
removes string interpolation and prevents attribute injection. Ensure you update
the code paths that reference artwork and title and remove the inline template
literal usage.

In `@src/hooks/useMarkers.ts`:
- Around line 56-70: nextMarker and previousMarker currently use
activeMarkerIndex directly, causing next to jump to index 0 when
activeMarkerIndex === -1 and previous to do nothing; change both callbacks to
fall back to resolving the target index from currentTime when activeMarkerIndex
=== -1: in nextMarker (function nextMarker) if activeMarkerIndex === -1 find the
first index i in sortedMarkers where sortedMarkers[i].time > currentTime and
call goToMarker(i) (or do nothing if none), otherwise use activeMarkerIndex+1;
in previousMarker (function previousMarker) if activeMarkerIndex === -1 find the
last index i where sortedMarkers[i].time < currentTime and call goToMarker(i)
(or do nothing if none), otherwise use activeMarkerIndex-1; keep references to
sortedMarkers, currentTime, activeMarkerIndex and goToMarker in the hooks'
dependency arrays.

In `@src/hooks/useTabVisibility.ts`:
- Around line 23-46: The effect currently leaves hiddenSinceRef null if the page
is initially hidden, so the first visible transition undercounts hidden time;
inside the useEffect that registers handleVisibilityChange (in
useTabVisibility.ts), initialize hiddenSinceRef.current = Date.now() when the
component mounts and document.hidden is true (guarding for typeof document !==
'undefined' to avoid SSR issues), so handleVisibilityChange computes the full
hiddenDuration; keep the existing handleVisibilityChange, setIsTabVisible, and
onHiddenRef/onVisibleRef usage unchanged—just set hiddenSinceRef.current
appropriately on mount.

In `@src/utils/fairu.ts`:
- Around line 349-360: The milliseconds computation in secondsToFairuTimestamp
can produce 1000 due to Math.round, causing a 4-digit ms field; change the logic
to compute ms using Math.floor((seconds % 1) * 1000) or compute ms with rounding
but clamp to 0..999 and, if you choose rounding and get ms === 1000, set ms = 0
and increment seconds (and propagate the carry into s, m, h using the existing
variables h/m/s in secondsToFairuTimestamp) so the formatted timestamp always
has a three-digit millisecond component.
🧹 Nitpick comments (5)
src/hooks/useCast.ts (1)

54-56: useEffect dependency may not trigger re-evaluation when video element changes.

The dependency [videoRef] tracks the ref object identity (stable across renders), not videoRef.current. If the underlying video element changes without the ref object changing, setIsSupported won't re-run.

This is typically fine if the ref is assigned once, but worth noting if dynamic video element swapping is expected.

src/components/controls/ProgressBar/ProgressBar.tsx (1)

177-189: Consider memoizing getMarkerAtTime or caching the result.

The function iterates over all markers on every hover position update. While typically small arrays, for videos with many markers this could be optimized with useMemo or binary search if markers are sorted by time.

That said, for typical use cases (< 50 markers), this is acceptable.

src/types/video.ts (1)

140-156: OverlayAd type is used before it's defined.

TabVisibilityConfig references OverlayAd on line 155, but OverlayAd is defined later in this file (line 256). While TypeScript allows this within the same file, consider reordering the interface definitions or adding a forward reference comment for clarity. This is a minor readability concern.

src/hooks/useVideo.ts (2)

173-185: Mute toggle logic may not restore original mute state correctly.

On line 180, when muteOnHidden is true and the video is not already muted, toggleMute() is called. However, wasMutedBeforeHiddenRef is set to true unconditionally before toggling. This ref should track whether the video was not muted before hiding (to know if we need to unmute on return), but the naming suggests it stores whether it was muted.

The current logic:

  • If not muted → set wasMutedBeforeHiddenRef = true → mute
  • On return → if wasMutedBeforeHiddenRef is true → toggle (unmute)

This works correctly but the variable name is misleading. Consider renaming to shouldUnmuteOnVisibleRef for clarity.

Suggested naming improvement
-  const wasMutedBeforeHiddenRef = useRef(false);
+  const shouldUnmuteOnVisibleRef = useRef(false);

And update usages accordingly at lines 179, 192-194.


187-215: Return-ad trigger emits events even when playerEventBus is undefined.

On line 212, playerEventBusRef.current?.emit('triggerReturnAd', ...) is called inside the condition that already checks adEventBusRef.current exists. However, if playerEventBus is not provided but adEventBus is, the triggerReturnAd event won't be emitted but the overlay ad will still show. This might be intentional, but consider whether the triggerReturnAd event should be emitted unconditionally when showing a return ad (for observability).

Otherwise, the tab visibility restoration logic looks correct.

Comment on lines +26 to +41
<button
onClick={onClick}
disabled={disabled}
className={cn(
'fp-cast-button',
'p-2 rounded-lg',
'text-[var(--fp-color-text-secondary)] hover:text-[var(--fp-color-text-primary)]',
'hover:bg-[var(--fp-color-surface-hover)]',
'transition-colors duration-[var(--fp-transition-fast)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--fp-color-accent)]/50',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
aria-label={isCasting ? labels.stopCast : labels.startCast}
title={isCasting ? labels.stopCast : labels.startCast}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add type="button" to prevent unintended form submission.

The button element defaults to type="submit". If this component is ever used within a form context, clicking it would submit the form. Explicitly setting type="button" prevents this behavior.

Proposed fix
     <button
+      type="button"
       onClick={onClick}
       disabled={disabled}
📝 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
<button
onClick={onClick}
disabled={disabled}
className={cn(
'fp-cast-button',
'p-2 rounded-lg',
'text-[var(--fp-color-text-secondary)] hover:text-[var(--fp-color-text-primary)]',
'hover:bg-[var(--fp-color-surface-hover)]',
'transition-colors duration-[var(--fp-transition-fast)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--fp-color-accent)]/50',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
aria-label={isCasting ? labels.stopCast : labels.startCast}
title={isCasting ? labels.stopCast : labels.startCast}
>
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
'fp-cast-button',
'p-2 rounded-lg',
'text-[var(--fp-color-text-secondary)] hover:text-[var(--fp-color-text-primary)]',
'hover:bg-[var(--fp-color-surface-hover)]',
'transition-colors duration-[var(--fp-transition-fast)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--fp-color-accent)]/50',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
aria-label={isCasting ? labels.stopCast : labels.startCast}
title={isCasting ? labels.stopCast : labels.startCast}
>
🤖 Prompt for AI Agents
In `@src/components/controls/CastButton/CastButton.tsx` around lines 26 - 41, The
CastButton component's JSX button currently lacks an explicit type, causing it
to default to "submit" in forms; update the button element in CastButton.tsx
(the JSX button inside the CastButton component where onClick, disabled,
className, aria-label and title are set) to include type="button" so clicks
won't submit surrounding forms unintentionally.

Comment on lines +26 to +41
<button
onClick={onClick}
disabled={disabled}
className={cn(
'fp-pip-button',
'p-2 rounded-lg',
'text-[var(--fp-color-text-secondary)] hover:text-[var(--fp-color-text-primary)]',
'hover:bg-[var(--fp-color-surface-hover)]',
'transition-colors duration-[var(--fp-transition-fast)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--fp-color-accent)]/50',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
aria-label={isPictureInPicture ? labels.exitPictureInPicture : labels.enterPictureInPicture}
title={isPictureInPicture ? labels.exitPictureInPicture : labels.enterPictureInPicture}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add type="button" to prevent unintended form submission.

Same issue as CastButton—the button element defaults to type="submit". Explicitly setting type="button" prevents unintended form submission if this component is used within a form.

Proposed fix
     <button
+      type="button"
       onClick={onClick}
       disabled={disabled}
📝 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
<button
onClick={onClick}
disabled={disabled}
className={cn(
'fp-pip-button',
'p-2 rounded-lg',
'text-[var(--fp-color-text-secondary)] hover:text-[var(--fp-color-text-primary)]',
'hover:bg-[var(--fp-color-surface-hover)]',
'transition-colors duration-[var(--fp-transition-fast)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--fp-color-accent)]/50',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
aria-label={isPictureInPicture ? labels.exitPictureInPicture : labels.enterPictureInPicture}
title={isPictureInPicture ? labels.exitPictureInPicture : labels.enterPictureInPicture}
>
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
'fp-pip-button',
'p-2 rounded-lg',
'text-[var(--fp-color-text-secondary)] hover:text-[var(--fp-color-text-primary)]',
'hover:bg-[var(--fp-color-surface-hover)]',
'transition-colors duration-[var(--fp-transition-fast)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--fp-color-accent)]/50',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
aria-label={isPictureInPicture ? labels.exitPictureInPicture : labels.enterPictureInPicture}
title={isPictureInPicture ? labels.exitPictureInPicture : labels.enterPictureInPicture}
>
🤖 Prompt for AI Agents
In `@src/components/controls/PictureInPictureButton/PictureInPictureButton.tsx`
around lines 26 - 41, The button in the PictureInPictureButton component
currently lacks an explicit type, causing it to default to "submit" inside
forms; update the <button> element in PictureInPictureButton (the JSX render for
the component) to include type="button" so it won't trigger form submissions
when clicked, keeping all existing props (onClick, disabled, className,
aria-label, title) intact.

Comment on lines +2598 to +2601
/**
* Tab Visibility with Return Ad demo
* Pause on hidden, resume on visible, show return ad after 3 seconds away
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Align the comment with the configured return-ad delay.
Line 2598-2601 vs. Line 2658: The comment says “after 3 seconds,” but returnAdMinHiddenDuration is set to 0 (immediate). Please make the comment match the behavior (or adjust the config).

🛠️ Suggested tweak
- * Pause on hidden, resume on visible, show return ad after 3 seconds away
+ * Pause on hidden, resume on visible, show return ad immediately when returning (0s away)

Also applies to: 2655-2659

🤖 Prompt for AI Agents
In `@src/components/VideoPlayer/VideoPlayer.stories.tsx` around lines 2598 - 2601,
Update the block comment for the "Tab Visibility with Return Ad demo" to match
the actual configured behavior: either change the phrase “after 3 seconds” to
reflect that returnAdMinHiddenDuration is set to 0 (immediate), or adjust the
configuration (returnAdMinHiddenDuration) to 3000 if you intend a 3-second
delay; ensure both the comment near the demo header and the similar comment at
lines around the second occurrence (the other comment at 2655-2659) are made
consistent with the chosen behavior.

Comment thread src/embed/loader.ts
Comment on lines +70 to +75
if (artwork) {
const d = document.createElement('div');
d.className = 'fl__art';
d.innerHTML = `<img src="${artwork}" alt="${title || 'Cover art'}" loading="lazy">`;
w.appendChild(d);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sanitize user-provided data attributes to prevent XSS.

The artwork and title values are read from data-* attributes and interpolated directly into HTML. If a page allows user-controlled data attributes, an attacker could inject malicious payloads (e.g., " onerror="alert(1)).

Proposed fix using safe DOM APIs
   if (artwork) {
     const d = document.createElement('div');
     d.className = 'fl__art';
-    d.innerHTML = `<img src="${artwork}" alt="${title || 'Cover art'}" loading="lazy">`;
+    const img = document.createElement('img');
+    img.src = artwork;
+    img.alt = title || 'Cover art';
+    img.loading = 'lazy';
+    d.appendChild(img);
     w.appendChild(d);
   }
📝 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
if (artwork) {
const d = document.createElement('div');
d.className = 'fl__art';
d.innerHTML = `<img src="${artwork}" alt="${title || 'Cover art'}" loading="lazy">`;
w.appendChild(d);
}
if (artwork) {
const d = document.createElement('div');
d.className = 'fl__art';
const img = document.createElement('img');
img.src = artwork;
img.alt = title || 'Cover art';
img.loading = 'lazy';
d.appendChild(img);
w.appendChild(d);
}
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 72-72: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: d.innerHTML = <img src="${artwork}" alt="${title || 'Cover art'}" loading="lazy">
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)


[warning] 72-72: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: d.innerHTML = <img src="${artwork}" alt="${title || 'Cover art'}" loading="lazy">
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html

(unsafe-html-content-assignment)

🤖 Prompt for AI Agents
In `@src/embed/loader.ts` around lines 70 - 75, The code currently injects
user-controlled artwork and title into innerHTML (in the block that checks
artwork and creates d with class 'fl__art'), which allows XSS; replace the
innerHTML usage with safe DOM APIs: create an HTMLImageElement
(document.createElement('img')), set its src (img.src = artwork or
img.setAttribute('src', artwork)), set img.alt = title || 'Cover art' (or
img.setAttribute('alt', ...)), set img.loading = 'lazy', and append the img to
the container div (d.appendChild(img)) before w.appendChild(d); this removes
string interpolation and prevents attribute injection. Ensure you update the
code paths that reference artwork and title and remove the inline template
literal usage.

Comment thread src/hooks/useMarkers.ts
Comment on lines +56 to +70
// Next marker
const nextMarker = useCallback(() => {
const nextIndex = activeMarkerIndex + 1;
if (nextIndex < sortedMarkers.length) {
goToMarker(nextIndex);
}
}, [activeMarkerIndex, sortedMarkers.length, goToMarker]);

// Previous marker
const previousMarker = useCallback(() => {
const prevIndex = activeMarkerIndex - 1;
if (prevIndex >= 0) {
goToMarker(prevIndex);
}
}, [activeMarkerIndex, goToMarker]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Next/previous jump to the first marker when none is “active.”

When activeMarkerIndex is -1 (e.g., currentTime is mid/late but outside the threshold), nextMarker() goes to index 0 and previousMarker() does nothing. That’s a surprising UX for “next/previous.” Consider resolving the next/previous index from currentTime when there’s no active marker.

🛠️ Proposed fix
  // Next marker
  const nextMarker = useCallback(() => {
-   const nextIndex = activeMarkerIndex + 1;
-   if (nextIndex < sortedMarkers.length) {
+   const nextIndex =
+     activeMarkerIndex >= 0
+       ? activeMarkerIndex + 1
+       : sortedMarkers.findIndex((m) => m.time > currentTime);
+   if (nextIndex >= 0 && nextIndex < sortedMarkers.length) {
      goToMarker(nextIndex);
    }
- }, [activeMarkerIndex, sortedMarkers.length, goToMarker]);
+ }, [activeMarkerIndex, sortedMarkers, currentTime, goToMarker]);

  // Previous marker
  const previousMarker = useCallback(() => {
-   const prevIndex = activeMarkerIndex - 1;
+   let prevIndex = activeMarkerIndex - 1;
+   if (activeMarkerIndex < 0) {
+     for (let i = sortedMarkers.length - 1; i >= 0; i--) {
+       if (sortedMarkers[i].time < currentTime) {
+         prevIndex = i;
+         break;
+       }
+     }
+   }
    if (prevIndex >= 0) {
      goToMarker(prevIndex);
    }
- }, [activeMarkerIndex, goToMarker]);
+ }, [activeMarkerIndex, sortedMarkers, currentTime, goToMarker]);
📝 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
// Next marker
const nextMarker = useCallback(() => {
const nextIndex = activeMarkerIndex + 1;
if (nextIndex < sortedMarkers.length) {
goToMarker(nextIndex);
}
}, [activeMarkerIndex, sortedMarkers.length, goToMarker]);
// Previous marker
const previousMarker = useCallback(() => {
const prevIndex = activeMarkerIndex - 1;
if (prevIndex >= 0) {
goToMarker(prevIndex);
}
}, [activeMarkerIndex, goToMarker]);
// Next marker
const nextMarker = useCallback(() => {
const nextIndex =
activeMarkerIndex >= 0
? activeMarkerIndex + 1
: sortedMarkers.findIndex((m) => m.time > currentTime);
if (nextIndex >= 0 && nextIndex < sortedMarkers.length) {
goToMarker(nextIndex);
}
}, [activeMarkerIndex, sortedMarkers, currentTime, goToMarker]);
// Previous marker
const previousMarker = useCallback(() => {
let prevIndex = activeMarkerIndex - 1;
if (activeMarkerIndex < 0) {
for (let i = sortedMarkers.length - 1; i >= 0; i--) {
if (sortedMarkers[i].time < currentTime) {
prevIndex = i;
break;
}
}
}
if (prevIndex >= 0) {
goToMarker(prevIndex);
}
}, [activeMarkerIndex, sortedMarkers, currentTime, goToMarker]);
🤖 Prompt for AI Agents
In `@src/hooks/useMarkers.ts` around lines 56 - 70, nextMarker and previousMarker
currently use activeMarkerIndex directly, causing next to jump to index 0 when
activeMarkerIndex === -1 and previous to do nothing; change both callbacks to
fall back to resolving the target index from currentTime when activeMarkerIndex
=== -1: in nextMarker (function nextMarker) if activeMarkerIndex === -1 find the
first index i in sortedMarkers where sortedMarkers[i].time > currentTime and
call goToMarker(i) (or do nothing if none), otherwise use activeMarkerIndex+1;
in previousMarker (function previousMarker) if activeMarkerIndex === -1 find the
last index i where sortedMarkers[i].time < currentTime and call goToMarker(i)
(or do nothing if none), otherwise use activeMarkerIndex-1; keep references to
sortedMarkers, currentTime, activeMarkerIndex and goToMarker in the hooks'
dependency arrays.

Comment on lines +23 to +46
const [isTabVisible, setIsTabVisible] = useState(
typeof document !== 'undefined' ? !document.hidden : true
);
const hiddenSinceRef = useRef<number | null>(null);

// Store callbacks in refs so the event listener always calls the latest version
const onHiddenRef = useRef(options.onHidden);
const onVisibleRef = useRef(options.onVisible);
onHiddenRef.current = options.onHidden;
onVisibleRef.current = options.onVisible;

useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
hiddenSinceRef.current = Date.now();
setIsTabVisible(false);
onHiddenRef.current?.();
} else {
const hiddenDuration = hiddenSinceRef.current
? (Date.now() - hiddenSinceRef.current) / 1000
: 0;
hiddenSinceRef.current = null;
setIsTabVisible(true);
onVisibleRef.current?.(hiddenDuration);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/hooks/useTabVisibility.ts

Repository: sushidev-team/fairu-player

Length of output: 2305


Initialize hiddenSinceRef when tab starts hidden.
If the page loads hidden (background tab or SSR hydration), hiddenSinceRef remains null and hiddenDuration computes as 0 on the first visibility change, undercounting actual hidden time. Initialize hiddenSinceRef in the effect based on the initial visibility state to capture the full hidden duration from page load.

🛠️ Proposed fix
  useEffect(() => {
+    const initiallyHidden = document.hidden;
+    hiddenSinceRef.current = initiallyHidden ? Date.now() : null;
+    setIsTabVisible(!initiallyHidden);
+
     const handleVisibilityChange = () => {
       if (document.hidden) {
         hiddenSinceRef.current = Date.now();
🤖 Prompt for AI Agents
In `@src/hooks/useTabVisibility.ts` around lines 23 - 46, The effect currently
leaves hiddenSinceRef null if the page is initially hidden, so the first visible
transition undercounts hidden time; inside the useEffect that registers
handleVisibilityChange (in useTabVisibility.ts), initialize
hiddenSinceRef.current = Date.now() when the component mounts and
document.hidden is true (guarding for typeof document !== 'undefined' to avoid
SSR issues), so handleVisibilityChange computes the full hiddenDuration; keep
the existing handleVisibilityChange, setIsTabVisible, and
onHiddenRef/onVisibleRef usage unchanged—just set hiddenSinceRef.current
appropriately on mount.

Comment thread src/utils/fairu.ts
Comment on lines +349 to +360
export function secondsToFairuTimestamp(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.round((seconds % 1) * 1000);

return [
String(h).padStart(2, '0'),
String(m).padStart(2, '0'),
String(s).padStart(2, '0'),
].join(':') + '.' + String(ms).padStart(3, '0');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential off-by-one in milliseconds calculation.

Math.round((seconds % 1) * 1000) can produce 1000 when seconds has a fractional part very close to 1 (e.g., 0.9999), resulting in a 4-digit millisecond string like "00:00:00.1000".

Proposed fix
-  const ms = Math.round((seconds % 1) * 1000);
+  const ms = Math.min(999, Math.floor((seconds % 1) * 1000));
🤖 Prompt for AI Agents
In `@src/utils/fairu.ts` around lines 349 - 360, The milliseconds computation in
secondsToFairuTimestamp can produce 1000 due to Math.round, causing a 4-digit ms
field; change the logic to compute ms using Math.floor((seconds % 1) * 1000) or
compute ms with rounding but clamp to 0..999 and, if you choose rounding and get
ms === 1000, set ms = 0 and increment seconds (and propagate the carry into s,
m, h using the existing variables h/m/s in secondsToFairuTimestamp) so the
formatted timestamp always has a three-digit millisecond component.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant