diff --git a/apps/web/__tests__/e2e/seek-protection.spec.ts b/apps/web/__tests__/e2e/seek-protection.spec.ts index 6f9e1ca8..34f149f2 100644 --- a/apps/web/__tests__/e2e/seek-protection.spec.ts +++ b/apps/web/__tests__/e2e/seek-protection.spec.ts @@ -1,14 +1,27 @@ /** * 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 { + 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); @@ -16,15 +29,25 @@ test.describe('Seek overlay', () => { 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(); @@ -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(); diff --git a/apps/web/components/DirectStreamPageBase.tsx b/apps/web/components/DirectStreamPageBase.tsx index 7126aa4d..675fc171 100644 --- a/apps/web/components/DirectStreamPageBase.tsx +++ b/apps/web/components/DirectStreamPageBase.tsx @@ -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'; @@ -1183,6 +1184,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP )} + + {/* Bookmark controls removed β€” bookmarks accessed via drawer (right-edge tab / portrait tab / mobile bottom sheet) */} {/* Real-time bookmark toast notifications (portrait) */} @@ -1878,6 +1881,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP )} + + {/* Bookmark controls removed β€” bookmarks accessed via drawer (right-edge tab / portrait tab / mobile bottom sheet) */} {/* Real-time bookmark toast notifications */} @@ -1936,8 +1941,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP )} {/* Touch-friendly hint */} {!isFullscreen && isTouch && ( -

- πŸ’‘ Tap video for controls β€’ Tap πŸ“Š for score β€’ Tap πŸ’¬ to chat +

+ πŸ’‘ Hold the video 2s to skip β€’ Tap πŸ“Š for score β€’ Tap πŸ’¬ to chat

)} diff --git a/apps/web/components/v2/video/SeekHoldHint.tsx b/apps/web/components/v2/video/SeekHoldHint.tsx new file mode 100644 index 00000000..dc1f1d81 --- /dev/null +++ b/apps/web/components/v2/video/SeekHoldHint.tsx @@ -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 ( +
+

+ Hold the video for a couple of seconds +

+

+ Skip forward, rewind, or jump back to live. +

+ +
+ ); +} diff --git a/apps/web/components/v2/video/SeekOverlay.tsx b/apps/web/components/v2/video/SeekOverlay.tsx index 392db8ef..dfab35fc 100644 --- a/apps/web/components/v2/video/SeekOverlay.tsx +++ b/apps/web/components/v2/video/SeekOverlay.tsx @@ -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 ─── */ @@ -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(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) => { @@ -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) { @@ -164,19 +168,13 @@ export function SeekOverlay({ onSeek, onTogglePause, onGoToStart, onGoLive, isPa
{/* Long-press detection zone β€” excludes bottom 60px for native controls */}
e.preventDefault()} + aria-label="Hold two seconds to show skip controls" data-testid="seek-overlay-longpress-zone" - > - {/* Center 20% trigger zone */} -
-
+ /> {/* Visible strip β€” also excludes bottom 60px */}
diff --git a/apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx b/apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx index fd07884c..6854fa21 100644 --- a/apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx +++ b/apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx @@ -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 { @@ -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(); expect(screen.getByTestId('mock-mux')).toHaveAttribute( @@ -70,26 +82,23 @@ describe('MuxStreamPlayer seek overlay', () => { }); it('seeks -30s within seekable range', async () => { - const user = userEvent.setup(); render(); - 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(); - 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(); - 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); }); }); diff --git a/apps/web/components/v2/video/__tests__/SeekHoldHint.test.tsx b/apps/web/components/v2/video/__tests__/SeekHoldHint.test.tsx new file mode 100644 index 00000000..65b2d1e0 --- /dev/null +++ b/apps/web/components/v2/video/__tests__/SeekHoldHint.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { SEEK_HOLD_HINT_KEY, SeekHoldHint } from '../SeekHoldHint'; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((i: number) => Object.keys(store)[i] ?? null), + }; +})(); +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }); + +describe('SeekHoldHint', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + }); + + it('shows first-visit skip instructions when not seen before', async () => { + render(); + const hint = await screen.findByTestId('hint-seek-hold-first'); + expect(hint).toHaveTextContent(/hold the video for a couple of seconds/i); + expect(hint).toHaveTextContent(/skip forward/i); + expect(hint).toHaveTextContent(/rewind/i); + expect(hint).toHaveTextContent(/live/i); + }); + + it('does not show after it has been seen', async () => { + localStorageMock.setItem(SEEK_HOLD_HINT_KEY, 'true'); + render(); + await Promise.resolve(); + expect(screen.queryByTestId('hint-seek-hold-first')).not.toBeInTheDocument(); + }); + + it('dismisses and does not show again', async () => { + const { unmount } = render(); + fireEvent.click(await screen.findByTestId('btn-dismiss-seek-hold-hint')); + expect(screen.queryByTestId('hint-seek-hold-first')).not.toBeInTheDocument(); + expect(localStorageMock.getItem(SEEK_HOLD_HINT_KEY)).toBe('true'); + + unmount(); + render(); + expect(screen.queryByTestId('hint-seek-hold-first')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/v2/video/__tests__/SeekOverlay.test.tsx b/apps/web/components/v2/video/__tests__/SeekOverlay.test.tsx index 304c144d..3918f693 100644 --- a/apps/web/components/v2/video/__tests__/SeekOverlay.test.tsx +++ b/apps/web/components/v2/video/__tests__/SeekOverlay.test.tsx @@ -39,37 +39,74 @@ describe('SeekOverlay', () => { expect(backdrop.className).toContain('opacity-100'); } + async function revealByLongPress() { + const zone = screen.getByTestId('seek-overlay-longpress-zone'); + fireEvent.pointerDown(zone, { button: 0 }); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + } + it('starts hidden (opacity-0)', () => { render(); expectHidden(); }); - it('shows buttons when center trigger is clicked', () => { + it('does not show on tap or click', () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); - expectVisible(); + fireEvent.click(screen.getByTestId('seek-overlay-longpress-zone')); + expectHidden(); + expect(screen.queryByTestId('seek-overlay-trigger')).not.toBeInTheDocument(); }); - it('dismisses when backdrop is clicked', () => { + it('does not show if the press is released before 2s', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + const zone = screen.getByTestId('seek-overlay-longpress-zone'); + fireEvent.pointerDown(zone, { button: 0 }); + await act(async () => { + vi.advanceTimersByTime(1999); + }); + fireEvent.pointerUp(zone); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + expectHidden(); + }); + + it('dismisses when backdrop is clicked', async () => { + render(); + await revealByLongPress(); expectVisible(); fireEvent.click(screen.getByTestId('seek-overlay-backdrop')); expectHidden(); }); - it('toggles visibility on repeated center taps and backdrop clicks', () => { - render(); - const trigger = screen.getByTestId('seek-overlay-trigger'); - - fireEvent.click(trigger); + it('dismisses when tapping elsewhere on the screen (e.g. a tab)', async () => { + render( +
+ + +
+ ); + await revealByLongPress(); expectVisible(); - fireEvent.click(screen.getByTestId('seek-overlay-backdrop')); + fireEvent.pointerDown(screen.getByTestId('tab-chat'), { button: 0 }); expectHidden(); }); + it('does not dismiss when pressing a skip button', async () => { + render(); + await revealByLongPress(); + expectVisible(); + + fireEvent.pointerDown(screen.getByTestId('seek-overlay-btn--10'), { button: 0 }); + expectVisible(); + }); + it('renders all 9 segment buttons', () => { render(); expect(screen.getByTestId('seek-overlay-btn-start')).toBeInTheDocument(); @@ -83,9 +120,9 @@ describe('SeekOverlay', () => { expect(screen.getByTestId('seek-overlay-btn-live')).toBeInTheDocument(); }); - it('calls onSeek with correct deltas for seek buttons', () => { + it('calls onSeek with correct deltas for seek buttons', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); fireEvent.click(screen.getByTestId('seek-overlay-btn--30')); expect(onSeek).toHaveBeenCalledWith(-30); @@ -106,25 +143,25 @@ describe('SeekOverlay', () => { expect(onSeek).toHaveBeenCalledWith(30); }); - it('calls onTogglePause when pause button is clicked', () => { + it('calls onTogglePause when pause button is clicked', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); fireEvent.click(screen.getByTestId('seek-overlay-btn-pause')); expect(onTogglePause).toHaveBeenCalledTimes(1); expect(onSeek).not.toHaveBeenCalled(); }); - it('calls onGoToStart when start button is clicked', () => { + it('calls onGoToStart when start button is clicked', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); fireEvent.click(screen.getByTestId('seek-overlay-btn-start')); expect(onGoToStart).toHaveBeenCalledTimes(1); expect(onSeek).not.toHaveBeenCalled(); }); - it('calls onGoLive when live button is clicked', () => { + it('calls onGoLive when live button is clicked', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); fireEvent.click(screen.getByTestId('seek-overlay-btn-live')); expect(onGoLive).toHaveBeenCalledTimes(1); expect(onSeek).not.toHaveBeenCalled(); @@ -144,7 +181,7 @@ describe('SeekOverlay', () => { it('auto-dismisses after 3 seconds of inactivity', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); expectVisible(); await act(async () => { @@ -155,7 +192,7 @@ describe('SeekOverlay', () => { it('button press resets auto-dismiss timer', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); // Advance 2 seconds await act(async () => { @@ -197,6 +234,14 @@ describe('SeekOverlay', () => { expect(root.className).toContain('pointer-events-none'); }); + it('long-press zone is labeled for assistive tech', () => { + render(); + expect(screen.getByTestId('seek-overlay-longpress-zone')).toHaveAttribute( + 'aria-label', + 'Hold two seconds to show skip controls' + ); + }); + it('long-press zone excludes bottom 60px for native controls', () => { render(); const zone = screen.getByTestId('seek-overlay-longpress-zone'); diff --git a/apps/web/components/v2/video/__tests__/VidstackPlayer.test.tsx b/apps/web/components/v2/video/__tests__/VidstackPlayer.test.tsx index 95abbc09..8145fe13 100644 --- a/apps/web/components/v2/video/__tests__/VidstackPlayer.test.tsx +++ b/apps/web/components/v2/video/__tests__/VidstackPlayer.test.tsx @@ -1,7 +1,6 @@ import { forwardRef, useImperativeHandle, 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 { VidstackPlayer } from '../VidstackPlayer'; const playerState = { @@ -61,30 +60,41 @@ vi.mock('@vidstack/react/player/layouts/default', () => ({ describe('VidstackPlayer seek overlay', () => { beforeEach(() => { + vi.useFakeTimers(); playerState.currentTime = 5000; }); + afterEach(() => { + vi.useRealTimers(); + }); + + async function revealOverlay() { + fireEvent.pointerDown(screen.getByTestId('seek-overlay-longpress-zone'), { + button: 0, + }); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + } + it('seeks -30s within seekable range', async () => { - const user = userEvent.setup(); render(); - 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(playerState.currentTime).toBe(4970); }); it('go-to-start uses seekable start', async () => { - const user = userEvent.setup(); render(); - 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(playerState.currentTime).toBe(120); }); it('go-live seeks to finite seekable end', async () => { - const user = userEvent.setup(); render(); - 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(playerState.currentTime).toBe(5100); expect(Number.isFinite(playerState.currentTime)).toBe(true); });