Skip to content

Strand-themed video.js skin#99

Open
aloewright wants to merge 1 commit into
mainfrom
conductor/alo-141-strand-themed-video-js-skin
Open

Strand-themed video.js skin#99
aloewright wants to merge 1 commit into
mainfrom
conductor/alo-141-strand-themed-video-js-skin

Conversation

@aloewright
Copy link
Copy Markdown
Owner

@aloewright aloewright commented May 8, 2026

Closes ALO-141.

Summary

  • Replaces the native UA controls bar on /watch/:id with a Strand-themed custom overlay (PlayerControls) layered over the <video> element.
  • Overlay renders a pill scrubber with separate buffered + played fills, play/pause + mute + fullscreen buttons, an expanding volume slider, monochrome OKLCH palette, gradient bar background, and an idle auto-hide (2.5s) that always stays visible while paused or scrubbing.
  • Extends the NativePlayer adapter with volume() / setVolume() (with clamp + auto-unmute) and bufferedAhead() so the controls render without poking at the underlying element. Also broadens the event surface (durationchange, playing, progress, volumechange, waiting, canplay).
  • Keyboard nav (j/k/l, space, ←/→, f, m, 0–9) is unchanged — already lives on the Watch page and continues to drive the same adapter.
  • Click-on-video toggles play/pause to match user expectations of a custom-skin player.

Files

  • src/frontend/components/PlayerControls.tsx — new overlay component
  • src/frontend/components/PlayerControls.test.ts — tests for the time-format helper
  • src/frontend/lib/native-player.ts / .test.ts — new methods + coverage
  • src/frontend/styles/strand.css — new .player, .player__bar, .player__scrubber*, .player__btn, .player__volume* styles using Strand tokens
  • src/frontend/pages/Watch.tsx — drops el.controls = true, mounts <PlayerControls /> over the <video>

Test plan

  • npm test — 498 passing (45 files)
  • npm run lint — 0 warnings, AI Gateway guard clean
  • npm run type-check — clean
  • npm run build — builds; Watch chunk ~32.6 KB raw / ~9 KB gz
  • Manual: hover/focus reveals controls, scrubber seeks (with buffered preview), volume slider expands on hover, mute toggle, fullscreen toggles, keyboard shortcuts still work

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 8, 2026 14:45
@aloewright aloewright added the conductor Conductor-managed PR label May 8, 2026
@ecc-tools
Copy link
Copy Markdown
Contributor

ecc-tools Bot commented May 8, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 8, 2026

Review Change Stack

Walkthrough

This pull request introduces a new StrandPlayer React component that replaces the raw <video> element in the Watch page with a fully customizable player featuring Strand-themed controls. The component integrates with the existing native player adapter, which gains support for a configurable fullscreen target element. The implementation includes state synchronization from video events, memoized callbacks for playback control, comprehensive CSS styling for controls and layout, and proper imperative ref exposure for programmatic access.

Changes

Strand Video Player Component

Layer / File(s) Summary
Public Interfaces and Options
src/frontend/components/StrandPlayer.tsx, src/frontend/lib/native-player.ts
Exports StrandPlayerProps, StrandPlayerHandle ref interface, and new CreateNativePlayerOptions type with optional fullscreenTarget field.
StrandPlayer Component Logic
src/frontend/components/StrandPlayer.tsx
Implements state management for playback, mute/volume, current time, and fullscreen status via video and document event listeners; provides memoized callbacks for play/pause, mute, fullscreen toggle, seek, and volume control; calculates progress percentages.
StrandPlayer Rendering and Controls
src/frontend/components/StrandPlayer.tsx
Renders wrapper, hit target, seek scrubber with buffered/progress overlays, play/pause button, mute/volume button with slider, time readout, and fullscreen toggle.
Native Player Fullscreen Target Support
src/frontend/lib/native-player.ts
Updates createNativePlayer to accept CreateNativePlayerOptions; uses fullscreenTarget (defaulting to video element) for fullscreen detection and entry.
Watch Page Player Wiring
src/frontend/pages/Watch.tsx
Imports StrandPlayer, adds ref to access video and wrapper, updates player setup effect to initialize native player with wrapper as fullscreenTarget and disable native controls, replaces raw video element with <StrandPlayer/> component.
Strand Player CSS Skin
src/frontend/styles/strand.css
Adds .strand-player base styling with fullscreen modifier, video sizing, click-to-toggle hit area, bottom controls overlay with fade behavior, custom scrubber with buffered/progress bars, control buttons with focus states, expandable volume slider, time display, and flex spacer.

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'Strand-themed video.js skin' is misleading—the PR does not implement a video.js skin but rather a custom React component (StrandPlayer) that wraps the native Revise the title to accurately reflect the implementation, such as 'Add StrandPlayer component with custom video controls' or 'Implement Strand-themed custom video player component'.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch conductor/alo-141-strand-themed-video-js-skin

Warning

Review ran into problems

🔥 Problems

These MCP integrations need to be re-authenticated in the Integrations settings: Sentry


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@aloewright
Copy link
Copy Markdown
Owner Author

The CodeRabbit comment above is a 'review in progress' status notification — no actionable feedback yet. Will address concrete review items once CodeRabbit posts them.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new custom video player component, StrandPlayer, styled with Strand design tokens. It replaces the native video controls on the Watch page and updates the native-player library to allow custom fullscreen targets, ensuring that custom controls remain visible during fullscreen playback. Feedback focuses on improving the responsiveness of keyboard seeking by updating the seek input logic, removing redundant event listeners on the seek bar that trigger on non-navigation keys, and consolidating time formatting logic into a shared utility to avoid code duplication.

Comment on lines +141 to +144
const onSeekInput = (e: React.ChangeEvent<HTMLInputElement>): void => {
const v = Number.parseFloat(e.target.value);
setCurrent(v);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Keyboard seeking (using arrow keys on the range input) currently feels unresponsive because the video only seeks on onKeyUp. Additionally, the timeupdate listener will cause the slider to jump back to the old position while a key is held down because scrubbing is false.

Updating onSeekInput to seek immediately when not dragging (i.e., keyboard interaction) provides a much smoother experience.

    const onSeekInput = (e: React.ChangeEvent<HTMLInputElement>): void => {
      const v = Number.parseFloat(e.target.value);
      setCurrent(v);
      if (!scrubbing) {
        seekTo(v);
      }
    };

Comment on lines +22 to +31
function fmt(total: number): string {
if (!Number.isFinite(total) || total < 0) return '0:00';
const t = Math.floor(total);
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
const mm = String(m).padStart(h > 0 ? 2 : 1, '0');
const ss = String(s).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The time formatting logic in fmt is identical to formatHms in Watch.tsx. To improve maintainability and adhere to DRY principles, this should be moved to a shared utility file (e.g., src/frontend/lib/format.ts).

onChange={onSeekInput}
onMouseUp={onSeekCommit}
onTouchEnd={onSeekCommit}
onKeyUp={onSeekCommit}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The onKeyUp listener on the range input is problematic as it triggers a seek on every key release, including non-navigation keys like Tab or Shift. With the suggested improvement to onSeekInput, this listener is no longer necessary for keyboard seeking.

Suggested change
onKeyUp={onSeekCommit}
onMouseUp={onSeekCommit}
onTouchEnd={onSeekCommit}

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12a22a4e2a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +198 to +202
onMouseDown={() => setScrubbing(true)}
onTouchStart={() => setScrubbing(true)}
onChange={onSeekInput}
onMouseUp={onSeekCommit}
onTouchEnd={onSeekCommit}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear scrubbing state on pointer-cancel/outside release

scrubbing is set to true on press, but it is only cleared in the input’s own onMouseUp/onTouchEnd handlers. If the user drags the seek thumb and releases outside the range element (or the interaction is canceled), those handlers may not fire, leaving scrubbing stuck true. In that state, onTime stops syncing playback time (if (!scrubbing)), so the displayed progress/time can freeze for the rest of playback until another successful seek interaction resets it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/frontend/components/StrandPlayer.tsx`:
- Around line 66-73: The component fails to reset timeline state when the video
source changes: update the event handling so that on loadstart and emptied you
clear current and buffered (e.g., setCurrent(0) and setBuffered(0)) to avoid
showing the previous video's progress, and then keep onMeta() (and onTime()) to
re-sync duration/current/buffered once the new source metadata/timeupdates
arrive; modify or add handlers tied to the same video element reference (the el
used in onTime and onMeta) to reset state on 'loadstart' and 'emptied' and
ensure onMeta() also sets current/buffered when appropriate.
- Around line 145-151: The scrub state is only being cleared by input-local end
handlers so releasing the pointer outside the range leaves scrubbing true and
blocks timeupdate; update the interaction to use pointer events and a global
release handler: on pointerdown (attach in the same component where you
currently set scrubbing true, e.g., the range input handlers around onSeekCommit
/ the corresponding start handler) call setScrubbing(true) and call
e.currentTarget.setPointerCapture(e.pointerId) if available, and add a one-time
window.addEventListener('pointerup', ...) (or document) to call
setScrubbing(false) and release capture; also ensure onSeekCommit still calls
seekTo and setScrubbing(false) for final commits. Apply the same change to the
other range handlers referenced (the block around lines 198-203) so scrubbing is
reliably cleared when the pointer is released anywhere.

In `@src/frontend/styles/strand.css`:
- Around line 614-619: The CSS rules that hide controls when playing rely on
:hover/:focus-within and break touch/coarse-pointer devices; scope the hiding to
only hover-capable devices by wrapping the selectors
.strand-player.is-playing:not(:hover):not(:focus-within) and its
.strand-player__controls rule inside a media query for hover-capable,
fine-pointer devices (e.g., media query using (hover: hover) and (pointer:
fine)), and likewise scope the cursor: none rule the same way so controls remain
reachable on touch devices while preserving the current behavior for mouse
users.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 92d10f13-d209-4a55-b8f8-e747461e4547

📥 Commits

Reviewing files that changed from the base of the PR and between 4d3c13f and 12a22a4.

📒 Files selected for processing (4)
  • src/frontend/components/StrandPlayer.tsx
  • src/frontend/lib/native-player.ts
  • src/frontend/pages/Watch.tsx
  • src/frontend/styles/strand.css

Comment on lines +66 to +73
const onTime = (): void => {
if (!scrubbing) setCurrent(el.currentTime);
const b = el.buffered;
if (b.length > 0) setBuffered(b.end(b.length - 1));
};
const onMeta = (): void => {
setDuration(Number.isFinite(el.duration) ? el.duration : 0);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset timeline state when the source changes.

onMeta() only refreshes duration, so when Watch swaps the source on the same <video> element this component can keep rendering the previous video's time/progress until the next timeupdate. Clear current/buffered on loadstart/emptied and re-sync them in onMeta().

One way to re-sync the UI state
       const onMeta = (): void => {
+        setCurrent(Number.isFinite(el.currentTime) ? el.currentTime : 0);
         setDuration(Number.isFinite(el.duration) ? el.duration : 0);
+        const b = el.buffered;
+        setBuffered(b.length > 0 ? b.end(b.length - 1) : 0);
+      };
+      const onLoadStart = (): void => {
+        setCurrent(0);
+        setDuration(0);
+        setBuffered(0);
       };
       const onVol = (): void => {
         setMuted(el.muted);
         setVolume(el.volume);
       };
@@
+      el.addEventListener('loadstart', onLoadStart);
       el.addEventListener('loadedmetadata', onMeta);
       el.addEventListener('durationchange', onMeta);
       el.addEventListener('volumechange', onVol);
@@
+        el.removeEventListener('loadstart', onLoadStart);
         el.removeEventListener('loadedmetadata', onMeta);
         el.removeEventListener('durationchange', onMeta);
         el.removeEventListener('volumechange', onVol);

Also applies to: 87-88

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/StrandPlayer.tsx` around lines 66 - 73, The component
fails to reset timeline state when the video source changes: update the event
handling so that on loadstart and emptied you clear current and buffered (e.g.,
setCurrent(0) and setBuffered(0)) to avoid showing the previous video's
progress, and then keep onMeta() (and onTime()) to re-sync
duration/current/buffered once the new source metadata/timeupdates arrive;
modify or add handlers tied to the same video element reference (the el used in
onTime and onMeta) to reset state on 'loadstart' and 'emptied' and ensure
onMeta() also sets current/buffered when appropriate.

Comment on lines +145 to +151
const onSeekCommit = (
e: React.SyntheticEvent<HTMLInputElement>,
): void => {
const v = Number.parseFloat(e.currentTarget.value);
seekTo(v);
setScrubbing(false);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't rely on element-local mouse/touch end events to finish scrubbing.

scrubbing is turned on at drag start, but it's only turned off by end handlers attached to the range itself. If the pointer is released outside the input, those handlers won't run and timeupdate stays ignored, so the scrubber/time display can freeze out of sync.

Use pointer events so the control keeps the interaction until release
-              onMouseDown={() => setScrubbing(true)}
-              onTouchStart={() => setScrubbing(true)}
+              onPointerDown={(event) => {
+                event.currentTarget.setPointerCapture(event.pointerId);
+                setScrubbing(true);
+              }}
               onChange={onSeekInput}
-              onMouseUp={onSeekCommit}
-              onTouchEnd={onSeekCommit}
+              onPointerUp={onSeekCommit}
+              onPointerCancel={() => setScrubbing(false)}
+              onBlur={onSeekCommit}
               onKeyUp={onSeekCommit}

Also applies to: 198-203

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/StrandPlayer.tsx` around lines 145 - 151, The scrub
state is only being cleared by input-local end handlers so releasing the pointer
outside the range leaves scrubbing true and blocks timeupdate; update the
interaction to use pointer events and a global release handler: on pointerdown
(attach in the same component where you currently set scrubbing true, e.g., the
range input handlers around onSeekCommit / the corresponding start handler) call
setScrubbing(true) and call e.currentTarget.setPointerCapture(e.pointerId) if
available, and add a one-time window.addEventListener('pointerup', ...) (or
document) to call setScrubbing(false) and release capture; also ensure
onSeekCommit still calls seekTo and setScrubbing(false) for final commits. Apply
the same change to the other range handlers referenced (the block around lines
198-203) so scrubbing is reliably cleared when the pointer is released anywhere.

Comment thread src/frontend/styles/strand.css Outdated
Comment on lines +614 to +619
.strand-player.is-playing:not(:hover):not(:focus-within) .strand-player__controls {
opacity: 0;
}
.strand-player.is-playing:not(:hover):not(:focus-within) {
cursor: none;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the controls reachable on touch devices.

This hide rule depends entirely on :hover/:focus-within. On touch or coarse-pointer devices there is no real hover state, so once playback starts the controls stay transparent while the overlay still sits above the hit target. That makes seek, volume, and fullscreen effectively unavailable during playback.

Suggested CSS guard for non-hover devices
 .strand-player.is-playing:not(:hover):not(:focus-within) .strand-player__controls {
   opacity: 0;
 }
 .strand-player.is-playing:not(:hover):not(:focus-within) {
   cursor: none;
 }
+
+@media (hover: none), (pointer: coarse) {
+  .strand-player.is-playing .strand-player__controls {
+    opacity: 1;
+  }
+
+  .strand-player.is-playing {
+    cursor: auto;
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/styles/strand.css` around lines 614 - 619, The CSS rules that
hide controls when playing rely on :hover/:focus-within and break
touch/coarse-pointer devices; scope the hiding to only hover-capable devices by
wrapping the selectors .strand-player.is-playing:not(:hover):not(:focus-within)
and its .strand-player__controls rule inside a media query for hover-capable,
fine-pointer devices (e.g., media query using (hover: hover) and (pointer:
fine)), and likewise scope the cursor: none rule the same way so controls remain
reachable on touch devices while preserving the current behavior for mouse
users.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a custom video player skin, StrandPlayer, which includes custom controls for playback, volume, and seeking, along with associated CSS styles. The createNativePlayer utility was updated to support a fullscreenTarget, allowing the custom UI to remain visible in fullscreen mode, and the Watch page was refactored to use this new component. Review feedback suggests extracting the time formatting logic into a shared utility to avoid duplication and addresses a UI flickering issue when seeking via keyboard by managing the scrubbing state on key down events.

Comment on lines +22 to +31
function fmt(total: number): string {
if (!Number.isFinite(total) || total < 0) return '0:00';
const t = Math.floor(total);
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
const mm = String(m).padStart(h > 0 ? 2 : 1, '0');
const ss = String(s).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The time formatting logic in fmt is identical to formatHms in src/frontend/pages/Watch.tsx. To improve maintainability and avoid duplication, consider extracting this logic into a shared utility function (e.g., in a new src/frontend/lib/time.ts or similar).

Comment on lines +191 to +205
<input
className="strand-player__seek"
type="range"
min={0}
max={duration > 0 ? duration : 0}
step="0.01"
value={current}
onMouseDown={() => setScrubbing(true)}
onTouchStart={() => setScrubbing(true)}
onChange={onSeekInput}
onMouseUp={onSeekCommit}
onTouchEnd={onSeekCommit}
onKeyUp={onSeekCommit}
aria-label="Seek"
/>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When using keyboard navigation (e.g., arrow keys) to seek, the scrubber will flicker or jump back to the current playhead position. This happens because scrubbing is only set to true on mouse/touch events, allowing the timeupdate listener to overwrite the current state with the video's old position before the seek is committed on onKeyUp. Adding onKeyDown to set scrubbing to true resolves this.

            <input
              className="strand-player__seek"
              type="range"
              min={0}
              max={duration > 0 ? duration : 0}
              step="0.01"
              value={current}
              onMouseDown={() => setScrubbing(true)}
              onTouchStart={() => setScrubbing(true)}
              onKeyDown={() => setScrubbing(true)}
              onChange={onSeekInput}
              onMouseUp={onSeekCommit}
              onTouchEnd={onSeekCommit}
              onKeyUp={onSeekCommit}
              aria-label="Seek"
            />

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Replaces the native UA controls bar on the Watch page with a custom
overlay that matches the Strand design system: pill scrubber with
buffered + played fills, play/pause + mute + fullscreen buttons,
volume slider, monochrome OKLCH palette, auto-hide on idle.

Extends the NativePlayer adapter with volume() / setVolume() and
bufferedAhead() so the controls can render without reaching into the
underlying <video>. The keyboard-shortcut layer (j/k/l, space, ←/→,
f, m, 0–9) already lives on the Watch page and continues to work
alongside the new overlay.
@aloewright aloewright force-pushed the conductor/alo-141-strand-themed-video-js-skin branch from 12a22a4 to f85ced8 Compare May 9, 2026 00:57
@ecc-tools
Copy link
Copy Markdown
Contributor

ecc-tools Bot commented May 9, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

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

Labels

conductor Conductor-managed PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants