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
40 changes: 31 additions & 9 deletions apps/web/__tests__/e2e/seek-protection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,53 @@
/**
* Seek Overlay E2E Tests
*
* Verifies the tap-to-reveal seek overlay, Go Live button, and keyboard seeking.
* Verifies the long-press seek overlay, Go Live button, and keyboard seeking.
* Uses /demo/seek-protection which renders VidstackPlayer with SeekOverlay.
*/

import { test, expect } from '@playwright/test';
import { test, expect, type Page } from '@playwright/test';

const SEEK_PROTECTION_URL = '/demo/seek-protection';

async function revealSeekOverlay(page: Page): Promise<void> {
const zone = page.getByTestId('seek-overlay-longpress-zone');
await zone.waitFor({ state: 'visible' });
const box = await zone.boundingBox();
if (!box) {
throw new Error('seek-overlay-longpress-zone has no bounding box');
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(2100);
await page.mouse.up();
}

test.describe('Seek overlay', () => {
test.beforeEach(async ({ page }) => {
await page.goto(SEEK_PROTECTION_URL);
await page.getByTestId('vidstack-player').waitFor({ state: 'visible' });
await page.waitForTimeout(2000);
});

test('tapping center trigger zone shows seek overlay buttons', async ({ page }) => {
const trigger = page.getByTestId('seek-overlay-trigger');
await trigger.click();
test('a tap does not show seek overlay buttons', async ({ page }) => {
await page.getByTestId('seek-overlay-longpress-zone').click();
await expect(page.getByTestId('seek-overlay-buttons')).not.toBeVisible();
});

test('holding 2s shows seek overlay buttons', async ({ page }) => {
await revealSeekOverlay(page);
await expect(page.getByTestId('seek-overlay-buttons')).toBeVisible({ timeout: 2000 });
});

test('tapping elsewhere on the page dismisses the overlay', async ({ page }) => {
await revealSeekOverlay(page);
await expect(page.getByTestId('seek-overlay-buttons')).toBeVisible({ timeout: 2000 });
await page.locator('body').click({ position: { x: 8, y: 8 }, force: true });
await expect(page.getByTestId('seek-overlay-buttons')).not.toBeVisible();
});

test('tapping backdrop dismisses the overlay', async ({ page }) => {
const trigger = page.getByTestId('seek-overlay-trigger');
await trigger.click();
await revealSeekOverlay(page);
await expect(page.getByTestId('seek-overlay-buttons')).toBeVisible({ timeout: 2000 });
const backdrop = page.getByTestId('seek-overlay-backdrop');
const box = await backdrop.boundingBox();
Expand All @@ -38,8 +61,7 @@ test.describe('Seek overlay', () => {
});

test('seek overlay auto-dismisses after idle timeout', async ({ page }) => {
const trigger = page.getByTestId('seek-overlay-trigger');
await trigger.click();
await revealSeekOverlay(page);
await expect(page.getByTestId('seek-overlay-buttons')).toBeVisible({ timeout: 2000 });
await page.waitForTimeout(5500);
await expect(page.getByTestId('seek-overlay-buttons')).not.toBeVisible();
Expand Down
9 changes: 7 additions & 2 deletions apps/web/components/DirectStreamPageBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { ViewerAnalyticsPanel } from '@/components/ViewerAnalyticsPanel';
import { PaywallModal } from '@/components/PaywallModal';
// v2 Components
import { StreamPlayer } from '@/components/v2/video/StreamPlayer';
import { SeekHoldHint } from '@/components/v2/video/SeekHoldHint';
import { useFullscreen } from '@/hooks/v2/useFullscreen';
import { Chat, ReportEventSheet } from '@/components/v2/chat';
import { AdminBroadcast } from '@/components/v2/chat/AdminBroadcast';
Expand Down Expand Up @@ -1183,6 +1184,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP
</StreamPlayer>
)}

<SeekHoldHint />

{/* Bookmark controls removed — bookmarks accessed via drawer (right-edge tab / portrait tab / mobile bottom sheet) */}

{/* Real-time bookmark toast notifications (portrait) */}
Expand Down Expand Up @@ -1878,6 +1881,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP
</StreamPlayer>
)}

<SeekHoldHint />

{/* Bookmark controls removed — bookmarks accessed via drawer (right-edge tab / portrait tab / mobile bottom sheet) */}

{/* Real-time bookmark toast notifications */}
Expand Down Expand Up @@ -1936,8 +1941,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP
)}
{/* Touch-friendly hint */}
{!isFullscreen && isTouch && (
<p className="mt-2">
💡 Tap video for controls • Tap 📊 for score • Tap 💬 to chat
<p className="mt-2" data-testid="hint-seek-hold">
💡 Hold the video 2s to skip • Tap 📊 for score • Tap 💬 to chat
</p>
)}
</div>
Expand Down
60 changes: 60 additions & 0 deletions apps/web/components/v2/video/SeekHoldHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client';

/**
* One-time first-visit tip: hold the video to skip/rewind/go live.
* Stored in localStorage so it only appears until dismissed.
*/

import { useCallback, useEffect, useState } from 'react';

export const SEEK_HOLD_HINT_KEY = 'fv_seek_hold_hint_seen';

export function SeekHoldHint() {
const [visible, setVisible] = useState(false);

useEffect(() => {
try {
if (window.localStorage.getItem(SEEK_HOLD_HINT_KEY) === 'true') return;
} catch {
return;
}
setVisible(true);
}, []);

const dismiss = useCallback(() => {
try {
window.localStorage.setItem(SEEK_HOLD_HINT_KEY, 'true');
} catch {
// ignore quota / private-mode
}
setVisible(false);
}, []);

if (!visible) return null;

return (
<div
role="dialog"
aria-labelledby="seek-hold-hint-title"
aria-describedby="seek-hold-hint-body"
data-testid="hint-seek-hold-first"
className="absolute inset-x-3 bottom-16 z-30 rounded-xl bg-black/85 backdrop-blur-md border border-white/15 p-4 shadow-lg"
>
<p id="seek-hold-hint-title" className="text-white text-sm font-semibold">
Hold the video for a couple of seconds
</p>
<p id="seek-hold-hint-body" className="mt-1 text-white/80 text-sm">
Skip forward, rewind, or jump back to live.
</p>
<button
type="button"
onClick={dismiss}
className="mt-3 min-h-[44px] px-4 rounded-lg bg-white/15 hover:bg-white/25 text-white text-sm font-medium"
data-testid="btn-dismiss-seek-hold-hint"
aria-label="Got it, dismiss skip instructions"
>
Got it
</button>
</div>
);
}
55 changes: 27 additions & 28 deletions apps/web/components/v2/video/SeekOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
'use client';

/**
* SeekOverlay - Tap-to-reveal frosted-glass seek strip overlaid on the video player.
* SeekOverlay - Long-press frosted-glass seek strip overlaid on the video player.
*
* Trigger: tap the center 20% of the video, or long-press (2s) anywhere above controls.
* Trigger: hold 2s anywhere on the video above the native controls (not a tap).
* Shows a 9-segment icon strip: |◀ ◀◀◀ ◀◀ ◀ ⏸/▶ ▶ ▶▶ ▶▶▶ ▶|
* Dismiss: tap outside the strip, or auto-dismiss after 3s idle.
* Dismiss: tap anywhere else (tabs, chrome, video, backdrop), or auto-dismiss after 3s idle.
*
* IMPORTANT: The overlay uses pointer-events-none by default and excludes the
* bottom 60px so native player controls (volume, fullscreen, etc.) remain accessible.
*/

import { useCallback, type MouseEvent } from 'react';
import { useCallback, useEffect, useRef, type MouseEvent } from 'react';
import { useSeekOverlay } from '@/hooks/v2/useSeekOverlay';

/* ─── SVG Icon Helpers ─── */
Expand Down Expand Up @@ -114,8 +114,24 @@ export interface SeekOverlayProps {
}

export function SeekOverlay({ onSeek, onTogglePause, onGoToStart, onGoLive, isPaused }: SeekOverlayProps) {
const { isVisible, show, dismiss, resetAutoDismiss, longPressHandlers } =
const { isVisible, dismiss, resetAutoDismiss, longPressHandlers } =
useSeekOverlay();
const buttonsRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!isVisible) return;

const onPointerDownCapture = (event: PointerEvent) => {
const buttons = buttonsRef.current;
if (buttons && event.target instanceof Node && buttons.contains(event.target)) {
return;
}
dismiss();
};

document.addEventListener('pointerdown', onPointerDownCapture, true);
return () => document.removeEventListener('pointerdown', onPointerDownCapture, true);
}, [isVisible, dismiss]);

const handleSegmentClick = useCallback(
(action: SegmentAction) => (e: MouseEvent) => {
Expand All @@ -139,18 +155,6 @@ export function SeekOverlay({ onSeek, onTogglePause, onGoToStart, onGoLive, isPa
[onSeek, onTogglePause, onGoToStart, onGoLive, resetAutoDismiss]
);

const handleCenterTap = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
if (isVisible) {
dismiss();
} else {
show();
}
},
[isVisible, show, dismiss]
);

const handleBackdropClick = useCallback(
(e: MouseEvent) => {
if (e.target === e.currentTarget) {
Expand All @@ -164,19 +168,13 @@ export function SeekOverlay({ onSeek, onTogglePause, onGoToStart, onGoLive, isPa
<div className="absolute inset-0 pointer-events-none" data-testid="seek-overlay-root">
{/* Long-press detection zone — excludes bottom 60px for native controls */}
<div
className="absolute inset-x-0 top-0 bottom-[60px] z-10 pointer-events-auto"
style={{ display: isVisible ? 'none' : 'block' }}
className="absolute inset-x-0 top-0 bottom-[60px] z-10 pointer-events-auto select-none touch-manipulation"
style={{ display: isVisible ? 'none' : 'block', WebkitTouchCallout: 'none' }}
{...longPressHandlers}
onContextMenu={(e) => e.preventDefault()}
aria-label="Hold two seconds to show skip controls"
data-testid="seek-overlay-longpress-zone"
>
{/* Center 20% trigger zone */}
<div
className="absolute inset-y-0"
style={{ left: '40%', right: '40%' }}
onClick={handleCenterTap}
data-testid="seek-overlay-trigger"
/>
</div>
/>

{/* Visible strip — also excludes bottom 60px */}
<div
Expand All @@ -190,6 +188,7 @@ export function SeekOverlay({ onSeek, onTogglePause, onGoToStart, onGoLive, isPa
data-testid="seek-overlay-backdrop"
>
<div
ref={buttonsRef}
className="flex items-center rounded-full bg-black/40 backdrop-blur-md shadow-lg shadow-black/20 overflow-hidden"
data-testid="seek-overlay-buttons"
>
Expand Down
35 changes: 22 additions & 13 deletions apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
type ReactNode,
} from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MuxStreamPlayer } from '../MuxStreamPlayer';

function mockSeekable(ranges: Array<{ start: number; end: number }>): TimeRanges {
Expand Down Expand Up @@ -57,10 +55,24 @@ vi.mock('@mux/mux-player-react', () => ({

describe('MuxStreamPlayer seek overlay', () => {
beforeEach(() => {
vi.useFakeTimers();
mediaState.currentTime = 5000;
mediaState.seekable = mockSeekable([{ start: 120, end: 5100 }]);
});

afterEach(() => {
vi.useRealTimers();
});

async function revealOverlay() {
fireEvent.pointerDown(screen.getByTestId('seek-overlay-longpress-zone'), {
button: 0,
});
await act(async () => {
vi.advanceTimersByTime(2000);
});
}

it('passes live:dvr streamType to Mux element', () => {
render(<MuxStreamPlayer playbackId="pid" streamType="live:dvr" />);
expect(screen.getByTestId('mock-mux')).toHaveAttribute(
Expand All @@ -70,26 +82,23 @@ describe('MuxStreamPlayer seek overlay', () => {
});

it('seeks -30s within seekable range', async () => {
const user = userEvent.setup();
render(<MuxStreamPlayer playbackId="pid" streamType="live:dvr" />);
await user.click(screen.getByTestId('seek-overlay-trigger'));
await user.click(screen.getByTestId('seek-overlay-btn--30'));
await revealOverlay();
fireEvent.click(screen.getByTestId('seek-overlay-btn--30'));
expect(mediaState.currentTime).toBe(4970);
});

it('go-to-start uses seekable start not zero', async () => {
const user = userEvent.setup();
render(<MuxStreamPlayer playbackId="pid" streamType="live:dvr" />);
await user.click(screen.getByTestId('seek-overlay-trigger'));
await user.click(screen.getByTestId('seek-overlay-btn-start'));
await revealOverlay();
fireEvent.click(screen.getByTestId('seek-overlay-btn-start'));
expect(mediaState.currentTime).toBe(120);
});

it('go-live seeks to seekable end', async () => {
const user = userEvent.setup();
render(<MuxStreamPlayer playbackId="pid" streamType="live:dvr" />);
await user.click(screen.getByTestId('seek-overlay-trigger'));
await user.click(screen.getByTestId('seek-overlay-btn-live'));
await revealOverlay();
fireEvent.click(screen.getByTestId('seek-overlay-btn-live'));
expect(mediaState.currentTime).toBe(5100);
});
});
Loading
Loading