From 46774cb425347a918757d2d17a7fda59890debf9 Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Wed, 2 Sep 2026 08:36:00 +0200 Subject: [PATCH 01/22] ARC-3904: start reworking the flowplayer for custom controls --- .../AudioWaveFormDisplay.helpers.ts | 79 ++++ .../AudioWaveFormDisplay.scss | 22 + .../AudioWaveFormDisplay.stories.tsx | 33 ++ .../AudioWaveFormDisplay.tsx | 57 +++ .../AudioWaveFormDisplay.types.tsx | 11 + src/components/AudioWaveFormDisplay/index.ts | 3 + src/components/Dropdown/Dropdown.tsx | 7 +- .../FlowPlayer/Controls/ControlBar.scss | 441 ++++++++++++++++++ .../FlowPlayer/Controls/ControlBar.tsx | 289 ++++++++++++ .../FlowPlayer/Controls/ControlBar.types.ts | 21 + .../FlowPlayer/Controls/Controls.consts.ts | 29 ++ .../FlowPlayer/Controls/Controls.helpers.ts | 10 + .../FlowPlayer/Controls/Controls.icons.tsx | 118 +++++ .../FlowPlayer/Controls/FullscreenButton.tsx | 20 + .../FlowPlayer/Controls/PeakDisplay.tsx | 41 ++ .../FlowPlayer/Controls/PlayPauseButton.tsx | 20 + .../FlowPlayer/Controls/ProgressBar.tsx | 121 +++++ .../FlowPlayer/Controls/ProgressBar.types.ts | 15 + .../FlowPlayer/Controls/SpeedControl.tsx | 77 +++ .../FlowPlayer/Controls/SubtitlesControl.tsx | 106 +++++ .../FlowPlayer/Controls/VolumeBars.tsx | 86 ++++ .../FlowPlayer/Controls/VolumeControl.tsx | 104 +++++ .../Controls/subtitles-track.helpers.ts | 73 +++ .../FlowPlayer/Controls/use-drag-value.ts | 71 +++ .../Controls/useAutoHideControls.ts | 70 +++ .../FlowPlayer/Controls/useFlowplayerState.ts | 257 ++++++++++ .../Controls/useKeyboardShortcuts.ts | 85 ++++ .../Controls/useSubtitlesPersistence.ts | 64 +++ .../FlowPlayer/FlowPlayer.commands.ts | 16 +- .../FlowPlayer/FlowPlayer.internal.tsx | 86 +++- src/components/FlowPlayer/FlowPlayer.scss | 48 +- .../FlowPlayer/FlowPlayer.stories.tsx | 92 ++++ src/components/FlowPlayer/FlowPlayer.types.ts | 73 +++ .../FlowPlayer/_flowplayer-shared.scss | 15 + src/components/index.ts | 1 + 35 files changed, 2639 insertions(+), 22 deletions(-) create mode 100644 src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts create mode 100644 src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.scss create mode 100644 src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx create mode 100644 src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx create mode 100644 src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx create mode 100644 src/components/AudioWaveFormDisplay/index.ts create mode 100644 src/components/FlowPlayer/Controls/ControlBar.scss create mode 100644 src/components/FlowPlayer/Controls/ControlBar.tsx create mode 100644 src/components/FlowPlayer/Controls/ControlBar.types.ts create mode 100644 src/components/FlowPlayer/Controls/Controls.consts.ts create mode 100644 src/components/FlowPlayer/Controls/Controls.helpers.ts create mode 100644 src/components/FlowPlayer/Controls/Controls.icons.tsx create mode 100644 src/components/FlowPlayer/Controls/FullscreenButton.tsx create mode 100644 src/components/FlowPlayer/Controls/PeakDisplay.tsx create mode 100644 src/components/FlowPlayer/Controls/PlayPauseButton.tsx create mode 100644 src/components/FlowPlayer/Controls/ProgressBar.tsx create mode 100644 src/components/FlowPlayer/Controls/ProgressBar.types.ts create mode 100644 src/components/FlowPlayer/Controls/SpeedControl.tsx create mode 100644 src/components/FlowPlayer/Controls/SubtitlesControl.tsx create mode 100644 src/components/FlowPlayer/Controls/VolumeBars.tsx create mode 100644 src/components/FlowPlayer/Controls/VolumeControl.tsx create mode 100644 src/components/FlowPlayer/Controls/subtitles-track.helpers.ts create mode 100644 src/components/FlowPlayer/Controls/use-drag-value.ts create mode 100644 src/components/FlowPlayer/Controls/useAutoHideControls.ts create mode 100644 src/components/FlowPlayer/Controls/useFlowplayerState.ts create mode 100644 src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts create mode 100644 src/components/FlowPlayer/Controls/useSubtitlesPersistence.ts create mode 100644 src/components/FlowPlayer/_flowplayer-shared.scss diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts new file mode 100644 index 00000000..20ee7af8 --- /dev/null +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts @@ -0,0 +1,79 @@ +export type AudioWaveFormDisplaySize = 'small' | 'large'; + +export interface WaveFormBar { + x: number; + y1: number; + y2: number; +} + +// Bar geometry traced from the original design asset: 30 vertical bars, 3 units apart starting +// at x=0.9, centered on y=21.6, in a 92x44 viewBox. +const WAVE_FORM_VIEW_BOX_WIDTH = 92; +const WAVE_FORM_VIEW_BOX_HEIGHT = 44; +const WAVE_FORM_CENTER_Y = 21.6; +const WAVE_FORM_FIRST_BAR_X = 0.9; +const WAVE_FORM_BAR_SPACING = 3; +export const WAVE_FORM_STROKE_WIDTH = 1.8; + +// Padding around the bars, as a fraction of the display's box on each side. Baked into the +// viewBox rather than CSS padding so it can't collapse to zero on a short/narrow container -- +// `preserveAspectRatio="xMidYMid meet"` on the then scales and centers it without stretching. +const WAVE_FORM_PADDING_X_RATIO = 0.15; +const WAVE_FORM_PADDING_Y_RATIO = 0.3; + +// Exposed for consumers using this as FlowPlayer's `peakVisual` - its horizontal padding needs +// to be accounted for via `customControlsConfig.peakVisualContentInset` (see PeakDisplay.tsx), +// or the fake-playing progress reveal "plays through" blank space before any bar is visible. +export const WAVE_FORM_PADDING_X_PERCENT = WAVE_FORM_PADDING_X_RATIO * 100; + +// Half the height of each bar (viewBox units), left to right, traced from the original asset. +const WAVE_FORM_BAR_HALF_HEIGHTS: readonly number[] = [ + 0.3, 3.3, 3.3, 6.9, 3.3, 6.9, 13.5, 20.7, 10.5, 6.9, 17.1, 13.5, 10.5, 3.3, 6.9, 3.3, 3.3, 6.9, + 10.5, 13.5, 6.9, 3.3, 3.3, 6.9, 3.3, 3.3, 6.9, 3.3, 3.3, 1.5, +]; + +const WAVE_FORM_BAR_COUNT = WAVE_FORM_BAR_HALF_HEIGHTS.length; + +// Right margin the reference asset leaves after its last bar, reused to size the large viewBox. +const WAVE_FORM_RIGHT_MARGIN = + WAVE_FORM_VIEW_BOX_WIDTH - (WAVE_FORM_FIRST_BAR_X + (WAVE_FORM_BAR_COUNT - 1) * WAVE_FORM_BAR_SPACING); + +function buildWaveFormBars(barCount: number, halfHeightAt: (index: number) => number): WaveFormBar[] { + return Array.from({ length: barCount }, (_, index) => { + const x = WAVE_FORM_FIRST_BAR_X + index * WAVE_FORM_BAR_SPACING; + const halfHeight = halfHeightAt(index); + return { x, y1: WAVE_FORM_CENTER_Y - halfHeight, y2: WAVE_FORM_CENTER_Y + halfHeight }; + }); +} + +function getWaveFormViewBoxWidth(barCount: number): number { + return WAVE_FORM_FIRST_BAR_X + (barCount - 1) * WAVE_FORM_BAR_SPACING + WAVE_FORM_RIGHT_MARGIN; +} + +const SMALL_WAVE_FORM_BARS: readonly WaveFormBar[] = buildWaveFormBars( + WAVE_FORM_BAR_COUNT, + (index) => WAVE_FORM_BAR_HALF_HEIGHTS[index] +); + +// Large: the small wave form immediately followed by its own mirror, on one continuous grid -- +// not two small viewBoxes side by side, which would double their outer margins into a bigger gap. +const LARGE_WAVE_FORM_BARS: readonly WaveFormBar[] = buildWaveFormBars( + WAVE_FORM_BAR_COUNT * 2, + (index) => WAVE_FORM_BAR_HALF_HEIGHTS[index < WAVE_FORM_BAR_COUNT ? index : WAVE_FORM_BAR_COUNT * 2 - 1 - index] +); + +export function getWaveFormBars(size: AudioWaveFormDisplaySize): readonly WaveFormBar[] { + return size === 'large' ? LARGE_WAVE_FORM_BARS : SMALL_WAVE_FORM_BARS; +} + +// Expands the bars' bounding box to the full display box, per the padding ratios above. +export function getWaveFormViewBox(size: AudioWaveFormDisplaySize): string { + const contentWidth = getWaveFormViewBoxWidth(getWaveFormBars(size).length); + const contentHeight = WAVE_FORM_VIEW_BOX_HEIGHT; + + const width = contentWidth / (1 - 2 * WAVE_FORM_PADDING_X_RATIO); + const height = contentHeight / (1 - 2 * WAVE_FORM_PADDING_Y_RATIO); + const minX = -(width - contentWidth) / 2; + const minY = -(height - contentHeight) / 2; + return `${minX} ${minY} ${width} ${height}`; +} diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.scss b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.scss new file mode 100644 index 00000000..c81a4aa8 --- /dev/null +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.scss @@ -0,0 +1,22 @@ +.c-audio-wave-form-display { + display: block; + width: 100%; + height: 100%; + background-color: var(--c-audio-wave-form-display-bg, transparent); + + &__scaler { + display: block; + width: 100%; + height: 100%; + } + + &__svg { + display: block; + width: 100%; + height: 100%; + } + + &__bar { + stroke: var(--c-audio-wave-form-display-bar-color, var(--c-audio-wave-form-display-wave-color, #fff)); + } +} diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx new file mode 100644 index 00000000..7dde19c4 --- /dev/null +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { AudioWaveFormDisplay } from './AudioWaveFormDisplay'; + +const meta: Meta = { + title: 'Components/AudioWaveFormDisplay', + component: AudioWaveFormDisplay, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+
+ +
+
+ +
+
+ ), + args: {}, +}; + +export const CustomColors: Story = { + render: () => ( +
+ +
+ ), + args: {}, +}; diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx new file mode 100644 index 00000000..6d578df4 --- /dev/null +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx @@ -0,0 +1,57 @@ +import clsx from 'clsx'; +import type { CSSProperties, FC } from 'react'; +import { getWaveFormBars, getWaveFormViewBox, WAVE_FORM_STROKE_WIDTH } from './AudioWaveFormDisplay.helpers'; +import type { AudioWaveFormDisplayProps } from './AudioWaveFormDisplay.types'; + +import './AudioWaveFormDisplay.scss'; + +export const AudioWaveFormDisplay: FC = ({ + className, + rootClassName: root = 'c-audio-wave-form-display', + ariaLabel, + waveColor, + backgroundColor, + size = 'small', +}) => { + const bars = getWaveFormBars(size); + const viewBox = getWaveFormViewBox(size); + + return ( +
+ {/* Plain box for consumers to hook a hover-zoom transform onto: transitioning `transform` + on an itself doesn't animate smoothly in every browser, unlike an ordinary element. */} +
+ +
+
+ ); +}; diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx new file mode 100644 index 00000000..4b3aff40 --- /dev/null +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx @@ -0,0 +1,11 @@ +import type { DefaultComponentProps } from '../../types'; +import type { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; + +export type { AudioWaveFormDisplaySize }; + +export type AudioWaveFormDisplayProps = DefaultComponentProps & { + waveColor?: string; + backgroundColor?: string; + size?: AudioWaveFormDisplaySize; + ariaLabel?: string; +}; diff --git a/src/components/AudioWaveFormDisplay/index.ts b/src/components/AudioWaveFormDisplay/index.ts new file mode 100644 index 00000000..3926e2a6 --- /dev/null +++ b/src/components/AudioWaveFormDisplay/index.ts @@ -0,0 +1,3 @@ +export { AudioWaveFormDisplay } from './AudioWaveFormDisplay'; +export { WAVE_FORM_PADDING_X_PERCENT } from './AudioWaveFormDisplay.helpers'; +export * from './AudioWaveFormDisplay.types'; diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index c85eaeac..ecbdde9d 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -1,6 +1,7 @@ import { autoUpdate, offset as offsetHelper, + shift, useClick, useDismiss, useFloating, @@ -57,7 +58,11 @@ const Dropdown: FC = ({ children, ...props }) => { open ? onOpen() : onClose(); }, whileElementsMounted: autoUpdate, - middleware: [offsetHelper(offset)], + // `shift` nudges the flyout back within its clipping ancestor (e.g. the video player's + // `overflow: hidden` root) when the default placement would otherwise push part of it + // outside the visible area - without it, a flyout near an edge gets silently clipped + // instead of repositioned. + middleware: [offsetHelper(offset), shift({ padding: 8 })], }); const click = useClick(context); diff --git a/src/components/FlowPlayer/Controls/ControlBar.scss b/src/components/FlowPlayer/Controls/ControlBar.scss new file mode 100644 index 00000000..d08891fd --- /dev/null +++ b/src/components/FlowPlayer/Controls/ControlBar.scss @@ -0,0 +1,441 @@ +@use "sass:math"; +@use "../flowplayer-shared" as *; + +// Colors are runtime-themeable CSS custom properties (set inline by ControlBar.tsx from the +// `colors` config) - these are only the *fallback* values for when no config is passed. +$flowplayer-controls-default-bg: #000; +$flowplayer-controls-default-fg: #fff; +$flowplayer-controls-default-accent: #00c8aa; +$flowplayer-controls-default-flyout-bg: #fff; +$flowplayer-controls-radius: 99.9rem; // always fully round - clamps automatically to half the shorter side + +// All measurements below are taken directly from the Figma export (desktop.svg / mobile.svg / +// "with muted sound.svg") rather than assumed - see PR discussion for the raw numbers. + +// Play/pause and fullscreen: each is its own 48x36 pill (not a circle - `rx=18` on a 36-tall +// rect is a "stadium" shape). 36px is also the height of every other segment in the bar. +$flowplayer-icon-button-width: 4.8rem; +$flowplayer-icon-button-height: 3.6rem; +$flowplayer-controls-gap: 0.8rem; // gap between the 4 top-level segments +$flowplayer-controls-edge-offset: 0.8rem; // not shown in the exports (isolated bar, no video frame) - matches the inter-segment gap; flag if this should differ + +// Progress segment: [current time] -- gap -- [track] -- gap -- [duration], with the same value +// used for both the gap and the segment's own inline padding (measured ~16.8px on both sides, +// both files). +$flowplayer-progress-inline-gap: 1.68rem; +$flowplayer-progress-track-height: 0.4rem; +$flowplayer-progress-handle-size: 0.8rem; +$flowplayer-track-background: #222; // unplayed track +$flowplayer-buffered-background: #505050; // buffered range +$flowplayer-cuepoint-background: rgb(255 255 255 / 60%); // not present in any export - unconfirmed, left as-is + +// Volume/subtitles/speed share one pill: 4px inset on every edge, then each icon gets a 40x28 +// slot (28 = 36 - 2*4) with zero gap between adjacent slots - they sit flush against each other. +$flowplayer-slot-inset: 0.4rem; +$flowplayer-slot-width: 4rem; +$flowplayer-slot-height: 2.8rem; + +// Background shown behind a slot's icon when that control is toggled on (e.g. muted) or its own +// flyout is open - not exposed via the `colors` config, same treatment as the track/buffered/ +// cuepoint colors above (fixed, not brand-critical enough to warrant its own prop). +$flowplayer-slot-active-background: #009690; +// These were previously 0.5rem/1rem, silently assuming a 16px root font-size (5px/10px real +// result) - wrong for this codebase, where the actual root is 10px (62.5% reset, see +// hetarchief-client/admin-core-ui's own base styles). 0.8rem/1.6rem are the correct 8px/16px here. +$flowplayer-controls-padding-block: 0.8rem; // 8px +$flowplayer-controls-padding-inline: 1.6rem; // 16px +$flowplayer-controls-transition: opacity 0.2s ease; +$flowplayer-focus-outline-width: 0.2rem; +$flowplayer-focus-outline-offset: 0.2rem; + +// The subtitles flyout card, from "with call out.svg": 240px wide, square corners, drop shadow +// (blur 4, black 24% opacity, no offset), an "Off" row, a divider, then the track list. Padding +// and the checkmark<->label gap are approximated from the exported glyph bounding boxes (outline +// text has no precise box of its own) - close, not pixel-exact. +$flowplayer-subtitles-flyout-width: 24rem; +$flowplayer-flyout-shadow: 0 0 0.8rem rgb(0 0 0 / 24%); +$flowplayer-flyout-divider-color: #e6e6e6; +$flowplayer-flyout-option-padding: 2rem 3.7rem; +$flowplayer-flyout-option-icon-gap: 1.3rem; + +// Confirmed from Figma's inspect panel (not the outline-path estimate above): "Uit" and a track +// name are both 14px/500/20px - no weight difference between them, despite how it reads visually. +// The AI-generated-subtitles disclaimer text (not yet built, see PR discussion) is 12px/500/16px +// when that lands. + +@mixin flowplayer-focus-ring { + &:focus-visible { + outline: $flowplayer-focus-outline-width solid var(--flowplayer-controls-accent, $flowplayer-controls-default-accent); + outline-offset: $flowplayer-focus-outline-offset; + } +} + +@mixin flowplayer-absolute-fill-bar($background) { + position: absolute; + top: 0; + left: 0; + height: 100%; + border-radius: $flowplayer-controls-radius; + background-color: $background; +} + +// Nested under a real ancestor (present on the same element as `.flowplayer` itself) so every +// rule here outranks Flowplayer's own `.flowplayer * { background-color: transparent; ... }` +// reset, which has equal specificity to a single class selector and would otherwise win on +// source order (flowplayer.css loads after this file in the module graph). +.c-video-player-inner { + // The outer bar is just a transparent flex row - each segment below carries its own pill + // background, matching the design's four independently-grouped controls (play/pause, + // timestamps+progress, volume/subtitles/speed, fullscreen) rather than one continuous bar. + .c-flowplayer-control-bar { + position: absolute; + left: $flowplayer-controls-edge-offset; + right: $flowplayer-controls-edge-offset; + bottom: $flowplayer-controls-edge-offset; + z-index: 5; + display: flex; + align-items: center; + gap: $flowplayer-controls-gap; + color: var(--flowplayer-controls-fg, $flowplayer-controls-default-fg); + transition: $flowplayer-controls-transition; + opacity: 1; + + // Flowplayer's own `.flowplayer * { color: #fff; }` reset (same class of bug as the + // `background-color` one at the top of this file) directly matches every element in this + // bar and its flyouts, not just the bar itself - without re-affirming inherit at every + // level, anything that sets its own `color` (this rule, or a flyout's inline + // `flyoutForegroundColor` for readable content against its light popover background) has + // that override silently lost one level further down the tree. + * { + color: inherit; + } + + &--hidden { + opacity: 0; + pointer-events: none; + } + + &__segment { + display: flex; + align-items: center; + height: $flowplayer-icon-button-height; + border-radius: $flowplayer-controls-radius; + background-color: var(--flowplayer-controls-bg, $flowplayer-controls-default-bg); + + &--icon { + flex: 0 0 auto; + + // No padding: the button itself is the full 48x36 pill, matching the reference exactly. + padding: 0; + } + + &--progress { + flex: 1 1 auto; + min-width: 0; + + // Padding/gap live on `.c-flowplayer-progress` below, not here - ProgressBar + // renders its own root div as the single child of this segment, and that's where + // the actual [time][track][time] flex row lives. + } + + &--secondary { + flex: 0 0 auto; + gap: 0; // slots sit flush against each other, no inter-icon gap + padding: $flowplayer-slot-inset; + } + } + } + + // Opt-in (`customControlsConfig.showTitleOverlay`) - native mode only ever shows the title/logo + // overlay in fullscreen (see FlowPlayer.scss), essentially never for a normal embed, so custom + // mode defaults to that same conservative behaviour. `&.is-fullscreen` is the actual gate for + // that (missing here previously - the overlay was showing at any size once opted in); when + // fullscreen, this drives the overlays (DOM children of `.fp-ui`, built imperatively in + // FlowPlayer.internal.tsx - not this stylesheet's own markup) off the same auto-hide signal as + // the control bar, so both fade together instead of drifting apart on separate timers. + &.c-video-player-inner--show-title-overlay.is-fullscreen { + .c-title-overlay, + .c-logo-overlay { + display: block; + opacity: 1; + transition: $flowplayer-controls-transition; + } + + &.c-video-player-inner--controls-hidden { + .c-title-overlay, + .c-logo-overlay { + opacity: 0; + pointer-events: none; + } + } + } + + .c-flowplayer-control-button { + flex: 0 0 auto; + width: $flowplayer-icon-button-width; + height: $flowplayer-icon-button-height; + display: inline-flex; + align-items: center; + justify-content: center; + + // A host app's own global button reset can set a default padding/margin/line-height on + // bare + + ))} + + + +); diff --git a/src/components/FlowPlayer/Controls/SubtitlesControl.tsx b/src/components/FlowPlayer/Controls/SubtitlesControl.tsx new file mode 100644 index 00000000..d2754ac2 --- /dev/null +++ b/src/components/FlowPlayer/Controls/SubtitlesControl.tsx @@ -0,0 +1,106 @@ +import clsx from 'clsx'; +import type { FC } from 'react'; +import { Button } from '../../Button'; +import Dropdown from '../../Dropdown/Dropdown'; +import { DropdownButton, DropdownContent } from '../../Dropdown/Dropdown.slots'; +import { CheckIcon, SubtitlesHighlightedIcon, SubtitlesIcon } from './Controls.icons'; + +export interface SubtitleTrackOption { + key: string; + label: string; +} + +export interface SubtitlesControlProps { + id: string; + tracks: SubtitleTrackOption[]; + activeTrackKey: string | null; + onSelect: (trackKey: string | null) => void; + offLabel: string; + triggerLabel: string; + flyoutForegroundColor: string; + flyoutBackground: string; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +} + +/** + * Mirrors Flowplayer's own native "Subtitles" menu (track list + an off option) - not just a + * plain on/off toggle, so every subtitle track the consumer configured stays selectable. + */ +export const SubtitlesControl: FC = ({ + id, + tracks, + activeTrackKey, + onSelect, + offLabel, + triggerLabel, + flyoutForegroundColor, + flyoutBackground, + isOpen, + onOpen, + onClose, +}) => { + const isOn = activeTrackKey !== null; + const isHighlighted = isOn || isOpen; + + return ( + + + + + {tracks.length > 0 &&
  • + +
  • + ); + })} + + +
    + ); +}; diff --git a/src/components/FlowPlayer/Controls/VolumeBars.tsx b/src/components/FlowPlayer/Controls/VolumeBars.tsx new file mode 100644 index 00000000..17676180 --- /dev/null +++ b/src/components/FlowPlayer/Controls/VolumeBars.tsx @@ -0,0 +1,86 @@ +import type { FC, KeyboardEvent } from 'react'; +import { useDragValue } from './use-drag-value'; + +export interface VolumeBarsProps { + value: number; // 0-100 + steps?: number; + onChange: (value: number) => void; + accentColor: string; + unfilledColor: string; + ariaLabel: string; +} + +/** + * The row of bars IS the interactive control (click/drag sets discrete volume steps), not a + * decorative icon next to a separate slider. The bars are laid out left-to-right, so the drag + * axis has to be horizontal too - it was wired up as vertical before, which read the click/drag + * position along the container's ~20px height instead of its full width, making the actual step + * you landed on nearly random relative to where you clicked. + */ +export const VolumeBars: FC = ({ + value, + steps = 10, + onChange, + accentColor, + unfilledColor, + ariaLabel, +}) => { + const { containerRef, dragHandlers } = useDragValue({ + orientation: 'horizontal', + onChange: (percentage) => { + const stepIndex = Math.round((percentage / 100) * steps); + onChange(Math.max(0, Math.min(100, (stepIndex / steps) * 100))); + }, + }); + + const handleKeyDown = (event: KeyboardEvent) => { + const stepSize = 100 / steps; + switch (event.key) { + case 'ArrowUp': + case 'ArrowRight': + onChange(Math.min(100, value + stepSize)); + break; + case 'ArrowDown': + case 'ArrowLeft': + onChange(Math.max(0, value - stepSize)); + break; + case 'Home': + onChange(0); + break; + case 'End': + onChange(100); + break; + default: + return; + } + event.preventDefault(); + }; + + return ( +
    + {Array.from({ length: steps }).map((_, index) => { + const filled = value >= ((index + 1) / steps) * 100; + return ( + + ); + })} +
    + ); +}; diff --git a/src/components/FlowPlayer/Controls/VolumeControl.tsx b/src/components/FlowPlayer/Controls/VolumeControl.tsx new file mode 100644 index 00000000..a14c972b --- /dev/null +++ b/src/components/FlowPlayer/Controls/VolumeControl.tsx @@ -0,0 +1,104 @@ +import clsx from 'clsx'; +import type { FC } from 'react'; +import { Button } from '../../Button'; +import Dropdown from '../../Dropdown/Dropdown'; +import { DropdownButton, DropdownContent } from '../../Dropdown/Dropdown.slots'; +import type { FlowPlayerControlsLabels } from '../FlowPlayer.types'; +import { MuteHighlightedIcon, MuteIcon, VolumeHighlightedIcon, VolumeIcon } from './Controls.icons'; +import { VolumeBars } from './VolumeBars'; + +export interface VolumeControlProps { + id: string; + volume: number; + muted: boolean; + steps?: number; + onVolumeChange: (value: number) => void; + onToggleMute: () => void; + accentColor: string; + flyoutBackground: string; + flyoutForegroundColor: string; + labels: Required; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +} + +export const VolumeControl: FC = ({ + id, + volume, + muted, + steps, + onVolumeChange, + onToggleMute, + accentColor, + flyoutBackground, + flyoutForegroundColor, + labels, + isOpen, + onOpen, + onClose, +}) => { + const isMutedVisually = muted || volume === 0; + + return ( + + + + + ); + })} + + + + ); +}; diff --git a/src/components/FlowPlayer/Controls/SpeedControl.tsx b/src/components/FlowPlayer/Controls/SpeedControl.tsx index 0dfc659b..cca42c14 100644 --- a/src/components/FlowPlayer/Controls/SpeedControl.tsx +++ b/src/components/FlowPlayer/Controls/SpeedControl.tsx @@ -1,19 +1,16 @@ import clsx from 'clsx'; import type { FC } from 'react'; import { Button } from '../../Button'; -import Dropdown from '../../Dropdown/Dropdown'; -import { DropdownButton, DropdownContent } from '../../Dropdown/Dropdown.slots'; +import { ControlFlyout, type FlyoutOptionData } from './ControlFlyout'; export interface SpeedControlProps { id: string; - options: number[]; - labelsForOptions?: string[]; + options: FlyoutOptionData[]; currentRate: number; onChange: (rate: number) => void; label: string; flyoutBackground: string; flyoutForegroundColor: string; - accentColor: string; isOpen: boolean; onOpen: () => void; onClose: () => void; @@ -22,29 +19,26 @@ export interface SpeedControlProps { export const SpeedControl: FC = ({ id, options, - labelsForOptions, currentRate, onChange, label, flyoutBackground, flyoutForegroundColor, - accentColor, isOpen, onOpen, onClose, }) => ( - - + activeKey={currentRate} + onSelect={(key) => onChange(key as number)} + trigger={ - - ))} - - - + } + options={options} + /> ); diff --git a/src/components/FlowPlayer/Controls/SubtitlesControl.tsx b/src/components/FlowPlayer/Controls/SubtitlesControl.tsx index 2b0e2602..8e8741e4 100644 --- a/src/components/FlowPlayer/Controls/SubtitlesControl.tsx +++ b/src/components/FlowPlayer/Controls/SubtitlesControl.tsx @@ -1,14 +1,10 @@ import clsx from 'clsx'; import type { FC } from 'react'; import { Button } from '../../Button'; -import Dropdown from '../../Dropdown/Dropdown'; -import { DropdownButton, DropdownContent } from '../../Dropdown/Dropdown.slots'; -import { CheckIcon, SubtitlesHighlightedIcon, SubtitlesIcon } from './Controls.icons'; +import { ControlFlyout, type FlyoutOptionData } from './ControlFlyout'; +import { SubtitlesHighlightedIcon, SubtitlesIcon } from './Controls.icons'; -export interface SubtitleTrackOption { - key: string; - label: string; -} +export type SubtitleTrackOption = FlyoutOptionData; export interface SubtitlesControlProps { id: string; @@ -24,6 +20,8 @@ export interface SubtitlesControlProps { onClose: () => void; } +const OFF_KEY = '__off'; + /** Mirrors Flowplayer's own native "Subtitles" menu: track list + an off option, not a plain toggle. */ export const SubtitlesControl: FC = ({ id, @@ -41,19 +39,20 @@ export const SubtitlesControl: FC = ({ const isOn = activeTrackKey !== null; const isHighlighted = isOn || isOpen; + const options: FlyoutOptionData[] = [{ key: OFF_KEY, label: offLabel }, ...tracks]; + return ( - - + activeKey={activeTrackKey ?? OFF_KEY} + onSelect={(key) => onSelect(key === OFF_KEY ? null : (key as string))} + trigger={ - - {tracks.length > 0 &&
  • - -
  • - ); - })} - - -
    + } + options={options} + /> ); }; diff --git a/src/components/FlowPlayer/FlowPlayer.internal.tsx b/src/components/FlowPlayer/FlowPlayer.internal.tsx index 76483dc6..6b9ccaff 100644 --- a/src/components/FlowPlayer/FlowPlayer.internal.tsx +++ b/src/components/FlowPlayer/FlowPlayer.internal.tsx @@ -703,6 +703,7 @@ const FlowPlayerInternal: FunctionComponent = ({ config={customControlsConfig} isAudio={isAudio} hasSubtitles={!!subtitles?.length} + subtitles={subtitles} cuepoints={cuepointsForBar} speed={speed} containerRef={videoContainerRef} diff --git a/src/components/FlowPlayer/FlowPlayer.stories.tsx b/src/components/FlowPlayer/FlowPlayer.stories.tsx index 4c68eec5..b0f128c1 100644 --- a/src/components/FlowPlayer/FlowPlayer.stories.tsx +++ b/src/components/FlowPlayer/FlowPlayer.stories.tsx @@ -364,6 +364,7 @@ export const CustomControls: Story = { lang: 'nl', id: '123', label: 'Nederlands', + subLabel: 'nl', src: 'https://avo2-proxy-qas.hetarchief.be/subtitles/convert-srt-to-vtt/viaa/MOB/TESTBEELD/3b61046461be4b1e9f0fad19b42baa192487807cfefa4c289c0fa65d5c78195b/3b61046461be4b1e9f0fad19b42baa192487807cfefa4c289c0fa65d5c78195b.srt', }, ], diff --git a/src/components/FlowPlayer/FlowPlayer.types.ts b/src/components/FlowPlayer/FlowPlayer.types.ts index 95dc0df3..93e55d79 100644 --- a/src/components/FlowPlayer/FlowPlayer.types.ts +++ b/src/components/FlowPlayer/FlowPlayer.types.ts @@ -68,6 +68,9 @@ export interface FlowplayerTrackSchema { label: string; lang?: string; src: string; + /** Raw HTML, rendered below the label/icon row. */ + subLabel?: string; + icon?: ReactNode; } export interface FlowplayerSourceItem { From 2d26dac58d7c1f3c17af9ef7bf3fe2c29e823ba5 Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Mon, 7 Sep 2026 14:04:58 +0200 Subject: [PATCH 11/22] ARC-3904: Remove subtitle persistence --- .../FlowPlayer/Controls/ControlBar.tsx | 22 -- .../FlowPlayer/Controls/Controls.consts.ts | 1 - .../Controls/subtitles-track.helpers.test.ts | 27 +++ .../Controls/subtitles-track.helpers.ts | 29 +++ .../Controls/useSubtitlesPersistence.test.ts | 191 ------------------ .../Controls/useSubtitlesPersistence.ts | 82 -------- src/components/FlowPlayer/FlowPlayer.types.ts | 4 - 7 files changed, 56 insertions(+), 300 deletions(-) delete mode 100644 src/components/FlowPlayer/Controls/useSubtitlesPersistence.test.ts delete mode 100644 src/components/FlowPlayer/Controls/useSubtitlesPersistence.ts diff --git a/src/components/FlowPlayer/Controls/ControlBar.tsx b/src/components/FlowPlayer/Controls/ControlBar.tsx index 5fb26e97..896b46b4 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.tsx +++ b/src/components/FlowPlayer/Controls/ControlBar.tsx @@ -4,7 +4,6 @@ import type { ControlBarProps } from './ControlBar.types'; import type { FlowPlayerControlsColors, FlowPlayerControlsLabels } from '../FlowPlayer.types'; import { DEFAULT_AUTO_HIDE_DELAY_MS, - DEFAULT_PERSISTENCE_KEY_PREFIX, defaultControlsColors, defaultControlsLabels, isGenericPeakMode, @@ -25,7 +24,6 @@ import { import { useAutoHideControls } from './useAutoHideControls'; import { useFlowplayerState } from './useFlowplayerState'; import { useKeyboardShortcuts } from './useKeyboardShortcuts'; -import { useSubtitlesPersistence } from './useSubtitlesPersistence'; import { VolumeControl } from './VolumeControl'; import './ControlBar.scss'; @@ -65,8 +63,6 @@ export const ControlBar: FC = ({ peakColorInactive, peakColorBackground, autoHideDelayMs = DEFAULT_AUTO_HIDE_DELAY_MS, - persistPreferences = true, - persistenceKeyPrefix = DEFAULT_PERSISTENCE_KEY_PREFIX, colors = EMPTY_COLORS, labels = EMPTY_LABELS, } = config; @@ -127,30 +123,12 @@ export const ControlBar: FC = ({ }; }, [playerRef, playerInstance, subtitles]); - const { persist: persistSubtitles } = useSubtitlesPersistence({ - enabled: persistPreferences, - keyPrefix: persistenceKeyPrefix, - isPlayerReady: !!playerInstance, - hasTracks: subtitleTracks.length > 0, - onRestore: (storedTrackKey) => { - if (!playerRef.current) { - return false; - } - const restored = selectSubtitleTrack(playerRef.current, storedTrackKey); - if (restored) { - setActiveSubtitleTrackKey(storedTrackKey); - } - return restored; - }, - }); - const handleSelectSubtitleTrack = (trackKey: string | null) => { if (!playerRef.current) { return; } selectSubtitleTrack(playerRef.current, trackKey); setActiveSubtitleTrackKey(trackKey); - persistSubtitles(trackKey); }; // Listens on the whole player root, not just the bar itself, so moving the pointer anywhere diff --git a/src/components/FlowPlayer/Controls/Controls.consts.ts b/src/components/FlowPlayer/Controls/Controls.consts.ts index 3890c4fd..040685ab 100644 --- a/src/components/FlowPlayer/Controls/Controls.consts.ts +++ b/src/components/FlowPlayer/Controls/Controls.consts.ts @@ -1,7 +1,6 @@ import type { FlowPlayerControlsColors, FlowPlayerControlsLabels } from '../FlowPlayer.types'; export const DEFAULT_AUTO_HIDE_DELAY_MS = 3000; -export const DEFAULT_PERSISTENCE_KEY_PREFIX = 'meemoo-flowplayer'; export const DEFAULT_SHOW_PEAK = true; export const DEFAULT_PEAK_MODE = 'data' as const; diff --git a/src/components/FlowPlayer/Controls/subtitles-track.helpers.test.ts b/src/components/FlowPlayer/Controls/subtitles-track.helpers.test.ts index 2ef1dedb..0586affa 100644 --- a/src/components/FlowPlayer/Controls/subtitles-track.helpers.test.ts +++ b/src/components/FlowPlayer/Controls/subtitles-track.helpers.test.ts @@ -12,6 +12,7 @@ function buildTrack(overrides: Partial & { kind: TextTrackKind }) language: '', mode: 'disabled', is_active: false, + addEventListener: jest.fn(), ...overrides, } as FakeTextTrack; } @@ -41,6 +42,32 @@ describe('selectSubtitleTrack', () => { expect(track.mode).toBe('hidden'); }); + it('emits a synthetic "cuechange" after activating a track, mirroring the plugin\'s own non-native select behaviour - without it, the caption overlay can stay empty until the browser\'s own cuechange happens to fire', () => { + const track = buildTrack({ kind: 'subtitles', label: 'Nederlands', language: 'nl' }); + const { player } = buildPlayer([track]); + const key = getSubtitleTrackKey([track], track); + + selectSubtitleTrack(player, key); + + expect(player.emit).toHaveBeenCalledWith('cuechange', { track }); + }); + + it('also forwards the track\'s own native "cuechange" once, to catch the case where the immediate emit above raced the browser (cues/activeCues aren\'t available in the same tick right after a track\'s first activation - confirmed live: empty immediately after the mode flip, populated only after the browser parses/links the cues)', () => { + const track = buildTrack({ kind: 'subtitles', label: 'Nederlands', language: 'nl' }); + const { player } = buildPlayer([track]); + const key = getSubtitleTrackKey([track], track); + + selectSubtitleTrack(player, key); + + expect(track.addEventListener).toHaveBeenCalledWith('cuechange', expect.any(Function), { once: true }); + + (player.emit as jest.Mock).mockClear(); + const [, nativeHandler] = (track.addEventListener as jest.Mock).mock.calls[0]; + nativeHandler(); + + expect(player.emit).toHaveBeenCalledWith('cuechange', { track }); + }); + it('returns true and clears the active track when passed null', () => { const track = buildTrack({ kind: 'subtitles', label: 'Nederlands', language: 'nl' }); const { player } = buildPlayer([track]); diff --git a/src/components/FlowPlayer/Controls/subtitles-track.helpers.ts b/src/components/FlowPlayer/Controls/subtitles-track.helpers.ts index 4647d514..dd6761a6 100644 --- a/src/components/FlowPlayer/Controls/subtitles-track.helpers.ts +++ b/src/components/FlowPlayer/Controls/subtitles-track.helpers.ts @@ -55,6 +55,34 @@ function emitTracksUpdated(player: Player, track?: FlowplayerTextTrack) { ); } +/** + * Mirrors the subtitles plugin's own (non-native) track-select behaviour (checked against + * 3.32.1's plugins/subtitles.js): after activating a track, it manually re-emits "cuechange" so + * the plugin's own cue-rendering listener redraws `.fp-captions` immediately, instead of relying + * solely on the browser's native `cuechange` event on the TextTrack (which the plugin's own + * `oncuechange` closure - bound once, per track, when it was first added - can otherwise miss or + * delay, leaving the caption overlay empty even though the track is correctly marked active). + * + * On a track's first activation, though, that immediate emit races the browser: flipping `mode` + * from "disabled" to "hidden" doesn't make `track.cues`/`track.activeCues` available in the same + * tick (confirmed live - empty immediately after the flip, populated only after the browser + * parses/links the cues, ~hundreds of ms later), so the immediate render can still draw an empty + * frame. Also listen once for the track's own native "cuechange" so the real cue list gets forwarded + * as soon as the browser actually computes it, instead of leaving the premature empty render as the + * final state until some unrelated later cue change happens to fire. + */ +function emitCueChange(player: Player, track: FlowplayerTextTrack) { + const emit = (player as unknown as { emit: (event: string, payload?: unknown) => void }).emit.bind(player); + emit('cuechange', { track }); + track.addEventListener( + 'cuechange', + () => { + emit('cuechange', { track }); + }, + { once: true } + ); +} + /** * Selects a track by key, or pass `null` to turn subtitles off entirely. Returns whether the * selection actually took effect - `false` when `trackKey` doesn't match any currently loaded @@ -95,5 +123,6 @@ export function selectSubtitleTrack(player: Player, trackKey: string | null): bo target.mode = 'hidden'; target.is_active = true; emitTracksUpdated(player, target); + emitCueChange(player, target); return true; } diff --git a/src/components/FlowPlayer/Controls/useSubtitlesPersistence.test.ts b/src/components/FlowPlayer/Controls/useSubtitlesPersistence.test.ts deleted file mode 100644 index 53c82c55..00000000 --- a/src/components/FlowPlayer/Controls/useSubtitlesPersistence.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { useSubtitlesPersistence } from './useSubtitlesPersistence'; - -const KEY_PREFIX = 'test-flowplayer'; -const STORAGE_KEY = `${KEY_PREFIX}:subtitles-track`; - -describe('useSubtitlesPersistence', () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it('does not restore before the player is ready', () => { - window.localStorage.setItem(STORAGE_KEY, 'nl'); - const onRestore = jest.fn(() => true); - - renderHook(() => - useSubtitlesPersistence({ - enabled: true, - keyPrefix: KEY_PREFIX, - isPlayerReady: false, - hasTracks: true, - onRestore, - }) - ); - - expect(onRestore).not.toHaveBeenCalled(); - }); - - it('restores the stored track once the player becomes ready', () => { - // Regression: the restore effect used to run (and flip its one-shot guard) before the - // player existed, and was never retried once it became ready - so this restore was - // silently dropped every time. - window.localStorage.setItem(STORAGE_KEY, 'nl'); - const onRestore = jest.fn(() => true); - - const { rerender } = renderHook( - ({ isPlayerReady }) => - useSubtitlesPersistence({ - enabled: true, - keyPrefix: KEY_PREFIX, - isPlayerReady, - hasTracks: true, - onRestore, - }), - { initialProps: { isPlayerReady: false } } - ); - - expect(onRestore).not.toHaveBeenCalled(); - - rerender({ isPlayerReady: true }); - - expect(onRestore).toHaveBeenCalledTimes(1); - expect(onRestore).toHaveBeenCalledWith('nl'); - }); - - it('retries once tracks load, if the first attempt found nothing to select yet', () => { - // Regression: the player can exist (isPlayerReady) before its text tracks have populated - // (e.g. an HLS manifest parsed asynchronously) - a restore attempted in that window used to - // permanently mark itself done even though nothing was actually selected. - window.localStorage.setItem(STORAGE_KEY, 'nl'); - const onRestore = jest.fn(() => false); - - const { rerender } = renderHook( - ({ hasTracks }) => - useSubtitlesPersistence({ - enabled: true, - keyPrefix: KEY_PREFIX, - isPlayerReady: true, - hasTracks, - onRestore, - }), - { initialProps: { hasTracks: false } } - ); - - expect(onRestore).toHaveBeenCalledTimes(1); - - onRestore.mockReturnValue(true); - rerender({ hasTracks: true }); - - expect(onRestore).toHaveBeenCalledTimes(2); - - rerender({ hasTracks: false }); - rerender({ hasTracks: true }); - - expect(onRestore).toHaveBeenCalledTimes(2); - }); - - it('restores `null` when subtitles were explicitly turned off', () => { - window.localStorage.setItem(STORAGE_KEY, '__off__'); - const onRestore = jest.fn(() => true); - - renderHook(() => - useSubtitlesPersistence({ - enabled: true, - keyPrefix: KEY_PREFIX, - isPlayerReady: true, - hasTracks: true, - onRestore, - }) - ); - - expect(onRestore).toHaveBeenCalledWith(null); - }); - - it('does not call onRestore when nothing is stored', () => { - const onRestore = jest.fn(() => true); - - renderHook(() => - useSubtitlesPersistence({ - enabled: true, - keyPrefix: KEY_PREFIX, - isPlayerReady: true, - hasTracks: true, - onRestore, - }) - ); - - expect(onRestore).not.toHaveBeenCalled(); - }); - - it('only restores once, even if the player becomes ready again', () => { - window.localStorage.setItem(STORAGE_KEY, 'nl'); - const onRestore = jest.fn(() => true); - - const { rerender } = renderHook( - ({ isPlayerReady }) => - useSubtitlesPersistence({ - enabled: true, - keyPrefix: KEY_PREFIX, - isPlayerReady, - hasTracks: true, - onRestore, - }), - { initialProps: { isPlayerReady: true } } - ); - rerender({ isPlayerReady: false }); - rerender({ isPlayerReady: true }); - - expect(onRestore).toHaveBeenCalledTimes(1); - }); - - it('does not restore when persistence is disabled', () => { - window.localStorage.setItem(STORAGE_KEY, 'nl'); - const onRestore = jest.fn(() => true); - - renderHook(() => - useSubtitlesPersistence({ - enabled: false, - keyPrefix: KEY_PREFIX, - isPlayerReady: true, - hasTracks: true, - onRestore, - }) - ); - - expect(onRestore).not.toHaveBeenCalled(); - }); - - it('persist() writes the track key, and null as the off-sentinel', () => { - const { result } = renderHook(() => - useSubtitlesPersistence({ - enabled: true, - keyPrefix: KEY_PREFIX, - isPlayerReady: true, - hasTracks: true, - onRestore: jest.fn(() => true), - }) - ); - - result.current.persist('en'); - expect(window.localStorage.getItem(STORAGE_KEY)).toEqual('en'); - - result.current.persist(null); - expect(window.localStorage.getItem(STORAGE_KEY)).toEqual('__off__'); - }); - - it('persist() does nothing when disabled', () => { - const { result } = renderHook(() => - useSubtitlesPersistence({ - enabled: false, - keyPrefix: KEY_PREFIX, - isPlayerReady: true, - hasTracks: true, - onRestore: jest.fn(() => true), - }) - ); - - result.current.persist('en'); - expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); - }); -}); diff --git a/src/components/FlowPlayer/Controls/useSubtitlesPersistence.ts b/src/components/FlowPlayer/Controls/useSubtitlesPersistence.ts deleted file mode 100644 index 14032722..00000000 --- a/src/components/FlowPlayer/Controls/useSubtitlesPersistence.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useEffect, useRef } from 'react'; - -const STORAGE_SUFFIX = 'subtitles-track'; -const OFF_VALUE = '__off__'; - -/** Returns undefined when nothing is stored, null when subtitles were explicitly turned off. */ -function readStoredValue(keyPrefix: string): string | null | undefined { - try { - const raw = window.localStorage.getItem(`${keyPrefix}:${STORAGE_SUFFIX}`); - if (raw === null) { - return undefined; - } - return raw === OFF_VALUE ? null : raw; - } catch { - // private browsing / SSR / storage disabled - return undefined; - } -} - -function writeStoredValue(keyPrefix: string, trackKey: string | null): void { - try { - window.localStorage.setItem(`${keyPrefix}:${STORAGE_SUFFIX}`, trackKey ?? OFF_VALUE); - } catch { - // private browsing / SSR / storage disabled - } -} - -export interface UseSubtitlesPersistenceOptions { - /** Whether persistence is turned on at all (`customControlsConfig.persistPreferences`). */ - enabled: boolean; - keyPrefix: string; - /** Restore is a no-op until the player actually exists - the control bar mounts before it's created. */ - isPlayerReady: boolean; - /** - * Whether at least one subtitle track has loaded. The player can exist before its text tracks - * have populated (e.g. an HLS manifest parsed asynchronously); restore retries whenever this - * flips, instead of giving up permanently the first time it's tried too early. - */ - hasTracks: boolean; - /** - * Called once the player is ready, only if a stored preference exists (null = subtitles off). - * Must return whether the restore actually took effect - a `false` return (target track not - * loaded yet) keeps the restore retryable instead of marking it done. - */ - onRestore: (trackKey: string | null) => boolean; -} - -/** Only subtitle track selection needs custom persistence - volume/mute persist via Flowplayer's own storage. */ -export function useSubtitlesPersistence({ - enabled, - keyPrefix, - isPlayerReady, - hasTracks, - onRestore, -}: UseSubtitlesPersistenceOptions) { - const hasRestoredRef = useRef(false); - const onRestoreRef = useRef(onRestore); - onRestoreRef.current = onRestore; - - // biome-ignore lint/correctness/useExhaustiveDependencies: `hasTracks` isn't read in the body, it only re-triggers a retry once tracks that weren't there on the first attempt load - useEffect(() => { - if (!enabled || !isPlayerReady || hasRestoredRef.current) { - return; - } - const stored = readStoredValue(keyPrefix); - if (stored === undefined) { - hasRestoredRef.current = true; - return; - } - if (onRestoreRef.current(stored)) { - hasRestoredRef.current = true; - } - }, [enabled, keyPrefix, isPlayerReady, hasTracks]); - - const persist = (trackKey: string | null) => { - if (enabled) { - writeStoredValue(keyPrefix, trackKey); - } - }; - - return { persist }; -} diff --git a/src/components/FlowPlayer/FlowPlayer.types.ts b/src/components/FlowPlayer/FlowPlayer.types.ts index 93e55d79..5194c072 100644 --- a/src/components/FlowPlayer/FlowPlayer.types.ts +++ b/src/components/FlowPlayer/FlowPlayer.types.ts @@ -173,10 +173,6 @@ export interface FlowPlayerCustomControlsConfig { peakColorBackground?: string; // eg: '#FFFFFF' autoHideDelayMs?: number; // default 3000, 0 disables auto-hide - // Only governs the subtitle on/off preference - volume/mute already persist via - // Flowplayer's own internal storage regardless of this flag. - persistPreferences?: boolean; // default true - persistenceKeyPrefix?: string; // default 'meemoo-flowplayer' // Native mode only ever shows the title/logo overlay in fullscreen (see FlowPlayer.scss) - a // normal embedded player essentially never displays it. Custom mode keeps that same From 3e96bef2670bb1ef889f13d1acefb716aafe5d9b Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Mon, 7 Sep 2026 14:07:59 +0200 Subject: [PATCH 12/22] ARC-3904: Make colors a bit more tweakable --- src/components/FlowPlayer/Controls/ControlBar.scss | 2 +- src/components/FlowPlayer/Controls/ControlBar.tsx | 3 ++- src/components/FlowPlayer/Controls/Controls.consts.ts | 3 ++- src/components/FlowPlayer/FlowPlayer.types.ts | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/FlowPlayer/Controls/ControlBar.scss b/src/components/FlowPlayer/Controls/ControlBar.scss index b3e19c64..1f4b6b98 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.scss +++ b/src/components/FlowPlayer/Controls/ControlBar.scss @@ -56,7 +56,7 @@ $flowplayer-flyout-option-padding-inline-end: 1.6rem; $flowplayer-flyout-option-row-gap: 0.8rem; $flowplayer-flyout-option-sublabel-gap: 0.4rem; $flowplayer-flyout-option-icon-size: 2rem; -$flowplayer-flyout-option-divider-color: silver; +$flowplayer-flyout-option-divider-color: #dbdbdb; // silver $flowplayer-flyout-option-text-color: #000; $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" diff --git a/src/components/FlowPlayer/Controls/ControlBar.tsx b/src/components/FlowPlayer/Controls/ControlBar.tsx index 896b46b4..32d0ee26 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.tsx +++ b/src/components/FlowPlayer/Controls/ControlBar.tsx @@ -186,6 +186,7 @@ export const ControlBar: FC = ({ const colorVars: CSSProperties = { ['--flowplayer-controls-bg' as string]: mergedColors.backgroundColor, ['--flowplayer-controls-fg' as string]: mergedColors.foregroundColor, + ['--flowplayer-controls-progress' as string]: mergedColors.progressColor, ['--flowplayer-controls-accent' as string]: mergedColors.accentColor, ['--flowplayer-controls-flyout-bg' as string]: mergedColors.flyoutBackground, }; @@ -234,7 +235,7 @@ export const ControlBar: FC = ({ onSeekEnd={handleSeekEnd} showTimestamps={showTimestamps} cuepoints={cuepoints} - accentColor={mergedColors.accentColor} + accentColor={mergedColors.progressColor} foregroundColor={mergedColors.foregroundColor} ariaLabel={mergedLabels.progressBar} /> diff --git a/src/components/FlowPlayer/Controls/Controls.consts.ts b/src/components/FlowPlayer/Controls/Controls.consts.ts index 040685ab..78d2a654 100644 --- a/src/components/FlowPlayer/Controls/Controls.consts.ts +++ b/src/components/FlowPlayer/Controls/Controls.consts.ts @@ -13,7 +13,8 @@ export function isGenericPeakMode(showPeak: boolean | undefined, peakMode: 'data export const defaultControlsColors: Required = { backgroundColor: '#000000', foregroundColor: '#FFFFFF', - accentColor: '#00c8aa', + progressColor: '#00CCA9', + accentColor: '#009991', flyoutBackground: '#FFFFFF', flyoutForeground: '#000000', }; diff --git a/src/components/FlowPlayer/FlowPlayer.types.ts b/src/components/FlowPlayer/FlowPlayer.types.ts index 5194c072..91614a13 100644 --- a/src/components/FlowPlayer/FlowPlayer.types.ts +++ b/src/components/FlowPlayer/FlowPlayer.types.ts @@ -187,7 +187,8 @@ export interface FlowPlayerCustomControlsConfig { export interface FlowPlayerControlsColors { backgroundColor?: string; // control bar background + button backgrounds foregroundColor?: string; // icon color + timestamp text color, against `backgroundColor` - accentColor?: string; // progress fill/handle, an active/highlighted button, filled volume bars + progressColor?: string; // progress fill/handle + accentColor?: string; // background of a highlighted/selected button (muted, subtitles/speed open or on) flyoutBackground?: string; // volume/subtitles/speed popover surface // Text/icon color *inside* a flyout popover, against `flyoutBackground` - deliberately // separate from `foregroundColor`: that one is meant to read against the dark bar, this one From 391925ae088f607ed60fe346e06d4c3e9f0077c5 Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Tue, 8 Sep 2026 07:37:40 +0200 Subject: [PATCH 13/22] ARC-3904: PR remarks audio wave form --- .../AudioWaveFormDisplay.helpers.ts | 16 +++++++++++----- .../AudioWaveFormDisplay.stories.tsx | 5 +++-- .../AudioWaveFormDisplay.tsx | 13 +++++++++---- .../AudioWaveFormDisplay.types.tsx | 4 +--- .../FlowPlayer/Controls/PeakDisplay.tsx | 9 ++++++--- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts index d6c229ff..3381c53e 100644 --- a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts @@ -1,9 +1,15 @@ -export type AudioWaveFormDisplaySize = 'small' | 'large'; +export enum AudioWaveFormDisplaySize { + Small = 'small', + Large = 'large', +} export interface WaveFormBar { + /** Horizontal position of the bar (viewBox units). Shared by both endpoints since the bar is a vertical line. */ x: number; - y1: number; - y2: number; + /** Y-coordinate of the bar's top endpoint (viewBox units). */ + yTop: number; + /** Y-coordinate of the bar's bottom endpoint (viewBox units). */ + yBottom: number; } // Bar geometry traced from the original design asset. @@ -39,7 +45,7 @@ function buildWaveFormBars(barCount: number, halfHeightAt: (index: number) => nu return Array.from({ length: barCount }, (_, index) => { const x = WAVE_FORM_FIRST_BAR_X + index * WAVE_FORM_BAR_SPACING; const halfHeight = halfHeightAt(index); - return { x, y1: WAVE_FORM_CENTER_Y - halfHeight, y2: WAVE_FORM_CENTER_Y + halfHeight }; + return { x, yTop: WAVE_FORM_CENTER_Y - halfHeight, yBottom: WAVE_FORM_CENTER_Y + halfHeight }; }); } @@ -59,7 +65,7 @@ const LARGE_WAVE_FORM_BARS: readonly WaveFormBar[] = buildWaveFormBars( ); export function getWaveFormBars(size: AudioWaveFormDisplaySize): readonly WaveFormBar[] { - return size === 'large' ? LARGE_WAVE_FORM_BARS : SMALL_WAVE_FORM_BARS; + return size === AudioWaveFormDisplaySize.Large ? LARGE_WAVE_FORM_BARS : SMALL_WAVE_FORM_BARS; } // Expands the bars' bounding box to the full display box, per the padding ratios above. diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx index 7dde19c4..66735be8 100644 --- a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { AudioWaveFormDisplay } from './AudioWaveFormDisplay'; +import { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; const meta: Meta = { title: 'Components/AudioWaveFormDisplay', @@ -13,10 +14,10 @@ export const Default: Story = { render: () => (
    - +
    - +
    ), diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx index 434ad83f..e8f4d1d1 100644 --- a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx @@ -1,6 +1,11 @@ import clsx from 'clsx'; import { type CSSProperties, type FC, memo } from 'react'; -import { getWaveFormBars, getWaveFormViewBox, WAVE_FORM_STROKE_WIDTH } from './AudioWaveFormDisplay.helpers'; +import { + AudioWaveFormDisplaySize, + getWaveFormBars, + getWaveFormViewBox, + WAVE_FORM_STROKE_WIDTH, +} from './AudioWaveFormDisplay.helpers'; import type { AudioWaveFormDisplayProps } from './AudioWaveFormDisplay.types'; import './AudioWaveFormDisplay.scss'; @@ -14,7 +19,7 @@ export const AudioWaveFormDisplay: FC = memo(function ariaLabel, waveColor, backgroundColor, - size = 'small', + size = AudioWaveFormDisplaySize.Small, }) { const bars = getWaveFormBars(size); const viewBox = getWaveFormViewBox(size); @@ -47,8 +52,8 @@ export const AudioWaveFormDisplay: FC = memo(function className="c-audio-wave-form-display__bar" x1={bar.x} x2={bar.x} - y1={bar.y1} - y2={bar.y2} + y1={bar.yTop} + y2={bar.yBottom} strokeWidth={WAVE_FORM_STROKE_WIDTH} strokeLinecap="round" /> diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx index 4b3aff40..5745651b 100644 --- a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx @@ -1,7 +1,5 @@ import type { DefaultComponentProps } from '../../types'; -import type { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; - -export type { AudioWaveFormDisplaySize }; +import { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; export type AudioWaveFormDisplayProps = DefaultComponentProps & { waveColor?: string; diff --git a/src/components/FlowPlayer/Controls/PeakDisplay.tsx b/src/components/FlowPlayer/Controls/PeakDisplay.tsx index edd9c286..a2a737c8 100644 --- a/src/components/FlowPlayer/Controls/PeakDisplay.tsx +++ b/src/components/FlowPlayer/Controls/PeakDisplay.tsx @@ -1,6 +1,9 @@ import type { FC } from 'react'; import { AudioWaveFormDisplay } from '../../AudioWaveFormDisplay/AudioWaveFormDisplay'; -import { WAVE_FORM_PADDING_X_PERCENT } from '../../AudioWaveFormDisplay/AudioWaveFormDisplay.helpers'; +import { + AudioWaveFormDisplaySize, + WAVE_FORM_PADDING_X_PERCENT, +} from '../../AudioWaveFormDisplay/AudioWaveFormDisplay.helpers'; import { clamp } from '../../../utils/clamp'; export interface PeakDisplayProps { @@ -24,13 +27,13 @@ export const PeakDisplay: FC = ({ return (
    - +
    - +
    ); From 6bd12c5882db6fe671c9195932d0cffa2786242a Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Tue, 8 Sep 2026 07:48:54 +0200 Subject: [PATCH 14/22] ARC-3904: PR remarks css --- .../FlowPlayer/Controls/ControlBar.scss | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/components/FlowPlayer/Controls/ControlBar.scss b/src/components/FlowPlayer/Controls/ControlBar.scss index 1f4b6b98..db939643 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.scss +++ b/src/components/FlowPlayer/Controls/ControlBar.scss @@ -1,60 +1,58 @@ @use "sass:math"; @use "../flowplayer-shared" as *; +$g-spacer-unit: 0.8rem; + // Colors are runtime CSS custom properties set by ControlBar.tsx - these are just the fallbacks. $flowplayer-controls-default-bg: #000; $flowplayer-controls-default-fg: #fff; $flowplayer-controls-default-accent: #00c8aa; -$flowplayer-controls-default-flyout-bg: #fff; $flowplayer-controls-radius: 99.9rem; // always fully round - clamps automatically to half the shorter side // Measurements below are taken from the Figma export, not assumed. // Play/pause and fullscreen: each a 48x36 "stadium" pill (rx=18). 36px is also the height of // every other segment in the bar. -$flowplayer-icon-button-width: 4.8rem; -$flowplayer-icon-button-height: 3.6rem; -$flowplayer-controls-gap: 0.8rem; // gap between the 4 top-level segments -$flowplayer-controls-edge-offset: 0.8rem; // not in the exports - matches the inter-segment gap; flag if this should differ +$flowplayer-icon-button-width: $g-spacer-unit * 6; // 4.8rem +$flowplayer-icon-button-height: $g-spacer-unit * 4.5; // 3.6rem +$flowplayer-controls-edge-offset: $g-spacer-unit; // Progress segment: [current time] -- gap -- [track] -- gap -- [duration], same value for the // gap and the segment's own inline padding. $flowplayer-progress-inline-gap: 1.68rem; -$flowplayer-progress-track-height: 0.4rem; -$flowplayer-progress-handle-size: 0.8rem; +$flowplayer-progress-track-height: math.div($g-spacer-unit, 2); // 0.4rem +$flowplayer-progress-handle-size: $g-spacer-unit; $flowplayer-track-background: #222; // unplayed track $flowplayer-buffered-background: #505050; // buffered range $flowplayer-cuepoint-background: rgb(255 255 255 / 60%); // not present in any export - unconfirmed, left as-is // Volume/subtitles/speed share one pill: 4px inset on every edge, then each icon gets a 40x28 // slot (28 = 36 - 2*4) with zero gap between adjacent slots - they sit flush against each other. -$flowplayer-slot-inset: 0.4rem; -$flowplayer-slot-width: 4rem; -$flowplayer-slot-height: 2.8rem; +$flowplayer-slot-inset: math.div($g-spacer-unit, 2); // 0.4rem +$flowplayer-slot-width: $g-spacer-unit * 5; // 4rem +$flowplayer-slot-height: $g-spacer-unit * 3.5; // 2.8rem // Background for a toggled-on slot icon (e.g. muted) or an open flyout trigger - follows the // accent color from `colors` (falls back to this when unset). $flowplayer-slot-active-background: #009690; // This codebase's root font-size is 10px (62.5% reset), not the usual 16px - 0.8rem/1.6rem are the correct 8px/16px here. -$flowplayer-controls-padding-block: 0.8rem; // 8px -$flowplayer-controls-padding-inline: 1.6rem; // 16px $flowplayer-controls-transition: opacity 0.2s ease; $flowplayer-focus-outline-width: 0.2rem; $flowplayer-focus-outline-offset: 0.2rem; // Subtitles flyout card: 240px wide, square corners, drop shadow, "Off" row + track list. $flowplayer-subtitles-flyout-width: 24rem; -$flowplayer-flyout-shadow: 0 0 0.8rem rgb(0 0 0 / 24%); +$flowplayer-flyout-shadow: 0 0 $g-spacer-unit rgb(0 0 0 / 24%); // Option row: checkmark box (reserves its space even when empty) -- gap -- label (+ optional // trailing icon), with an optional HTML sublabel below, indented to sit under the label/icon // rather than the checkmark. One divider line between every option, not just at fixed points. -$flowplayer-flyout-option-padding-block: 1.6rem; -$flowplayer-flyout-option-padding-inline-start: 0.8rem; -$flowplayer-flyout-option-padding-inline-end: 1.6rem; -$flowplayer-flyout-option-row-gap: 0.8rem; -$flowplayer-flyout-option-sublabel-gap: 0.4rem; +$flowplayer-flyout-option-padding-block: $g-spacer-unit * 2; +$flowplayer-flyout-option-padding-inline-start: $g-spacer-unit; +$flowplayer-flyout-option-padding-inline-end: $g-spacer-unit*2; +$flowplayer-flyout-option-row-gap: $g-spacer-unit; +$flowplayer-flyout-option-sublabel-gap: math.div($g-spacer-unit, 2); $flowplayer-flyout-option-icon-size: 2rem; $flowplayer-flyout-option-divider-color: #dbdbdb; // silver $flowplayer-flyout-option-text-color: #000; @@ -89,7 +87,7 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" z-index: 5; display: flex; align-items: center; - gap: $flowplayer-controls-gap; + gap: $g-spacer-unit; // gap between the 4 top-level segments color: var(--flowplayer-controls-fg, $flowplayer-controls-default-fg); transition: $flowplayer-controls-transition; opacity: 1; @@ -314,7 +312,7 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" // divider. The divider therefore lives on the
  • , where sibling order is real. &-item { &:not(:last-child) { - border-bottom: 0.1rem solid $flowplayer-flyout-option-divider-color; + border-bottom: 1px solid $flowplayer-flyout-option-divider-color; } } From 22a0826e968cce989cdb0fb36022722729c8d81d Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Tue, 8 Sep 2026 07:52:24 +0200 Subject: [PATCH 15/22] ARC-3904: PR remarks flyoutId to enum --- .../FlowPlayer/Controls/ControlBar.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/FlowPlayer/Controls/ControlBar.tsx b/src/components/FlowPlayer/Controls/ControlBar.tsx index 32d0ee26..0cec1d24 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.tsx +++ b/src/components/FlowPlayer/Controls/ControlBar.tsx @@ -28,7 +28,10 @@ import { VolumeControl } from './VolumeControl'; import './ControlBar.scss'; -type FlyoutId = 'subtitles' | 'speed'; +enum FlyoutId { + subtitles = 'subtitles', + speed = 'speed', +} // Stable identities for unset `config`/`colors`/`labels` - a `= {}` default in a destructuring // pattern allocates a new object every render, which would defeat the `useMemo`s below keyed on @@ -258,9 +261,9 @@ export const ControlBar: FC = ({ triggerLabel={mergedLabels.subtitles} flyoutForegroundColor={mergedColors.flyoutForeground} flyoutBackground={mergedColors.flyoutBackground} - isOpen={openFlyout === 'subtitles'} - onOpen={() => openFlyoutHandler('subtitles')} - onClose={() => closeFlyoutHandler('subtitles')} + isOpen={openFlyout === FlyoutId.subtitles} + onOpen={() => openFlyoutHandler(FlyoutId.subtitles)} + onClose={() => closeFlyoutHandler(FlyoutId.subtitles)} /> )} @@ -276,9 +279,9 @@ export const ControlBar: FC = ({ label={mergedLabels.speed} flyoutBackground={mergedColors.flyoutBackground} flyoutForegroundColor={mergedColors.flyoutForeground} - isOpen={openFlyout === 'speed'} - onOpen={() => openFlyoutHandler('speed')} - onClose={() => closeFlyoutHandler('speed')} + isOpen={openFlyout === FlyoutId.speed} + onOpen={() => openFlyoutHandler(FlyoutId.speed)} + onClose={() => closeFlyoutHandler(FlyoutId.speed)} /> )} From d03304439ac06daf3fe7b47168e29c8f2bbc22fd Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Tue, 8 Sep 2026 08:32:51 +0200 Subject: [PATCH 16/22] ARC-3904: PR remarks tweak color fallbacks and configs --- .../FlowPlayer/Controls/ControlBar.scss | 30 ++++++++----------- .../FlowPlayer/Controls/ControlBar.tsx | 6 +--- .../FlowPlayer/Controls/ControlFlyout.tsx | 9 +----- .../FlowPlayer/Controls/Controls.consts.ts | 3 +- .../FlowPlayer/Controls/ProgressBar.tsx | 21 ++++++++----- .../FlowPlayer/Controls/ProgressBar.types.ts | 1 + .../FlowPlayer/Controls/SpeedControl.tsx | 6 ---- .../FlowPlayer/Controls/SubtitlesControl.tsx | 6 ---- .../FlowPlayer/FlowPlayer.stories.tsx | 1 - src/components/FlowPlayer/FlowPlayer.types.ts | 8 +---- 10 files changed, 30 insertions(+), 61 deletions(-) diff --git a/src/components/FlowPlayer/Controls/ControlBar.scss b/src/components/FlowPlayer/Controls/ControlBar.scss index db939643..908101dd 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.scss +++ b/src/components/FlowPlayer/Controls/ControlBar.scss @@ -2,11 +2,6 @@ @use "../flowplayer-shared" as *; $g-spacer-unit: 0.8rem; - -// Colors are runtime CSS custom properties set by ControlBar.tsx - these are just the fallbacks. -$flowplayer-controls-default-bg: #000; -$flowplayer-controls-default-fg: #fff; -$flowplayer-controls-default-accent: #00c8aa; $flowplayer-controls-radius: 99.9rem; // always fully round - clamps automatically to half the shorter side // Measurements below are taken from the Figma export, not assumed. @@ -24,7 +19,6 @@ $flowplayer-progress-track-height: math.div($g-spacer-unit, 2); // 0.4rem $flowplayer-progress-handle-size: $g-spacer-unit; $flowplayer-track-background: #222; // unplayed track $flowplayer-buffered-background: #505050; // buffered range -$flowplayer-cuepoint-background: rgb(255 255 255 / 60%); // not present in any export - unconfirmed, left as-is // Volume/subtitles/speed share one pill: 4px inset on every edge, then each icon gets a 40x28 // slot (28 = 36 - 2*4) with zero gap between adjacent slots - they sit flush against each other. @@ -32,10 +26,6 @@ $flowplayer-slot-inset: math.div($g-spacer-unit, 2); // 0.4rem $flowplayer-slot-width: $g-spacer-unit * 5; // 4rem $flowplayer-slot-height: $g-spacer-unit * 3.5; // 2.8rem -// Background for a toggled-on slot icon (e.g. muted) or an open flyout trigger - follows the -// accent color from `colors` (falls back to this when unset). -$flowplayer-slot-active-background: #009690; - // This codebase's root font-size is 10px (62.5% reset), not the usual 16px - 0.8rem/1.6rem are the correct 8px/16px here. $flowplayer-controls-transition: opacity 0.2s ease; $flowplayer-focus-outline-width: 0.2rem; @@ -54,13 +44,14 @@ $flowplayer-flyout-option-padding-inline-end: $g-spacer-unit*2; $flowplayer-flyout-option-row-gap: $g-spacer-unit; $flowplayer-flyout-option-sublabel-gap: math.div($g-spacer-unit, 2); $flowplayer-flyout-option-icon-size: 2rem; +$flowplayer-flyout-background: #fff; $flowplayer-flyout-option-divider-color: #dbdbdb; // silver $flowplayer-flyout-option-text-color: #000; $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" @mixin flowplayer-focus-ring { &:focus-visible { - outline: $flowplayer-focus-outline-width solid var(--flowplayer-controls-accent, $flowplayer-controls-default-accent); + outline: $flowplayer-focus-outline-width solid var(--flowplayer-controls-accent); outline-offset: $flowplayer-focus-outline-offset; } } @@ -88,7 +79,7 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" display: flex; align-items: center; gap: $g-spacer-unit; // gap between the 4 top-level segments - color: var(--flowplayer-controls-fg, $flowplayer-controls-default-fg); + color: var(--flowplayer-controls-fg); transition: $flowplayer-controls-transition; opacity: 1; @@ -108,7 +99,7 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" align-items: center; height: $flowplayer-icon-button-height; border-radius: $flowplayer-controls-radius; - background-color: var(--flowplayer-controls-bg, $flowplayer-controls-default-bg); + background-color: var(--flowplayer-controls-bg); &--icon { flex: 0 0 auto; @@ -169,7 +160,7 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" background: transparent; border: none; cursor: pointer; - color: var(--flowplayer-controls-fg, $flowplayer-controls-default-fg); + color: var(--flowplayer-controls-fg); // Same reasoning, one level down. [class$="__content"], @@ -191,7 +182,7 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" // Toggled-on slot (e.g. muted) or its flyout open - background only, icon stays regular color. &--active { - background-color: var(--flowplayer-controls-accent, $flowplayer-slot-active-background); + background-color: var(--flowplayer-controls-accent); } // Speed trigger shows the rate as text ("1x") instead of an icon - restore line-height and size it to fit the slot. @@ -260,11 +251,13 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" transform: translate(-50%, -50%); } + // Taller than the track itself (same height as the handle) so it still pokes out above/below + // the fill once playback has passed it, instead of being fully hidden at the track's height. &__cuepoint { position: absolute; - top: 0; - height: 100%; - background-color: $flowplayer-cuepoint-background; + top: 50%; + height: $g-spacer-unit * 1.5; + transform: translateY(-50%); border-radius: $flowplayer-controls-radius; pointer-events: none; } @@ -291,6 +284,7 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" list-style: none; margin: 0; padding: 0; + background-color: $flowplayer-flyout-background; } &__option { diff --git a/src/components/FlowPlayer/Controls/ControlBar.tsx b/src/components/FlowPlayer/Controls/ControlBar.tsx index 0cec1d24..1720daac 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.tsx +++ b/src/components/FlowPlayer/Controls/ControlBar.tsx @@ -191,7 +191,6 @@ export const ControlBar: FC = ({ ['--flowplayer-controls-fg' as string]: mergedColors.foregroundColor, ['--flowplayer-controls-progress' as string]: mergedColors.progressColor, ['--flowplayer-controls-accent' as string]: mergedColors.accentColor, - ['--flowplayer-controls-flyout-bg' as string]: mergedColors.flyoutBackground, }; return ( @@ -240,6 +239,7 @@ export const ControlBar: FC = ({ cuepoints={cuepoints} accentColor={mergedColors.progressColor} foregroundColor={mergedColors.foregroundColor} + cuepointColor={mergedColors.cuepointColor} ariaLabel={mergedLabels.progressBar} /> @@ -259,8 +259,6 @@ export const ControlBar: FC = ({ onSelect={handleSelectSubtitleTrack} offLabel={mergedLabels.subtitlesOff} triggerLabel={mergedLabels.subtitles} - flyoutForegroundColor={mergedColors.flyoutForeground} - flyoutBackground={mergedColors.flyoutBackground} isOpen={openFlyout === FlyoutId.subtitles} onOpen={() => openFlyoutHandler(FlyoutId.subtitles)} onClose={() => closeFlyoutHandler(FlyoutId.subtitles)} @@ -277,8 +275,6 @@ export const ControlBar: FC = ({ currentRate={state.playbackRate} onChange={actions.setPlaybackRate} label={mergedLabels.speed} - flyoutBackground={mergedColors.flyoutBackground} - flyoutForegroundColor={mergedColors.flyoutForeground} isOpen={openFlyout === FlyoutId.speed} onOpen={() => openFlyoutHandler(FlyoutId.speed)} onClose={() => closeFlyoutHandler(FlyoutId.speed)} diff --git a/src/components/FlowPlayer/Controls/ControlFlyout.tsx b/src/components/FlowPlayer/Controls/ControlFlyout.tsx index 48a3560d..a910bdab 100644 --- a/src/components/FlowPlayer/Controls/ControlFlyout.tsx +++ b/src/components/FlowPlayer/Controls/ControlFlyout.tsx @@ -19,8 +19,6 @@ export interface ControlFlyoutProps { options: FlyoutOptionData[]; activeKey: string | number; onSelect: (key: string | number) => void; - flyoutBackground: string; - flyoutForegroundColor: string; isOpen: boolean; onOpen: () => void; onClose: () => void; @@ -34,8 +32,6 @@ export const ControlFlyout: FC = ({ options, activeKey, onSelect, - flyoutBackground, - flyoutForegroundColor, isOpen, onOpen, onClose, @@ -56,10 +52,7 @@ export const ControlFlyout: FC = ({ > {trigger} -
      +
        {options.map((option) => { const isActive = option.key === activeKey; return ( diff --git a/src/components/FlowPlayer/Controls/Controls.consts.ts b/src/components/FlowPlayer/Controls/Controls.consts.ts index 78d2a654..60e1a5b2 100644 --- a/src/components/FlowPlayer/Controls/Controls.consts.ts +++ b/src/components/FlowPlayer/Controls/Controls.consts.ts @@ -15,8 +15,7 @@ export const defaultControlsColors: Required = { foregroundColor: '#FFFFFF', progressColor: '#00CCA9', accentColor: '#009991', - flyoutBackground: '#FFFFFF', - flyoutForeground: '#000000', + cuepointColor: '#009991', }; // Matches the rest of FlowPlayer.consts.ts: Dutch defaults, overridable by the consumer. diff --git a/src/components/FlowPlayer/Controls/ProgressBar.tsx b/src/components/FlowPlayer/Controls/ProgressBar.tsx index 8f90fead..3e729d69 100644 --- a/src/components/FlowPlayer/Controls/ProgressBar.tsx +++ b/src/components/FlowPlayer/Controls/ProgressBar.tsx @@ -19,6 +19,7 @@ export const ProgressBar: FC = ({ cuepoints, accentColor, foregroundColor, + cuepointColor, ariaLabel, }) => { const playedPct = duration > 0 ? clamp((currentTime / duration) * 100, 0, 100) : 0; @@ -89,14 +90,6 @@ export const ProgressBar: FC = ({ >
        -
        -
        {cuepointMarkers.map((cuepoint, index) => { if (cuepoint.startTime == null) { return null; @@ -111,10 +104,22 @@ export const ProgressBar: FC = ({ style={{ left: `${(start / duration) * 100}%`, width: `${((end - start) / duration) * 100}%`, + backgroundColor: `color-mix(in srgb, ${cuepointColor} 60%, transparent)`, }} /> ); })} + {/* Drawn after the cuepoint markers (later in source order = higher paint order in + this shared stacking context) so playback progress stays visible over any cuepoint + it has already passed, instead of the marker painting over it. */} +
        +
        {showTimestamps && ( diff --git a/src/components/FlowPlayer/Controls/ProgressBar.types.ts b/src/components/FlowPlayer/Controls/ProgressBar.types.ts index be90eec0..d5e62c6e 100644 --- a/src/components/FlowPlayer/Controls/ProgressBar.types.ts +++ b/src/components/FlowPlayer/Controls/ProgressBar.types.ts @@ -11,5 +11,6 @@ export interface ProgressBarProps { cuepoints?: Cuepoints; accentColor: string; foregroundColor: string; + cuepointColor: string; ariaLabel: string; } diff --git a/src/components/FlowPlayer/Controls/SpeedControl.tsx b/src/components/FlowPlayer/Controls/SpeedControl.tsx index cca42c14..63dad8a3 100644 --- a/src/components/FlowPlayer/Controls/SpeedControl.tsx +++ b/src/components/FlowPlayer/Controls/SpeedControl.tsx @@ -9,8 +9,6 @@ export interface SpeedControlProps { currentRate: number; onChange: (rate: number) => void; label: string; - flyoutBackground: string; - flyoutForegroundColor: string; isOpen: boolean; onOpen: () => void; onClose: () => void; @@ -22,8 +20,6 @@ export const SpeedControl: FC = ({ currentRate, onChange, label, - flyoutBackground, - flyoutForegroundColor, isOpen, onOpen, onClose, @@ -31,8 +27,6 @@ export const SpeedControl: FC = ({ void; offLabel: string; triggerLabel: string; - flyoutForegroundColor: string; - flyoutBackground: string; isOpen: boolean; onOpen: () => void; onClose: () => void; @@ -30,8 +28,6 @@ export const SubtitlesControl: FC = ({ onSelect, offLabel, triggerLabel, - flyoutForegroundColor, - flyoutBackground, isOpen, onOpen, onClose, @@ -45,8 +41,6 @@ export const SubtitlesControl: FC = ({ Date: Tue, 8 Sep 2026 10:28:10 +0200 Subject: [PATCH 17/22] ARC-3904: PR remarks fix translations --- .../FlowPlayer/Controls/ControlBar.tsx | 34 ++++++------ .../FlowPlayer/Controls/Controls.consts.ts | 52 ++++++++++++++----- .../FlowPlayer/Controls/FullscreenButton.tsx | 2 +- .../FlowPlayer/Controls/PlayPauseButton.tsx | 2 +- .../FlowPlayer/Controls/VolumeControl.tsx | 2 +- src/components/FlowPlayer/FlowPlayer.types.ts | 30 ++++++----- .../RichTextEditor/RichTextEditor.labels.ts | 3 +- .../RichTextEditor/RichTextEditor.stories.tsx | 2 +- .../RichTextEditor/RichTextEditor.types.tsx | 5 -- .../RichTextEditor/RichTextEditorInternal.tsx | 2 +- src/types/index.ts | 5 ++ 11 files changed, 82 insertions(+), 57 deletions(-) diff --git a/src/components/FlowPlayer/Controls/ControlBar.tsx b/src/components/FlowPlayer/Controls/ControlBar.tsx index 1720daac..06f2a74c 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.tsx +++ b/src/components/FlowPlayer/Controls/ControlBar.tsx @@ -1,11 +1,12 @@ import clsx from 'clsx'; import { type CSSProperties, type FC, useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { Locale } from "../../../types"; +import { type FlowPlayerControlsColors } from '../FlowPlayer.types'; import type { ControlBarProps } from './ControlBar.types'; -import type { FlowPlayerControlsColors, FlowPlayerControlsLabels } from '../FlowPlayer.types'; import { DEFAULT_AUTO_HIDE_DELAY_MS, defaultControlsColors, - defaultControlsLabels, + FLOW_PLAYER_CONTROLS_LABELS, isGenericPeakMode, } from './Controls.consts'; import { FullscreenButton } from './FullscreenButton'; @@ -33,12 +34,11 @@ enum FlyoutId { speed = 'speed', } -// Stable identities for unset `config`/`colors`/`labels` - a `= {}` default in a destructuring -// pattern allocates a new object every render, which would defeat the `useMemo`s below keyed on -// `colors`/`labels` whenever the caller doesn't override them (the common case). +// Stable identities for unset `config`/`colors` - a `= {}` default in a destructuring pattern +// allocates a new object every render, which would defeat the `useMemo` below keyed on `colors` +// whenever the caller doesn't override them (the common case). const EMPTY_CONFIG: NonNullable = {}; const EMPTY_COLORS: NonNullable = {}; -const EMPTY_LABELS: NonNullable = {}; export const ControlBar: FC = ({ playerRef, @@ -67,14 +67,14 @@ export const ControlBar: FC = ({ peakColorBackground, autoHideDelayMs = DEFAULT_AUTO_HIDE_DELAY_MS, colors = EMPTY_COLORS, - labels = EMPTY_LABELS, + locale = Locale.nl, } = config; // Stable per-instance id for the flyout dropdowns - avoids id collisions with multiple players on one page. const controlsId = useId(); const mergedColors = useMemo(() => ({ ...defaultControlsColors, ...colors }), [colors]); - const mergedLabels = useMemo(() => ({ ...defaultControlsLabels, ...labels }), [labels]); + const labels = FLOW_PLAYER_CONTROLS_LABELS[locale]; const resolvedShowSubtitles = showSubtitles ?? hasSubtitles; const resolvedShowSpeed = showSpeed ?? !!speed?.options?.length; @@ -218,11 +218,7 @@ export const ControlBar: FC = ({ {/* Four independently-styled pill segments, matching the design - not one continuous bar. */} {showPlayPause && (
        - +
        )} @@ -240,7 +236,7 @@ export const ControlBar: FC = ({ accentColor={mergedColors.progressColor} foregroundColor={mergedColors.foregroundColor} cuepointColor={mergedColors.cuepointColor} - ariaLabel={mergedLabels.progressBar} + ariaLabel={labels.progressBar} />
        )} @@ -248,7 +244,7 @@ export const ControlBar: FC = ({ {hasSecondarySegment && (
        {showVolume && ( - + )} {resolvedShowSubtitles && ( @@ -257,8 +253,8 @@ export const ControlBar: FC = ({ tracks={subtitleTracks} activeTrackKey={activeSubtitleTrackKey} onSelect={handleSelectSubtitleTrack} - offLabel={mergedLabels.subtitlesOff} - triggerLabel={mergedLabels.subtitles} + offLabel={labels.subtitlesOff} + triggerLabel={labels.subtitles} isOpen={openFlyout === FlyoutId.subtitles} onOpen={() => openFlyoutHandler(FlyoutId.subtitles)} onClose={() => closeFlyoutHandler(FlyoutId.subtitles)} @@ -274,7 +270,7 @@ export const ControlBar: FC = ({ }))} currentRate={state.playbackRate} onChange={actions.setPlaybackRate} - label={mergedLabels.speed} + label={labels.speed} isOpen={openFlyout === FlyoutId.speed} onOpen={() => openFlyoutHandler(FlyoutId.speed)} onClose={() => closeFlyoutHandler(FlyoutId.speed)} @@ -288,7 +284,7 @@ export const ControlBar: FC = ({
        )} diff --git a/src/components/FlowPlayer/Controls/Controls.consts.ts b/src/components/FlowPlayer/Controls/Controls.consts.ts index 60e1a5b2..497a95ec 100644 --- a/src/components/FlowPlayer/Controls/Controls.consts.ts +++ b/src/components/FlowPlayer/Controls/Controls.consts.ts @@ -1,4 +1,9 @@ -import type { FlowPlayerControlsColors, FlowPlayerControlsLabels } from '../FlowPlayer.types'; +import { Locale } from "../../../types"; +import { + type FlowPlayerControlsColors, + FlowPlayerControlsLabelKey, + type FlowPlayerControlsLabels, +} from '../FlowPlayer.types'; export const DEFAULT_AUTO_HIDE_DELAY_MS = 3000; export const DEFAULT_SHOW_PEAK = true; @@ -18,17 +23,36 @@ export const defaultControlsColors: Required = { cuepointColor: '#009991', }; -// Matches the rest of FlowPlayer.consts.ts: Dutch defaults, overridable by the consumer. -export const defaultControlsLabels: Required = { - play: 'Afspelen', - pause: 'Pauzeren', - mute: 'Dempen', - unmute: 'Dempen opheffen', - volume: 'Volume', - fullscreenEnter: 'Volledig scherm', - fullscreenExit: 'Volledig scherm sluiten', - subtitles: 'Ondertitels', - subtitlesOff: 'Uit', - speed: 'Snelheid', - progressBar: 'Voortgang', +// Base label sets per locale - the consumer's `labels` config overrides individual keys on top +// of whichever set `locale` resolves to. Defaults to nl, matching the rest of FlowPlayer.consts.ts. +export const FLOW_PLAYER_CONTROLS_LABELS: Record< + Locale, + FlowPlayerControlsLabels +> = { + [Locale.nl]: { + [FlowPlayerControlsLabelKey.Play]: 'Afspelen', + [FlowPlayerControlsLabelKey.Pause]: 'Pauzeren', + [FlowPlayerControlsLabelKey.Mute]: 'Dempen', + [FlowPlayerControlsLabelKey.Unmute]: 'Dempen opheffen', + [FlowPlayerControlsLabelKey.Volume]: 'Volume', + [FlowPlayerControlsLabelKey.FullscreenEnter]: 'Volledig scherm', + [FlowPlayerControlsLabelKey.FullscreenExit]: 'Volledig scherm sluiten', + [FlowPlayerControlsLabelKey.Subtitles]: 'Ondertitels', + [FlowPlayerControlsLabelKey.SubtitlesOff]: 'Uit', + [FlowPlayerControlsLabelKey.Speed]: 'Snelheid', + [FlowPlayerControlsLabelKey.ProgressBar]: 'Voortgang', + }, + [Locale.en]: { + [FlowPlayerControlsLabelKey.Play]: 'Play', + [FlowPlayerControlsLabelKey.Pause]: 'Pause', + [FlowPlayerControlsLabelKey.Mute]: 'Mute', + [FlowPlayerControlsLabelKey.Unmute]: 'Unmute', + [FlowPlayerControlsLabelKey.Volume]: 'Volume', + [FlowPlayerControlsLabelKey.FullscreenEnter]: 'Enter fullscreen', + [FlowPlayerControlsLabelKey.FullscreenExit]: 'Exit fullscreen', + [FlowPlayerControlsLabelKey.Subtitles]: 'Subtitles', + [FlowPlayerControlsLabelKey.SubtitlesOff]: 'Off', + [FlowPlayerControlsLabelKey.Speed]: 'Speed', + [FlowPlayerControlsLabelKey.ProgressBar]: 'Progress', + }, }; diff --git a/src/components/FlowPlayer/Controls/FullscreenButton.tsx b/src/components/FlowPlayer/Controls/FullscreenButton.tsx index ece23b30..e9c7b26b 100644 --- a/src/components/FlowPlayer/Controls/FullscreenButton.tsx +++ b/src/components/FlowPlayer/Controls/FullscreenButton.tsx @@ -6,7 +6,7 @@ import { FullscreenEnterIcon, FullscreenExitIcon } from './Controls.icons'; export interface FullscreenButtonProps { isFullscreen: boolean; onToggle: () => void; - labels: Required; + labels: FlowPlayerControlsLabels; } export const FullscreenButton: FC = ({ isFullscreen, onToggle, labels }) => ( diff --git a/src/components/FlowPlayer/Controls/PlayPauseButton.tsx b/src/components/FlowPlayer/Controls/PlayPauseButton.tsx index 95c8cabd..8285f053 100644 --- a/src/components/FlowPlayer/Controls/PlayPauseButton.tsx +++ b/src/components/FlowPlayer/Controls/PlayPauseButton.tsx @@ -6,7 +6,7 @@ import { PauseIcon, PlayIcon } from './Controls.icons'; export interface PlayPauseButtonProps { paused: boolean; onToggle: () => void; - labels: Required; + labels: FlowPlayerControlsLabels; } export const PlayPauseButton: FC = ({ paused, onToggle, labels }) => ( diff --git a/src/components/FlowPlayer/Controls/VolumeControl.tsx b/src/components/FlowPlayer/Controls/VolumeControl.tsx index cba21692..cfd9ba2e 100644 --- a/src/components/FlowPlayer/Controls/VolumeControl.tsx +++ b/src/components/FlowPlayer/Controls/VolumeControl.tsx @@ -7,7 +7,7 @@ import { MuteIcon, VolumeIcon } from './Controls.icons'; export interface VolumeControlProps { muted: boolean; onToggleMute: () => void; - labels: Required; + labels: FlowPlayerControlsLabels; } /** No flyout/slider - a plain mute/unmute toggle, backed by `muted` rather than `volume` so it diff --git a/src/components/FlowPlayer/FlowPlayer.types.ts b/src/components/FlowPlayer/FlowPlayer.types.ts index de78947d..26c17098 100644 --- a/src/components/FlowPlayer/FlowPlayer.types.ts +++ b/src/components/FlowPlayer/FlowPlayer.types.ts @@ -174,6 +174,9 @@ export interface FlowPlayerCustomControlsConfig { autoHideDelayMs?: number; // default 3000, 0 disables auto-hide + // Label set to use. Defaults to 'nl'. + locale?: 'nl' | 'en'; + // Native mode only ever shows the title/logo overlay in fullscreen (see FlowPlayer.scss) - a // normal embedded player essentially never displays it. Custom mode keeps that same // conservative default (false); opt in for a demo/player-page context where showing it, @@ -181,7 +184,6 @@ export interface FlowPlayerCustomControlsConfig { showTitleOverlay?: boolean; // default false colors?: FlowPlayerControlsColors; - labels?: FlowPlayerControlsLabels; } export interface FlowPlayerControlsColors { @@ -192,16 +194,18 @@ export interface FlowPlayerControlsColors { cuepointColor?: string; // progress track cuepoint marker background } -export interface FlowPlayerControlsLabels { - play?: string; - pause?: string; - mute?: string; - unmute?: string; - volume?: string; - fullscreenEnter?: string; - fullscreenExit?: string; - subtitles?: string; - subtitlesOff?: string; - speed?: string; - progressBar?: string; +export enum FlowPlayerControlsLabelKey { + Play = 'play', + Pause = 'pause', + Mute = 'mute', + Unmute = 'unmute', + Volume = 'volume', + FullscreenEnter = 'fullscreenEnter', + FullscreenExit = 'fullscreenExit', + Subtitles = 'subtitles', + SubtitlesOff = 'subtitlesOff', + Speed = 'speed', + ProgressBar = 'progressBar', } + +export type FlowPlayerControlsLabels = Record; diff --git a/src/components/RichTextEditor/RichTextEditor.labels.ts b/src/components/RichTextEditor/RichTextEditor.labels.ts index fc9e5a56..b877104c 100644 --- a/src/components/RichTextEditor/RichTextEditor.labels.ts +++ b/src/components/RichTextEditor/RichTextEditor.labels.ts @@ -1,4 +1,5 @@ -import { type Heading, Locale } from './RichTextEditor.types'; +import { Locale } from "../../types"; +import { type Heading } from './RichTextEditor.types'; export enum LabelKey { Bold = 'bold', diff --git a/src/components/RichTextEditor/RichTextEditor.stories.tsx b/src/components/RichTextEditor/RichTextEditor.stories.tsx index eaa0b9d0..2f34bb49 100644 --- a/src/components/RichTextEditor/RichTextEditor.stories.tsx +++ b/src/components/RichTextEditor/RichTextEditor.stories.tsx @@ -1,11 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { cloneElement, type ReactElement, useState } from 'react'; import { action } from 'storybook/actions'; +import { Locale } from "../../types"; import { selectOptionsMock } from '../Select/__mocks__/select'; import Select from '../Select/Select'; import { RichTextEditor } from './RichTextEditor'; -import { Locale } from './RichTextEditor.types'; import type { RichTextEditorControl } from './RichTextEditor.types'; const RICH_TEXT_EDITOR_OPTIONS: RichTextEditorControl[] = [ diff --git a/src/components/RichTextEditor/RichTextEditor.types.tsx b/src/components/RichTextEditor/RichTextEditor.types.tsx index 43c0c26a..e9e9c03b 100644 --- a/src/components/RichTextEditor/RichTextEditor.types.tsx +++ b/src/components/RichTextEditor/RichTextEditor.types.tsx @@ -5,11 +5,6 @@ export interface CustomRichTextEditorButton { component: ReactNode; } -export enum Locale { - nl = 'nl', - en = 'en', -} - export type RichTextEditorControl = | 'font-size' // Text size selector | 'font-family' // Text font selector diff --git a/src/components/RichTextEditor/RichTextEditorInternal.tsx b/src/components/RichTextEditor/RichTextEditorInternal.tsx index e1cd93c3..f3bf7ad8 100644 --- a/src/components/RichTextEditor/RichTextEditorInternal.tsx +++ b/src/components/RichTextEditor/RichTextEditorInternal.tsx @@ -13,6 +13,7 @@ import StarterKit from '@tiptap/starter-kit'; import clsx from 'clsx'; import type { ChangeEvent, FunctionComponent, ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { Locale } from "../../types"; import { RichTextEditorHeadingsDropdown } from './components/RichTextEditorHeadingsDropdown/RichTextEditorHeadingsDropdown'; import { RichTextEditorLinkDropdown } from './components/RichTextEditorLinkDropdown/RichTextEditorLinkDropdown'; import { RichTextEditorTableDropdown } from './components/RichTextEditorTableDropdown/RichTextEditorTableDropdown'; @@ -42,7 +43,6 @@ import { LabelKey, RICH_TEXT_EDITOR_LABELS } from './RichTextEditor.labels'; import { ALL_RICH_TEXT_HEADINGS, type Heading, - Locale, type RichTextEditorControl, type RichTextEditorMedia, type RichTextEditorUploadInfo, diff --git a/src/types/index.ts b/src/types/index.ts index c8c62006..0f3875cc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,11 @@ export interface DefaultComponentProps { variants?: VariantsProp; } +export enum Locale { + nl = 'nl', + en = 'en', +} + export type VariantsProp = string | string[]; export type RefTypes = MutableRefObject | RefCallback | null; From 6a44ea7040d62b62e85a178ac09c8db3e1e4eeac Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Tue, 8 Sep 2026 10:49:39 +0200 Subject: [PATCH 18/22] ARC-3904: Export AudioWaveFormDisplaySize --- src/components/AudioWaveFormDisplay/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AudioWaveFormDisplay/index.ts b/src/components/AudioWaveFormDisplay/index.ts index 3926e2a6..3b6fe42d 100644 --- a/src/components/AudioWaveFormDisplay/index.ts +++ b/src/components/AudioWaveFormDisplay/index.ts @@ -1,3 +1,3 @@ export { AudioWaveFormDisplay } from './AudioWaveFormDisplay'; -export { WAVE_FORM_PADDING_X_PERCENT } from './AudioWaveFormDisplay.helpers'; +export { WAVE_FORM_PADDING_X_PERCENT, AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; export * from './AudioWaveFormDisplay.types'; From 4edd92041eb7529bfe215d1ababdbac20559ca93 Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Tue, 8 Sep 2026 11:02:53 +0200 Subject: [PATCH 19/22] ARC-3904: Ran biome --- package.json | 2 +- .../AudioWaveFormDisplay.helpers.ts | 13 +- .../AudioWaveFormDisplay.stories.tsx | 22 +- .../AudioWaveFormDisplay.tsx | 96 +- .../AudioWaveFormDisplay.types.tsx | 2 +- src/components/AudioWaveFormDisplay/index.ts | 5 +- .../FlowPlayer/Controls/ControlBar.scss | 3 +- .../FlowPlayer/Controls/ControlBar.tsx | 20 +- .../FlowPlayer/Controls/ControlBar.types.ts | 6 +- .../FlowPlayer/Controls/ControlFlyout.tsx | 4 +- .../FlowPlayer/Controls/Controls.consts.ts | 12 +- .../FlowPlayer/Controls/Controls.icons.tsx | 33 +- .../FlowPlayer/Controls/PeakDisplay.tsx | 14 +- .../Controls/VolumeControl.test.tsx | 7 +- .../Controls/subtitles-track.helpers.test.ts | 20 +- .../Controls/subtitles-track.helpers.ts | 13 +- .../Controls/useAutoHideControls.ts | 7 +- .../FlowPlayer/Controls/useFlowplayerState.ts | 10 +- .../Controls/useKeyboardShortcuts.ts | 8 +- .../FlowPlayer/FlowPlayer.internal.tsx | 4 +- src/components/FlowPlayer/FlowPlayer.scss | 4 +- .../Peak/__mock__/peak-low-amplitude.json | 12610 +--------------- .../RichTextEditor/RichTextEditor.labels.ts | 4 +- .../RichTextEditor/RichTextEditor.stories.tsx | 2 +- .../RichTextEditor/RichTextEditorInternal.tsx | 2 +- .../RichTextEditorLinkDropdown.tsx | 4 +- .../TimeCropControls/TimeCropControls.tsx | 4 +- src/utils/formatters/duration.test.ts | 4 +- src/utils/formatters/duration.ts | 7 +- tsconfig.json | 51 +- vite.config.mts | 12 +- 31 files changed, 831 insertions(+), 12174 deletions(-) diff --git a/package.json b/package.json index bcf1b1f0..0e44421d 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "lint": "npm run lint:ts && npm run lint:scss", "lint:ts": "biome lint --write src", "lint:scss": "stylelint 'src/**/*.{css,scss}' --allow-empty-input --fix", - "format": "biome format --write src", + "format": "biome check --write", "dev": "storybook dev -p 3020", "test": "jest --cache", "test:watch": "npm run test -- --watch", diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts index 3381c53e..5a410eae 100644 --- a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.helpers.ts @@ -39,9 +39,13 @@ const WAVE_FORM_BAR_COUNT = WAVE_FORM_BAR_HALF_HEIGHTS.length; // Right margin the reference asset leaves after its last bar, reused to size the large viewBox. const WAVE_FORM_RIGHT_MARGIN = - WAVE_FORM_VIEW_BOX_WIDTH - (WAVE_FORM_FIRST_BAR_X + (WAVE_FORM_BAR_COUNT - 1) * WAVE_FORM_BAR_SPACING); + WAVE_FORM_VIEW_BOX_WIDTH - + (WAVE_FORM_FIRST_BAR_X + (WAVE_FORM_BAR_COUNT - 1) * WAVE_FORM_BAR_SPACING); -function buildWaveFormBars(barCount: number, halfHeightAt: (index: number) => number): WaveFormBar[] { +function buildWaveFormBars( + barCount: number, + halfHeightAt: (index: number) => number +): WaveFormBar[] { return Array.from({ length: barCount }, (_, index) => { const x = WAVE_FORM_FIRST_BAR_X + index * WAVE_FORM_BAR_SPACING; const halfHeight = halfHeightAt(index); @@ -61,7 +65,10 @@ const SMALL_WAVE_FORM_BARS: readonly WaveFormBar[] = buildWaveFormBars( // Large: the small waveform immediately followed by its own mirror, on one continuous grid. const LARGE_WAVE_FORM_BARS: readonly WaveFormBar[] = buildWaveFormBars( WAVE_FORM_BAR_COUNT * 2, - (index) => WAVE_FORM_BAR_HALF_HEIGHTS[index < WAVE_FORM_BAR_COUNT ? index : WAVE_FORM_BAR_COUNT * 2 - 1 - index] + (index) => + WAVE_FORM_BAR_HALF_HEIGHTS[ + index < WAVE_FORM_BAR_COUNT ? index : WAVE_FORM_BAR_COUNT * 2 - 1 - index + ] ); export function getWaveFormBars(size: AudioWaveFormDisplaySize): readonly WaveFormBar[] { diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx index 66735be8..18ee8c3b 100644 --- a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.stories.tsx @@ -12,12 +12,28 @@ type Story = StoryObj; export const Default: Story = { render: () => ( -
        +
        - +
        - +
        ), diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx index e8f4d1d1..b39a1c8c 100644 --- a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx @@ -13,53 +13,55 @@ import './AudioWaveFormDisplay.scss'; // Memoized so PeakDisplay's static "inactive" waveform layer (unchanging colors/size) skips // reconciling its ~30-60 elements on every playback timeupdate tick, which only changes // the sibling "active" layer's clip-path, not either layer's own props. -export const AudioWaveFormDisplay: FC = memo(function AudioWaveFormDisplay({ - className, - rootClassName: root = 'c-audio-wave-form-display', - ariaLabel, - waveColor, - backgroundColor, - size = AudioWaveFormDisplaySize.Small, -}) { - const bars = getWaveFormBars(size); - const viewBox = getWaveFormViewBox(size); +export const AudioWaveFormDisplay: FC = memo( + function AudioWaveFormDisplay({ + className, + rootClassName: root = 'c-audio-wave-form-display', + ariaLabel, + waveColor, + backgroundColor, + size = AudioWaveFormDisplaySize.Small, + }) { + const bars = getWaveFormBars(size); + const viewBox = getWaveFormViewBox(size); - return ( -
        - {/* Plain box for consumers to hook a hover-zoom transform onto: transitioning `transform` + return ( +
        + {/* Plain box for consumers to hook a hover-zoom transform onto: transitioning `transform` on an itself doesn't animate smoothly in every browser, unlike an ordinary element. */} -
        - +
        + +
        -
        - ); -}); + ); + } +); diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx index 5745651b..2d771a91 100644 --- a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx @@ -1,5 +1,5 @@ import type { DefaultComponentProps } from '../../types'; -import { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; +import type { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; export type AudioWaveFormDisplayProps = DefaultComponentProps & { waveColor?: string; diff --git a/src/components/AudioWaveFormDisplay/index.ts b/src/components/AudioWaveFormDisplay/index.ts index 3b6fe42d..3717d284 100644 --- a/src/components/AudioWaveFormDisplay/index.ts +++ b/src/components/AudioWaveFormDisplay/index.ts @@ -1,3 +1,6 @@ export { AudioWaveFormDisplay } from './AudioWaveFormDisplay'; -export { WAVE_FORM_PADDING_X_PERCENT, AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; +export { + AudioWaveFormDisplaySize, + WAVE_FORM_PADDING_X_PERCENT, +} from './AudioWaveFormDisplay.helpers'; export * from './AudioWaveFormDisplay.types'; diff --git a/src/components/FlowPlayer/Controls/ControlBar.scss b/src/components/FlowPlayer/Controls/ControlBar.scss index 908101dd..396d90bb 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.scss +++ b/src/components/FlowPlayer/Controls/ControlBar.scss @@ -40,7 +40,7 @@ $flowplayer-flyout-shadow: 0 0 $g-spacer-unit rgb(0 0 0 / 24%); // rather than the checkmark. One divider line between every option, not just at fixed points. $flowplayer-flyout-option-padding-block: $g-spacer-unit * 2; $flowplayer-flyout-option-padding-inline-start: $g-spacer-unit; -$flowplayer-flyout-option-padding-inline-end: $g-spacer-unit*2; +$flowplayer-flyout-option-padding-inline-end: $g-spacer-unit * 2; $flowplayer-flyout-option-row-gap: $g-spacer-unit; $flowplayer-flyout-option-sublabel-gap: math.div($g-spacer-unit, 2); $flowplayer-flyout-option-icon-size: 2rem; @@ -376,6 +376,7 @@ $flowplayer-flyout-option-sublabel-color: #666; // "leisteen" // Sibling of the control bar, absolutely filling the player area like native's own `.c-peak` canvas. .c-flowplayer-peak-image { @include flowplayer-fill-absolute; + z-index: 1; // No filter/dimming - PeakDisplay renders two real AudioWaveFormDisplay instances (one per diff --git a/src/components/FlowPlayer/Controls/ControlBar.tsx b/src/components/FlowPlayer/Controls/ControlBar.tsx index 06f2a74c..b1d82e7e 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.tsx +++ b/src/components/FlowPlayer/Controls/ControlBar.tsx @@ -1,7 +1,15 @@ import clsx from 'clsx'; -import { type CSSProperties, type FC, useCallback, useEffect, useId, useMemo, useState } from 'react'; -import { Locale } from "../../../types"; -import { type FlowPlayerControlsColors } from '../FlowPlayer.types'; +import { + type CSSProperties, + type FC, + useCallback, + useEffect, + useId, + useMemo, + useState, +} from 'react'; +import { Locale } from '../../../types'; +import type { FlowPlayerControlsColors } from '../FlowPlayer.types'; import type { ControlBarProps } from './ControlBar.types'; import { DEFAULT_AUTO_HIDE_DELAY_MS, @@ -244,7 +252,11 @@ export const ControlBar: FC = ({ {hasSecondarySegment && (
        {showVolume && ( - + )} {resolvedShowSubtitles && ( diff --git a/src/components/FlowPlayer/Controls/ControlBar.types.ts b/src/components/FlowPlayer/Controls/ControlBar.types.ts index 576d07e4..be2ca570 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.types.ts +++ b/src/components/FlowPlayer/Controls/ControlBar.types.ts @@ -1,6 +1,10 @@ import type { Player } from '@flowplayer/player'; import type { MutableRefObject } from 'react'; -import type { Cuepoints, FlowPlayerCustomControlsConfig, FlowplayerTrackSchema } from '../FlowPlayer.types'; +import type { + Cuepoints, + FlowPlayerCustomControlsConfig, + FlowplayerTrackSchema, +} from '../FlowPlayer.types'; export interface ControlBarProps { playerRef: MutableRefObject; diff --git a/src/components/FlowPlayer/Controls/ControlFlyout.tsx b/src/components/FlowPlayer/Controls/ControlFlyout.tsx index a910bdab..04817a0a 100644 --- a/src/components/FlowPlayer/Controls/ControlFlyout.tsx +++ b/src/components/FlowPlayer/Controls/ControlFlyout.tsx @@ -64,9 +64,7 @@ export const ControlFlyout: FC = ({ onClick={() => onSelect(option.key)} > - - {isActive && } - + {isActive && } {option.label} {option.icon && ( {option.icon} diff --git a/src/components/FlowPlayer/Controls/Controls.consts.ts b/src/components/FlowPlayer/Controls/Controls.consts.ts index 497a95ec..6e22f4bb 100644 --- a/src/components/FlowPlayer/Controls/Controls.consts.ts +++ b/src/components/FlowPlayer/Controls/Controls.consts.ts @@ -1,4 +1,4 @@ -import { Locale } from "../../../types"; +import { Locale } from '../../../types'; import { type FlowPlayerControlsColors, FlowPlayerControlsLabelKey, @@ -10,7 +10,10 @@ export const DEFAULT_SHOW_PEAK = true; export const DEFAULT_PEAK_MODE = 'data' as const; /** Whether the generic built-in waveform (`PeakDisplay`) should render - shared by ControlBar.tsx and FlowPlayer.internal.tsx so they can't drift. */ -export function isGenericPeakMode(showPeak: boolean | undefined, peakMode: 'data' | 'generic' | undefined): boolean { +export function isGenericPeakMode( + showPeak: boolean | undefined, + peakMode: 'data' | 'generic' | undefined +): boolean { return (showPeak ?? DEFAULT_SHOW_PEAK) && (peakMode ?? DEFAULT_PEAK_MODE) === 'generic'; } @@ -25,10 +28,7 @@ export const defaultControlsColors: Required = { // Base label sets per locale - the consumer's `labels` config overrides individual keys on top // of whichever set `locale` resolves to. Defaults to nl, matching the rest of FlowPlayer.consts.ts. -export const FLOW_PLAYER_CONTROLS_LABELS: Record< - Locale, - FlowPlayerControlsLabels -> = { +export const FLOW_PLAYER_CONTROLS_LABELS: Record = { [Locale.nl]: { [FlowPlayerControlsLabelKey.Play]: 'Afspelen', [FlowPlayerControlsLabelKey.Pause]: 'Pauzeren', diff --git a/src/components/FlowPlayer/Controls/Controls.icons.tsx b/src/components/FlowPlayer/Controls/Controls.icons.tsx index a1089aaf..394a9fa2 100644 --- a/src/components/FlowPlayer/Controls/Controls.icons.tsx +++ b/src/components/FlowPlayer/Controls/Controls.icons.tsx @@ -9,13 +9,25 @@ const baseProps: SVGProps = { // glyph) - no forced uniform grid, flex centering in `.c-flowplayer-control-button` handles it. export const PlayIcon: FC = () => ( -
        - +
        - +
        ); diff --git a/src/components/FlowPlayer/Controls/VolumeControl.test.tsx b/src/components/FlowPlayer/Controls/VolumeControl.test.tsx index b044328c..47ee8256 100644 --- a/src/components/FlowPlayer/Controls/VolumeControl.test.tsx +++ b/src/components/FlowPlayer/Controls/VolumeControl.test.tsx @@ -16,9 +16,12 @@ const defaultLabels = { }; const renderVolumeControl = (overrides: Partial = {}) => - render(); + render( + + ); -const getButton = (container: HTMLElement) => container.querySelector('button') as HTMLButtonElement; +const getButton = (container: HTMLElement) => + container.querySelector('button') as HTMLButtonElement; describe('', () => { it('does not mark the button active when unmuted', () => { diff --git a/src/components/FlowPlayer/Controls/subtitles-track.helpers.test.ts b/src/components/FlowPlayer/Controls/subtitles-track.helpers.test.ts index 0586affa..94b7305f 100644 --- a/src/components/FlowPlayer/Controls/subtitles-track.helpers.test.ts +++ b/src/components/FlowPlayer/Controls/subtitles-track.helpers.test.ts @@ -1,7 +1,15 @@ import type { Player } from '@flowplayer/player'; -import { getActiveSubtitleTrackKey, getSubtitleTrackKey, selectSubtitleTrack } from './subtitles-track.helpers'; - -type FakeTextTrack = TextTrack & { is_active?: boolean; is_hls_embedded?: boolean; track_id?: number }; +import { + getActiveSubtitleTrackKey, + getSubtitleTrackKey, + selectSubtitleTrack, +} from './subtitles-track.helpers'; + +type FakeTextTrack = TextTrack & { + is_active?: boolean; + is_hls_embedded?: boolean; + track_id?: number; +}; // jsdom doesn't implement HTMLMediaElement.addTextTrack, so `player.textTracks` is faked directly // with plain objects - Array.from (used throughout subtitles-track.helpers.ts) works the same on @@ -52,14 +60,16 @@ describe('selectSubtitleTrack', () => { expect(player.emit).toHaveBeenCalledWith('cuechange', { track }); }); - it('also forwards the track\'s own native "cuechange" once, to catch the case where the immediate emit above raced the browser (cues/activeCues aren\'t available in the same tick right after a track\'s first activation - confirmed live: empty immediately after the mode flip, populated only after the browser parses/links the cues)', () => { + it("also forwards the track's own native \"cuechange\" once, to catch the case where the immediate emit above raced the browser (cues/activeCues aren't available in the same tick right after a track's first activation - confirmed live: empty immediately after the mode flip, populated only after the browser parses/links the cues)", () => { const track = buildTrack({ kind: 'subtitles', label: 'Nederlands', language: 'nl' }); const { player } = buildPlayer([track]); const key = getSubtitleTrackKey([track], track); selectSubtitleTrack(player, key); - expect(track.addEventListener).toHaveBeenCalledWith('cuechange', expect.any(Function), { once: true }); + expect(track.addEventListener).toHaveBeenCalledWith('cuechange', expect.any(Function), { + once: true, + }); (player.emit as jest.Mock).mockClear(); const [, nativeHandler] = (track.addEventListener as jest.Mock).mock.calls[0]; diff --git a/src/components/FlowPlayer/Controls/subtitles-track.helpers.ts b/src/components/FlowPlayer/Controls/subtitles-track.helpers.ts index dd6761a6..3085e5a8 100644 --- a/src/components/FlowPlayer/Controls/subtitles-track.helpers.ts +++ b/src/components/FlowPlayer/Controls/subtitles-track.helpers.ts @@ -32,9 +32,14 @@ export function isSubtitlesEnabled(player: Player): boolean { } /** A stable id for a track. `language`/`label` alone can collide (e.g. two "en" tracks), so ties break by position. */ -export function getSubtitleTrackKey(tracks: FlowplayerTextTrack[], track: FlowplayerTextTrack): string { +export function getSubtitleTrackKey( + tracks: FlowplayerTextTrack[], + track: FlowplayerTextTrack +): string { const base = track.language || track.label || 'track'; - const sameBase = tracks.filter((candidate) => (candidate.language || candidate.label || 'track') === base); + const sameBase = tracks.filter( + (candidate) => (candidate.language || candidate.label || 'track') === base + ); if (sameBase.length <= 1) { return base; } @@ -72,7 +77,9 @@ function emitTracksUpdated(player: Player, track?: FlowplayerTextTrack) { * final state until some unrelated later cue change happens to fire. */ function emitCueChange(player: Player, track: FlowplayerTextTrack) { - const emit = (player as unknown as { emit: (event: string, payload?: unknown) => void }).emit.bind(player); + const emit = ( + player as unknown as { emit: (event: string, payload?: unknown) => void } + ).emit.bind(player); emit('cuechange', { track }); track.addEventListener( 'cuechange', diff --git a/src/components/FlowPlayer/Controls/useAutoHideControls.ts b/src/components/FlowPlayer/Controls/useAutoHideControls.ts index 3c82a04c..ab5a12d3 100644 --- a/src/components/FlowPlayer/Controls/useAutoHideControls.ts +++ b/src/components/FlowPlayer/Controls/useAutoHideControls.ts @@ -13,7 +13,12 @@ export interface UseAutoHideControlsOptions { * visible while paused or `suppress` is true. Handles `touchstart` itself since native's own * tap-to-reveal lives in the UI bundle we're hiding. */ -export function useAutoHideControls({ containerRef, delayMs, isPlaying, suppress }: UseAutoHideControlsOptions) { +export function useAutoHideControls({ + containerRef, + delayMs, + isPlaying, + suppress, +}: UseAutoHideControlsOptions) { const [isVisible, setIsVisible] = useState(true); const timeoutRef = useRef(null); diff --git a/src/components/FlowPlayer/Controls/useFlowplayerState.ts b/src/components/FlowPlayer/Controls/useFlowplayerState.ts index 29fbd874..0ec1c1eb 100644 --- a/src/components/FlowPlayer/Controls/useFlowplayerState.ts +++ b/src/components/FlowPlayer/Controls/useFlowplayerState.ts @@ -108,7 +108,8 @@ export function useFlowplayerState( }; const handlePlayPause = () => setState((prev) => ({ ...prev, paused: player.paused })); - const handleDurationChange = () => setState((prev) => ({ ...prev, duration: player.duration || 0 })); + const handleDurationChange = () => + setState((prev) => ({ ...prev, duration: player.duration || 0 })); const handleRateChange = () => setState((prev) => ({ ...prev, playbackRate: player.playbackRate || 1 })); const handleFullscreenEnter = () => setState((prev) => ({ ...prev, isFullscreen: true })); @@ -178,9 +179,10 @@ export function useFlowplayerState( } const opts = player.opts as { keyboard?: { seek_step?: number | string } }; const configuredStep = Number(opts?.keyboard?.seek_step); - const step = Number.isFinite(configuredStep) && configuredStep > 0 - ? configuredStep - : DEFAULT_NATIVE_SEEK_STEP_SECONDS; + const step = + Number.isFinite(configuredStep) && configuredStep > 0 + ? configuredStep + : DEFAULT_NATIVE_SEEK_STEP_SECONDS; player.enqueueSeek(direction * step); }, [playerRef] diff --git a/src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts b/src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts index 408df4a1..fd465329 100644 --- a/src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts +++ b/src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts @@ -19,7 +19,13 @@ export interface UseKeyboardShortcutsOptions { */ export function useKeyboardShortcuts({ actions }: UseKeyboardShortcutsOptions) { return (event: KeyboardEvent) => { - if (event.defaultPrevented || event.altKey || event.shiftKey || event.metaKey || event.ctrlKey) { + if ( + event.defaultPrevented || + event.altKey || + event.shiftKey || + event.metaKey || + event.ctrlKey + ) { return; } diff --git a/src/components/FlowPlayer/FlowPlayer.internal.tsx b/src/components/FlowPlayer/FlowPlayer.internal.tsx index 6b9ccaff..beffef77 100644 --- a/src/components/FlowPlayer/FlowPlayer.internal.tsx +++ b/src/components/FlowPlayer/FlowPlayer.internal.tsx @@ -694,7 +694,9 @@ const FlowPlayerInternal: FunctionComponent = ({ > {/* The generic-peak overlay (ControlBar's PeakDisplay) replaces this canvas entirely, rather than sitting on top of it - not rendered at all in that mode. */} - {!useGenericPeak && } + {!useGenericPeak && ( + + )} {customControls} {isCustomControls && ( = ({ [skipHourFormatting, minTime, maxTime] ); - const [fragmentStartString, setFragmentStartString] = useState(formatFieldValue(startTime)); + const [fragmentStartString, setFragmentStartString] = useState( + formatFieldValue(startTime) + ); const [fragmentEndString, setFragmentEndString] = useState(formatFieldValue(endTime)); useEffect(() => { diff --git a/src/utils/formatters/duration.test.ts b/src/utils/formatters/duration.test.ts index e64624b8..b8f73f95 100644 --- a/src/utils/formatters/duration.test.ts +++ b/src/utils/formatters/duration.test.ts @@ -117,7 +117,9 @@ describe('formatDuration', () => { it('leaves the leading unit unpadded when disabled, but still pads the trailing units', () => { expect(formatDuration(65, { includeHours: 'never', padLeadingUnit: false })).toEqual('1:05'); - expect(formatDuration(65, { includeHours: 'always', padLeadingUnit: false })).toEqual('0:01:05'); + expect(formatDuration(65, { includeHours: 'always', padLeadingUnit: false })).toEqual( + '0:01:05' + ); }); }); }); diff --git a/src/utils/formatters/duration.ts b/src/utils/formatters/duration.ts index 657f82b8..5353bd76 100644 --- a/src/utils/formatters/duration.ts +++ b/src/utils/formatters/duration.ts @@ -25,12 +25,15 @@ export function formatDuration( numSeconds: number | null | undefined, { includeHours = 'always', padLeadingUnit = true }: FormatDurationOptions = {} ): string { - const safeSeconds = Number.isFinite(numSeconds) && (numSeconds as number) > 0 ? (numSeconds as number) : 0; + const safeSeconds = + Number.isFinite(numSeconds) && (numSeconds as number) > 0 ? (numSeconds as number) : 0; const totalSeconds = Math.floor(safeSeconds); const showHours = includeHours === 'always' || (includeHours === 'auto' && totalSeconds >= 3600); const hours = Math.floor(totalSeconds / 3600); - const minutes = showHours ? Math.floor((totalSeconds % 3600) / 60) : Math.floor(totalSeconds / 60); + const minutes = showHours + ? Math.floor((totalSeconds % 3600) / 60) + : Math.floor(totalSeconds / 60); const secs = totalSeconds % 60; const pad = (n: number) => String(n).padStart(2, '0'); diff --git a/tsconfig.json b/tsconfig.json index 049e6776..6a5f283c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,24 @@ -{ - "compilerOptions": { - "target": "ESNext", - "lib": ["dom", "dom.iterable", "esnext"], - "types": ["node", "vite/client", "jest", "@testing-library/jest-dom"], - "module": "ESNext", - "moduleResolution": "bundler", - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "noUnusedParameters": true, - "noUnusedLocals": true, - "declaration": true, - "jsx": "react-jsx", - "noEmit": true, - "outDir": "dist", - "allowJs": true, - "isolatedModules": true - }, - "include": [ - "src", - "node_modules/storybook/dist/manager/runtime.js" - ] -} +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "types": ["node", "vite/client", "jest", "@testing-library/jest-dom"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "declaration": true, + "jsx": "react-jsx", + "noEmit": true, + "outDir": "dist", + "allowJs": true, + "isolatedModules": true + }, + "include": ["src", "node_modules/storybook/dist/manager/runtime.js"] +} diff --git a/vite.config.mts b/vite.config.mts index a1f72d26..afac1637 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,7 +1,7 @@ -import {resolve} from 'node:path'; +import { resolve } from 'node:path'; import react from '@vitejs/plugin-react'; -import {defineConfig} from 'vite'; +import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; import svgrPlugin from 'vite-plugin-svgr'; import pkg from './package.json'; @@ -44,11 +44,7 @@ export default defineConfig({ sourcemap: true, }, resolve: { - dedupe: external + dedupe: external, }, - plugins: [ - react(), - svgrPlugin(), - dts(), - ], + plugins: [react(), svgrPlugin(), dts()], }); From a6175b7d1e977f52dee1bc0186a854ac1cf44996 Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Tue, 8 Sep 2026 12:12:11 +0200 Subject: [PATCH 20/22] ARC-3904: PR remarks --- .../AudioWaveFormDisplay.test.tsx | 51 +++++++++++++++++++ .../FlowPlayer/Controls/ControlBar.tsx | 2 +- .../FlowPlayer/Controls/SpeedControl.tsx | 51 ++++++++++--------- 3 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.test.tsx diff --git a/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.test.tsx b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.test.tsx new file mode 100644 index 00000000..0bf5e580 --- /dev/null +++ b/src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, render } from '@testing-library/react'; + +import { AudioWaveFormDisplay } from './AudioWaveFormDisplay'; +import { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers'; + +afterEach(() => { + cleanup(); +}); + +describe('', () => { + it('should be able to render', () => { + render(); + }); + + it('should render the large size with twice as many bars as the small size', () => { + const { container: small } = render( + + ); + const { container: large } = render( + + ); + + const smallBarCount = small.querySelectorAll('.c-audio-wave-form-display__bar').length; + const largeBarCount = large.querySelectorAll('.c-audio-wave-form-display__bar').length; + + expect(largeBarCount).toBe(smallBarCount * 2); + }); + + it('should leave the wave color CSS variable unset when none is given, falling back to the SCSS default', () => { + const { container } = render(); + const outer = container.querySelector('.c-audio-wave-form-display') as HTMLElement; + + expect(outer.style.getPropertyValue('--c-audio-wave-form-display-wave-color')).toBe(''); + }); + + it('should pass the given wave and background colors through as CSS variables', () => { + const { container } = render( + + ); + const outer = container.querySelector('.c-audio-wave-form-display') as HTMLElement; + + expect(outer.style.getPropertyValue('--c-audio-wave-form-display-wave-color')).toBe('#00c8aa'); + expect(outer.style.getPropertyValue('--c-audio-wave-form-display-bg')).toBe('#1d1d1d'); + }); + + it('should pass the given className through', () => { + const { container } = render(); + + expect(container.querySelector('.c-audio-wave-form-display.my-extra-class')).not.toBeNull(); + }); +}); diff --git a/src/components/FlowPlayer/Controls/ControlBar.tsx b/src/components/FlowPlayer/Controls/ControlBar.tsx index b1d82e7e..e463b0f5 100644 --- a/src/components/FlowPlayer/Controls/ControlBar.tsx +++ b/src/components/FlowPlayer/Controls/ControlBar.tsx @@ -114,7 +114,7 @@ export const ControlBar: FC = ({ // doesn't propagate it onto the runtime TextTrack (`track.language` reads back // empty even when the config's `lang` was set), so label is the only field that // actually round-trips. - const trackConfig = subtitles?.find((config) => config.label === track.label); + const trackConfig = subtitles?.find((trackSchema) => trackSchema.label === track.label); return { key: getSubtitleTrackKey(tracks, track), label: track.label || track.language || '', diff --git a/src/components/FlowPlayer/Controls/SpeedControl.tsx b/src/components/FlowPlayer/Controls/SpeedControl.tsx index 63dad8a3..48096804 100644 --- a/src/components/FlowPlayer/Controls/SpeedControl.tsx +++ b/src/components/FlowPlayer/Controls/SpeedControl.tsx @@ -23,26 +23,31 @@ export const SpeedControl: FC = ({ isOpen, onOpen, onClose, -}) => ( - onChange(key as number)} - trigger={ -
        )} diff --git a/src/components/FlowPlayer/Controls/ControlFlyout.tsx b/src/components/FlowPlayer/Controls/ControlFlyout.tsx index 04817a0a..814bb024 100644 --- a/src/components/FlowPlayer/Controls/ControlFlyout.tsx +++ b/src/components/FlowPlayer/Controls/ControlFlyout.tsx @@ -1,4 +1,5 @@ -import type { FC, ReactNode } from 'react'; +import { cloneElement, isValidElement } from 'react'; +import type { FC, ReactElement, ReactNode } from 'react'; import Dropdown from '../../Dropdown/Dropdown'; import { DropdownButton, DropdownContent } from '../../Dropdown/Dropdown.slots'; import { CheckIcon } from './Controls.icons'; @@ -38,6 +39,16 @@ export const ControlFlyout: FC = ({ }) => { const optionClassName = `${flyoutClassName}__option`; + // Injects the disclosure-widget ARIA wiring onto the caller-supplied trigger without requiring + // callers to know about the popup they're attached to. + const triggerWithAria = isValidElement(trigger) + ? cloneElement(trigger as ReactElement>, { + 'aria-haspopup': 'menu', + 'aria-expanded': isOpen, + 'aria-controls': id, + }) + : trigger; + return ( = ({ shiftPadding={8} maxHeightPadding={8} > - {trigger} + {triggerWithAria} -
          +
          {options.map((option) => { const isActive = option.key === activeKey; return ( -
        • +
          -
        • +
          ); })} -
        +
        ); diff --git a/src/components/FlowPlayer/Controls/Controls.consts.ts b/src/components/FlowPlayer/Controls/Controls.consts.ts index 6e22f4bb..26a13a88 100644 --- a/src/components/FlowPlayer/Controls/Controls.consts.ts +++ b/src/components/FlowPlayer/Controls/Controls.consts.ts @@ -41,6 +41,7 @@ export const FLOW_PLAYER_CONTROLS_LABELS: Record = ({ foregroundColor, cuepointColor, ariaLabel, + cuepointLabel, }) => { - const playedPct = duration > 0 ? clamp((currentTime / duration) * 100, 0, 100) : 0; - const bufferedPct = duration > 0 ? clamp((bufferedEnd / duration) * 100, 0, 100) : 0; + const isSeekable = duration > 0; + const playedPct = isSeekable ? clamp((currentTime / duration) * 100, 0, 100) : 0; + const bufferedPct = isSeekable ? clamp((bufferedEnd / duration) * 100, 0, 100) : 0; + + // The value announced to assistive tech: kept in sync with `currentTime` while unfocused, but + // frozen while focused so a screen reader doesn't re-announce it on every playback tick - only + // resynced on blur or on a seek this component itself triggered (Home/End, drag). + const [announcedTime, setAnnouncedTime] = useState(currentTime); + const isFocusedRef = useRef(false); + + useEffect(() => { + if (!isFocusedRef.current) { + setAnnouncedTime(currentTime); + } + }, [currentTime]); + + const handleFocus = () => { + isFocusedRef.current = true; + }; + + // Also covers an ArrowLeft/ArrowRight seek from Flowplayer's global keyboard plugin (see below) - + // it can't call setAnnouncedTime directly, but its seek still ends in a blur or another + // interaction that resyncs this. + const handleBlur = (_event: FocusEvent) => { + isFocusedRef.current = false; + setAnnouncedTime(currentTime); + }; const handleDragChange = useCallback( (percentage: number) => { if (duration > 0) { - onSeek((percentage / 100) * duration); + const time = (percentage / 100) * duration; + onSeek(time); + setAnnouncedTime(time); } }, [duration, onSeek] @@ -50,9 +87,11 @@ export const ProgressBar: FC = ({ switch (event.key) { case 'Home': onSeek(0); + setAnnouncedTime(0); break; case 'End': onSeek(duration); + setAnnouncedTime(duration); break; default: return; @@ -60,7 +99,24 @@ export const ProgressBar: FC = ({ event.preventDefault(); }; - const cuepointMarkers = duration > 0 ? cuepoints || [] : []; + const cuepointRanges = (isSeekable ? cuepoints || [] : []).flatMap((cuepoint) => + cuepoint.startTime == null + ? [] + : [{ start: cuepoint.startTime, end: cuepoint.endTime ?? duration }] + ); + + // Cuepoints mark a highlighted segment (playback starts/stops at its bounds) - a purely visual + // affordance for sighted users otherwise, so announce it via aria-describedby. + const cuepointDescriptionId = useId(); + const cuepointDescription = + cuepointRanges.length > 0 + ? cuepointRanges + .map( + ({ start, end }) => + `${cuepointLabel}: ${formatProgressTime(start)}–${formatProgressTime(end)}` + ) + .join(', ') + : null; return (
        @@ -79,36 +135,33 @@ export const ProgressBar: FC = ({ // drag-to-percentage math in use-drag-value.ts is unaffected either way). className="c-flowplayer-progress__hit-area" role="slider" - tabIndex={0} + tabIndex={isSeekable ? 0 : -1} + aria-disabled={!isSeekable} aria-label={ariaLabel} + aria-describedby={cuepointDescription ? cuepointDescriptionId : undefined} aria-valuemin={0} aria-valuemax={duration} - aria-valuenow={currentTime} - aria-valuetext={formatProgressTime(currentTime)} + aria-valuenow={announcedTime} + aria-valuetext={formatProgressTime(announcedTime)} onKeyDown={handleKeyDown} + onFocus={handleFocus} + onBlur={handleBlur} {...dragHandlers} >
        - {cuepointMarkers.map((cuepoint, index) => { - if (cuepoint.startTime == null) { - return null; - } - const start = cuepoint.startTime; - const end = cuepoint.endTime ?? duration; - return ( -
        - ); - })} + {cuepointRanges.map(({ start, end }, index) => ( +
        + ))} {/* Drawn after the cuepoint markers (later in source order = higher paint order in this shared stacking context) so playback progress stays visible over any cuepoint it has already passed, instead of the marker painting over it. */} @@ -122,6 +175,11 @@ export const ProgressBar: FC = ({ />
        + {cuepointDescription && ( + + {cuepointDescription} + + )} {showTimestamps && ( = {}) => diff --git a/src/components/FlowPlayer/FlowPlayer.types.ts b/src/components/FlowPlayer/FlowPlayer.types.ts index 26c17098..c53b3de2 100644 --- a/src/components/FlowPlayer/FlowPlayer.types.ts +++ b/src/components/FlowPlayer/FlowPlayer.types.ts @@ -206,6 +206,7 @@ export enum FlowPlayerControlsLabelKey { SubtitlesOff = 'subtitlesOff', Speed = 'speed', ProgressBar = 'progressBar', + Cuepoint = 'cuepoint', } export type FlowPlayerControlsLabels = Record; From 90dadd4e33911ef22da3d4ac0f49c65fa371411b Mon Sep 17 00:00:00 2001 From: Femke Reunes Date: Tue, 8 Sep 2026 14:13:41 +0200 Subject: [PATCH 22/22] ARC-3904: PR remarks ensure buttons are being clicked --- .../Controls/useKeyboardShortcuts.test.ts | 9 ++++++-- .../Controls/useKeyboardShortcuts.ts | 22 +++++++++---------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/components/FlowPlayer/Controls/useKeyboardShortcuts.test.ts b/src/components/FlowPlayer/Controls/useKeyboardShortcuts.test.ts index be047384..e99e2492 100644 --- a/src/components/FlowPlayer/Controls/useKeyboardShortcuts.test.ts +++ b/src/components/FlowPlayer/Controls/useKeyboardShortcuts.test.ts @@ -67,15 +67,20 @@ describe('useKeyboardShortcuts', () => { expect(actions.enqueueSeek).toHaveBeenCalledWith(-1); }); - it('toggles play on Space unless focus is on a button', () => { + it('toggles play on Space, but clicks the target directly when it is a button', () => { const actions = buildActions(); const { result } = renderHook(() => useKeyboardShortcuts({ actions })); result.current(buildEvent(' ')); expect(actions.togglePlay).toHaveBeenCalledTimes(1); - result.current(buildEvent(' ', { tagName: 'BUTTON' })); + const buttonEvent = buildEvent(' ', { tagName: 'BUTTON' }); + const clickSpy = jest.spyOn(buttonEvent.target as HTMLElement, 'click'); + result.current(buttonEvent); expect(actions.togglePlay).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(buttonEvent.preventDefault).toHaveBeenCalled(); + expect(buttonEvent.stopPropagation).toHaveBeenCalled(); }); it('toggles fullscreen on F and mute on M', () => { diff --git a/src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts b/src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts index fd465329..ec9c29b3 100644 --- a/src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts +++ b/src/components/FlowPlayer/Controls/useKeyboardShortcuts.ts @@ -9,13 +9,11 @@ export interface UseKeyboardShortcutsOptions { /** * Space/F/M/arrow shortcuts while focus is anywhere inside the custom control bar. * - * Volume is mute/unmute only (M) - no granular up/down, since there's no volume-level UI to - * reflect it (see VolumeControl.tsx). + * Volume is mute/unmute only (M) - there's no volume-level UI to reflect finer control. * - * Arrow-key seeking is a deliberate hybrid: Flowplayer's own global keyboard plugin already seeks - * when the focused element has `aria-valuenow` (true for our progress bar), so we no-op there to - * avoid double-firing. Everywhere else (play/pause, mute, fullscreen buttons) that plugin doesn't - * recognize focus, so we call `enqueueSeek` ourselves (see useFlowplayerState.ts) to fill the gap. + * Arrow-key seeking is a deliberate hybrid: Flowplayer's global keyboard plugin already seeks when + * the focused element has `aria-valuenow` (our progress bar), so we no-op there to avoid double- + * firing, and call `enqueueSeek` ourselves everywhere else. */ export function useKeyboardShortcuts({ actions }: UseKeyboardShortcutsOptions) { return (event: KeyboardEvent) => { @@ -30,16 +28,18 @@ export function useKeyboardShortcuts({ actions }: UseKeyboardShortcutsOptions) { } const target = event.target as HTMLElement; - - // A focused