Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/app/player/ScreeningPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export interface ScreeningPlayerHandle {
playFrom(videoId: string): void;
}

/** Supported reel playback speeds, including normal rate as the default. */
export const SCREENING_PLAYBACK_RATES = [1, 1.15, 1.25, 1.5, 2] as const;

const initialState = (index: number, durationSeconds: number): PlayerState => ({
phase: 'idle',
index,
Expand Down Expand Up @@ -50,11 +53,13 @@ export const ScreeningPlayer = forwardRef<
] as const;
const controller = useRef<ScreeningController | null>(null);
const announcedVideoId = useRef<string | null>(null);
const playbackRateRef = useRef(1);
const requestedIndex = playlist.findIndex((clip) => clip.videoId === initialVideoId);
const initialIndex = Math.max(0, requestedIndex);
const [state, setState] = useState(
initialState(initialIndex, playlist[initialIndex]?.durationSeconds ?? 0),
);
const [playbackRate, setPlaybackRate] = useState(1);

const publishState = useCallback(
(next: PlayerState) => {
Expand All @@ -81,9 +86,22 @@ export const ScreeningPlayer = forwardRef<
getPlayback,
onState: publishState,
});
controller.current.setPlaybackRate(playbackRateRef.current);
return controller.current;
}, [getPlayback, playlist, publishState, videos]);

const updatePlaybackRate = useCallback(
(rate: number) => {
playbackRateRef.current = rate;
setPlaybackRate(rate);
controller.current?.setPlaybackRate(rate);
for (const element of videos) {
if (element.current) element.current.playbackRate = rate;
}
},
[videos],
);

useImperativeHandle(
ref,
() => ({
Expand Down Expand Up @@ -272,6 +290,20 @@ export const ScreeningPlayer = forwardRef<
{activeIndex + 1} of {playlist.length}
</span>
</div>
<label className="playbackSpeed">
<span className="sr-only">playback speed</span>
<select
aria-label="playback speed"
value={String(playbackRate)}
onChange={(event) => updatePlaybackRate(Number(event.currentTarget.value))}
>
{SCREENING_PLAYBACK_RATES.map((rate) => (
<option key={rate} value={rate}>
{formatPlaybackRate(rate)}
</option>
))}
</select>
</label>
<button
disabled={state.phase === 'idle' || state.phase === 'complete'}
onClick={() => void controller.current?.skip()}
Expand All @@ -293,13 +325,18 @@ function formatPlaybackTime(value: number) {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
}

function formatPlaybackRate(rate: number) {
return `${rate}x`;
}

export function handleScreeningShortcut(
event: Pick<KeyboardEvent, 'code' | 'key' | 'target' | 'preventDefault'>,
actions: {togglePause(): void; skip(): void; fullscreen(): void},
) {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement
)
return;
if (event.code === 'Space') actions.togglePause();
Expand Down
14 changes: 14 additions & 0 deletions src/app/player/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ScreeningController {
jumpTo(index: number): Promise<void>;
togglePause(): Promise<void>;
seek(time: number): void;
setPlaybackRate(rate: number): void;
skip(): Promise<void>;
destroy(): void;
}
Expand Down Expand Up @@ -53,6 +54,7 @@ export function createScreeningController({
let stateError: string | null = null;
let destroyed = false;
let operation = 0;
let playbackRate = 1;
let transitionTimer: ReturnType<typeof setTimeout> | null = null;
let countdownTimer: ReturnType<typeof setInterval> | null = null;
const attachments: [MediaAttachment | null, MediaAttachment | null] = [null, null];
Expand All @@ -68,6 +70,12 @@ export function createScreeningController({
durationSeconds,
});

function applyPlaybackRate() {
for (const element of elements) element.playbackRate = playbackRate;
}

applyPlaybackRate();

const endedHandlers = elements.map((_element, slot) => () => {
if (phase === 'playing' && slot === active) void advance();
});
Expand Down Expand Up @@ -125,6 +133,7 @@ export function createScreeningController({
}
attachments[slot] = attachment;
attachedVideoIds[slot] = clip.videoId;
applyPlaybackRate();
audio.setGain(slot, clip.gainDb);
}

Expand Down Expand Up @@ -252,6 +261,11 @@ export function createScreeningController({
currentTime = next;
notify();
},
setPlaybackRate(rate) {
if (!Number.isFinite(rate) || rate <= 0) return;
playbackRate = rate;
applyPlaybackRate();
},
async skip() {
if (phase === 'idle' || phase === 'complete') return;
await advance();
Expand Down
43 changes: 27 additions & 16 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3382,7 +3382,7 @@ main {
}
.screeningControls {
display: grid;
grid-template-columns: auto 1fr auto auto;
grid-template-columns: auto 1fr auto auto auto;
gap: 0.5rem;
align-items: center;
padding: 0.75rem;
Expand All @@ -3403,17 +3403,38 @@ main {
color: #d9cae5;
font-variant-numeric: tabular-nums;
}
.screeningControls button {
.screeningControls button,
.screeningControls .playbackSpeed select {
min-height: 2.6rem;
padding: 0.55rem 0.7rem;
color: #fff;
border: 1px solid #5e4a6c;
border-radius: 0.5rem;
background: #281b32;
background-color: #281b32;
}
.screeningControls button:hover:not(:disabled) {
.screeningControls button:hover:not(:disabled),
.screeningControls .playbackSpeed select:hover {
Comment thread
sentry-junior[bot] marked this conversation as resolved.
border-color: #9f77ff;
background: #382748;
background-color: #382748;
}
.screeningControls .playbackSpeed {
display: grid;
min-width: 0;
margin: 0;
}
.screeningControls .playbackSpeed select {
min-width: 4.75rem;
cursor: pointer;
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, #d9cae5 50%),
linear-gradient(135deg, #d9cae5 50%, transparent 50%);
background-position:
calc(100% - 0.95rem) calc(50% - 0.12rem),
calc(100% - 0.65rem) calc(50% - 0.12rem);
background-size: 0.3rem 0.3rem;
background-repeat: no-repeat;
padding-right: 1.6rem;
}
.screeningControls > div {
min-width: 0;
Expand Down Expand Up @@ -3575,7 +3596,7 @@ kbd {
flex-direction: column;
}
.screeningControls {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.screeningTimeline {
grid-row: 1;
Expand All @@ -3584,12 +3605,6 @@ kbd {
grid-column: 1 / -1;
grid-row: 2;
}
.screeningControls button {
grid-row: 3;
}
.screeningControls button:last-child {
grid-column: auto;
}
.screeningControls kbd {
display: none;
}
Expand All @@ -3609,10 +3624,6 @@ kbd {
.screeningControls {
grid-template-columns: 1fr 1fr;
}
.screeningControls button:last-child {
grid-row: 4;
grid-column: 1 / -1;
}
.titleCardFrame {
padding: 1rem;
}
Expand Down
7 changes: 7 additions & 0 deletions test/player/controller.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ describe('dual screening controller', () => {
expect(videos[0].currentTime).toBe(4.5);
expect(states.at(-1)).toMatchObject({currentTime: 4.5, durationSeconds: 10});

controller.setPlaybackRate(1.5);
expect(videos[0].playbackRate).toBe(1.5);
expect(videos[1].playbackRate).toBe(1.5);

videos[1].dispatchEvent(new Event('ended'));
await Promise.resolve();
expect(states.at(-1)?.index).toBe(0);
Expand All @@ -67,8 +71,11 @@ describe('dual screening controller', () => {
expect(audio.resume).toHaveBeenCalledTimes(2);
expect(states.at(-1)?.phase).toBe('title');
expect(states.at(-1)?.index).toBe(1);
expect(videos[0].playbackRate).toBe(1.5);
expect(videos[1].playbackRate).toBe(1.5);
await vi.advanceTimersByTimeAsync(5_000);
expect(vi.mocked(videos[1].play).mock.calls).toHaveLength(1);
expect(videos[1].playbackRate).toBe(1.5);

videos[1].dispatchEvent(new Event('ended'));
await Promise.resolve();
Expand Down
22 changes: 22 additions & 0 deletions test/video-ui/video-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,16 @@ describe('video user experience', () => {
expect(actions.togglePause).toHaveBeenCalledOnce();
expect(actions.skip).toHaveBeenCalledOnce();
expect(actions.fullscreen).toHaveBeenCalledOnce();
handleScreeningShortcut(
{
code: 'Space',
key: ' ',
target: document.createElement('select'),
preventDefault: vi.fn(),
},
actions,
);
expect(actions.togglePause).toHaveBeenCalledOnce();

const requestFullscreen = vi.fn(async () => undefined);
Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', {
Expand All @@ -398,6 +408,18 @@ describe('video user experience', () => {
expect(screen.getByRole('button', {name: /skip/}).hasAttribute('disabled')).toBe(
true,
);
const speed = screen.getByRole('combobox', {name: 'playback speed'});
expect(speed).toBeInstanceOf(HTMLSelectElement);
expect(
[...speed.querySelectorAll('option')].map((option) => option.getAttribute('value')),
).toEqual(['1', '1.15', '1.25', '1.5', '2']);
await userEvent.selectOptions(speed, '1.5');
expect(speed).toHaveProperty('value', '1.5');
expect(
[...document.querySelectorAll('video')].every(
(video) => video.playbackRate === 1.5,
),
).toBe(true);
await userEvent.click(screen.getByRole('button', {name: /fullscreen/}));
expect(requestFullscreen).toHaveBeenCalledOnce();
});
Expand Down
Loading