From a664baf7e72bb972c4aa818fb64f039f4428f6e6 Mon Sep 17 00:00:00 2001 From: Peifan Li Date: Mon, 29 Dec 2025 16:42:35 -0500 Subject: [PATCH] feat: Add pause on focus loss functionality --- backend/src/types/settings.ts | 2 + .../Settings/VideoDefaultSettings.tsx | 9 ++ .../hooks/__tests__/useFocusPause.test.ts | 109 ++++++++++++++++++ .../VideoControls/hooks/useFocusPause.ts | 47 ++++++++ .../VideoPlayer/VideoControls/index.tsx | 6 + frontend/src/hooks/useVideoPlayerSettings.ts | 2 + frontend/src/pages/VideoPlayer.tsx | 14 ++- frontend/src/types.ts | 1 + frontend/src/utils/locales/ar.ts | 1 + frontend/src/utils/locales/de.ts | 1 + frontend/src/utils/locales/en.ts | 1 + frontend/src/utils/locales/es.ts | 1 + frontend/src/utils/locales/fr.ts | 1 + frontend/src/utils/locales/ja.ts | 1 + frontend/src/utils/locales/ko.ts | 1 + frontend/src/utils/locales/pt.ts | 1 + frontend/src/utils/locales/ru.ts | 1 + frontend/src/utils/locales/zh.ts | 1 + 18 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/VideoPlayer/VideoControls/hooks/__tests__/useFocusPause.test.ts create mode 100644 frontend/src/components/VideoPlayer/VideoControls/hooks/useFocusPause.ts diff --git a/backend/src/types/settings.ts b/backend/src/types/settings.ts index 6d4aa13..b8b3afa 100644 --- a/backend/src/types/settings.ts +++ b/backend/src/types/settings.ts @@ -26,6 +26,7 @@ export interface Settings { videoColumns?: number; cloudflaredTunnelEnabled?: boolean; cloudflaredToken?: string; + pauseOnFocusLoss?: boolean; } export const defaultSettings: Settings = { @@ -49,6 +50,7 @@ export const defaultSettings: Settings = { visitorMode: false, infiniteScroll: false, videoColumns: 4, + pauseOnFocusLoss: false, }; diff --git a/frontend/src/components/Settings/VideoDefaultSettings.tsx b/frontend/src/components/Settings/VideoDefaultSettings.tsx index c617d18..f984bc2 100644 --- a/frontend/src/components/Settings/VideoDefaultSettings.tsx +++ b/frontend/src/components/Settings/VideoDefaultSettings.tsx @@ -24,6 +24,15 @@ const VideoDefaultSettings: React.FC = ({ settings, o } label={t('autoPlay')} /> + onChange('pauseOnFocusLoss', e.target.checked)} + /> + } + label={t('pauseOnFocusLoss') || "Pause video when window loses focus"} + /> ); diff --git a/frontend/src/components/VideoPlayer/VideoControls/hooks/__tests__/useFocusPause.test.ts b/frontend/src/components/VideoPlayer/VideoControls/hooks/__tests__/useFocusPause.test.ts new file mode 100644 index 0000000..54430ab --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoControls/hooks/__tests__/useFocusPause.test.ts @@ -0,0 +1,109 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFocusPause } from '../useFocusPause'; + +describe('useFocusPause', () => { + let videoMock: HTMLVideoElement; + let videoRef: React.RefObject; + + beforeEach(() => { + videoMock = { + play: vi.fn().mockResolvedValue(undefined), + pause: vi.fn(), + paused: false, + } as unknown as HTMLVideoElement; + + videoRef = { current: videoMock }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should pause video on window blur if playing', () => { + renderHook(() => useFocusPause(videoRef, true)); + + // Simulate playing state + Object.defineProperty(videoMock, 'paused', { value: false, writable: true }); + + // Trigger blur + window.dispatchEvent(new Event('blur')); + + expect(videoMock.pause).toHaveBeenCalled(); + }); + + it('should not pause video on window blur if already paused', () => { + renderHook(() => useFocusPause(videoRef, true)); + + // Simulate paused state + Object.defineProperty(videoMock, 'paused', { value: true, writable: true }); + + // Trigger blur + window.dispatchEvent(new Event('blur')); + + expect(videoMock.pause).not.toHaveBeenCalled(); + }); + + it('should resume video on window focus if it was paused by blur', () => { + renderHook(() => useFocusPause(videoRef, true)); + + // 1. Play + Object.defineProperty(videoMock, 'paused', { value: false, writable: true }); + + // 2. Blur (pauses) + window.dispatchEvent(new Event('blur')); + expect(videoMock.pause).toHaveBeenCalled(); + + // 3. Focus (resumes) + window.dispatchEvent(new Event('focus')); + expect(videoMock.play).toHaveBeenCalled(); + }); + + it('should NOT resume video on window focus if it was NOT playing before blur', () => { + renderHook(() => useFocusPause(videoRef, true)); + + // 1. Paused initially + Object.defineProperty(videoMock, 'paused', { value: true, writable: true }); + + // 2. Blur + window.dispatchEvent(new Event('blur')); + + // 3. Focus + window.dispatchEvent(new Event('focus')); + expect(videoMock.play).not.toHaveBeenCalled(); + }); + + it('should do nothing if disabled', () => { + renderHook(() => useFocusPause(videoRef, false)); + + Object.defineProperty(videoMock, 'paused', { value: false, writable: true }); + + window.dispatchEvent(new Event('blur')); + expect(videoMock.pause).not.toHaveBeenCalled(); + }); + + it('should pause on visibilitychange hidden', () => { + renderHook(() => useFocusPause(videoRef, true)); + Object.defineProperty(videoMock, 'paused', { value: false, writable: true }); + + Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true }); + document.dispatchEvent(new Event('visibilitychange')); + + expect(videoMock.pause).toHaveBeenCalled(); + }); + + it('should resume on visibilitychange visible', () => { + renderHook(() => useFocusPause(videoRef, true)); + + // Pause via blur/hidden first + Object.defineProperty(videoMock, 'paused', { value: false, writable: true }); + Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true }); + document.dispatchEvent(new Event('visibilitychange')); + expect(videoMock.pause).toHaveBeenCalled(); + + // Resume + Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true }); + document.dispatchEvent(new Event('visibilitychange')); + expect(videoMock.play).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/VideoPlayer/VideoControls/hooks/useFocusPause.ts b/frontend/src/components/VideoPlayer/VideoControls/hooks/useFocusPause.ts new file mode 100644 index 0000000..a4e671d --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoControls/hooks/useFocusPause.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react'; + +export const useFocusPause = ( + videoRef: React.RefObject, + enabled: boolean +) => { + // Track if the video was playing when we lost focus + const wasPlayingRef = useRef(false); + + useEffect(() => { + if (!enabled) return; + + const handleBlur = () => { + const videoElement = videoRef.current; + if (videoElement && !videoElement.paused) { + wasPlayingRef.current = true; + videoElement.pause(); + } + }; + + const handleFocus = () => { + const videoElement = videoRef.current; + if (videoElement && wasPlayingRef.current) { + videoElement.play().catch(e => console.error("Error resuming playback:", e)); + } + wasPlayingRef.current = false; + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + handleBlur(); + } else if (document.visibilityState === 'visible') { + handleFocus(); + } + }; + + window.addEventListener('blur', handleBlur); + window.addEventListener('focus', handleFocus); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('blur', handleBlur); + window.removeEventListener('focus', handleFocus); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled, videoRef]); +}; diff --git a/frontend/src/components/VideoPlayer/VideoControls/index.tsx b/frontend/src/components/VideoPlayer/VideoControls/index.tsx index 37b3b1e..64a94e4 100644 --- a/frontend/src/components/VideoPlayer/VideoControls/index.tsx +++ b/frontend/src/components/VideoPlayer/VideoControls/index.tsx @@ -1,6 +1,7 @@ import { Box } from '@mui/material'; import React, { useEffect } from 'react'; import ControlsOverlay from './ControlsOverlay'; +import { useFocusPause } from './hooks/useFocusPause'; import { useFullscreen } from './hooks/useFullscreen'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useSubtitles } from './hooks/useSubtitles'; @@ -13,6 +14,7 @@ interface VideoControlsProps { src: string; autoPlay?: boolean; autoLoop?: boolean; + pauseOnFocusLoss?: boolean; onTimeUpdate?: (currentTime: number) => void; onLoadedMetadata?: (duration: number) => void; startTime?: number; @@ -28,6 +30,7 @@ const VideoControls: React.FC = ({ src, autoPlay = false, autoLoop = false, + pauseOnFocusLoss = false, onTimeUpdate, onLoadedMetadata, startTime = 0, @@ -48,6 +51,9 @@ const VideoControls: React.FC = ({ onLoadedMetadata }); + // Auto-pause on focus loss + useFocusPause(videoPlayer.videoRef, pauseOnFocusLoss); + // Fullscreen management const fullscreen = useFullscreen(videoPlayer.videoRef); diff --git a/frontend/src/hooks/useVideoPlayerSettings.ts b/frontend/src/hooks/useVideoPlayerSettings.ts index 692bcdc..db3cf16 100644 --- a/frontend/src/hooks/useVideoPlayerSettings.ts +++ b/frontend/src/hooks/useVideoPlayerSettings.ts @@ -25,6 +25,7 @@ export function useVideoPlayerSettings() { const autoPlay = settings?.defaultAutoPlay || false; const autoLoop = settings?.defaultAutoLoop || false; const subtitlesEnabled = settings?.subtitlesEnabled ?? true; + const pauseOnFocusLoss = settings?.pauseOnFocusLoss || false; // Subtitle preference mutation const subtitlePreferenceMutation = useMutation({ @@ -82,6 +83,7 @@ export function useVideoPlayerSettings() { autoPlay, autoLoop, subtitlesEnabled, + pauseOnFocusLoss, availableTags, handleSubtitlesToggle, handleLoopToggle diff --git a/frontend/src/pages/VideoPlayer.tsx b/frontend/src/pages/VideoPlayer.tsx index 04921e2..dd24500 100644 --- a/frontend/src/pages/VideoPlayer.tsx +++ b/frontend/src/pages/VideoPlayer.tsx @@ -80,15 +80,16 @@ const VideoPlayer: React.FC = () => { }, [error, navigate, visitorMode, video]); // Use video player settings hook - const { - autoPlay: settingsAutoPlay, - autoLoop, + const { + autoPlay: settingsAutoPlay, + autoLoop, subtitlesEnabled, availableTags, - handleSubtitlesToggle, - handleLoopToggle + handleSubtitlesToggle, + handleLoopToggle, + pauseOnFocusLoss } = useVideoPlayerSettings(); - + const autoPlay = autoPlayNext || settingsAutoPlay; // Get cloud storage URLs @@ -280,6 +281,7 @@ const VideoPlayer: React.FC = () => { poster={posterUrl || localPosterUrl || video?.thumbnailUrl} autoPlay={autoPlay} autoLoop={autoLoop} + pauseOnFocusLoss={pauseOnFocusLoss} onTimeUpdate={handleTimeUpdate} startTime={video.progress || 0} subtitles={video.subtitles} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7dcb310..cddc674 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -84,4 +84,5 @@ export interface Settings { videoColumns?: number; cloudflaredTunnelEnabled?: boolean; cloudflaredToken?: string; + pauseOnFocusLoss?: boolean; } diff --git a/frontend/src/utils/locales/ar.ts b/frontend/src/utils/locales/ar.ts index b8c6cf0..6df1b00 100644 --- a/frontend/src/utils/locales/ar.ts +++ b/frontend/src/utils/locales/ar.ts @@ -58,6 +58,7 @@ export const ar = { settingsFailed: "فشل حفظ الإعدادات", debugMode: "وضع التصحيح", debugModeDescription: "إظهار أو إخفاء رسائل وحدة التحكم (يتطلب التحديث)", + pauseOnFocusLoss: "إيقاف مؤقت عند فقدان التركيز", tagsManagement: "إدارة العلامات", newTag: "علامة جديدة", tags: "العلامات", diff --git a/frontend/src/utils/locales/de.ts b/frontend/src/utils/locales/de.ts index c5b6789..c1efedf 100644 --- a/frontend/src/utils/locales/de.ts +++ b/frontend/src/utils/locales/de.ts @@ -59,6 +59,7 @@ export const de = { debugMode: "Debug-Modus", debugModeDescription: "Konsolenmeldungen anzeigen oder ausblenden (erfordert Aktualisierung)", + pauseOnFocusLoss: "Pause bei Fokusverlust", tagsManagement: "Tag-Verwaltung", newTag: "Neues Tag", tags: "Tags", diff --git a/frontend/src/utils/locales/en.ts b/frontend/src/utils/locales/en.ts index fb32df5..e475778 100644 --- a/frontend/src/utils/locales/en.ts +++ b/frontend/src/utils/locales/en.ts @@ -58,6 +58,7 @@ export const en = { settingsFailed: "Failed to save settings", debugMode: "Debug Mode", debugModeDescription: "Show or hide console messages (requires refresh)", + pauseOnFocusLoss: "Pause on Focus Loss", tagsManagement: "Tags Management", newTag: "New Tag", tags: "Tags", diff --git a/frontend/src/utils/locales/es.ts b/frontend/src/utils/locales/es.ts index d11dfc9..0ca0b83 100644 --- a/frontend/src/utils/locales/es.ts +++ b/frontend/src/utils/locales/es.ts @@ -69,6 +69,7 @@ export const es = { debugMode: "Modo de Depuración", debugModeDescription: "Mostrar u ocultar mensajes de consola (requiere actualización)", + pauseOnFocusLoss: "Pausar al perder el foco", tagsManagement: "Gestión de Etiquetas", newTag: "Nueva Etiqueta", tags: "Etiquetas", diff --git a/frontend/src/utils/locales/fr.ts b/frontend/src/utils/locales/fr.ts index 0ca8004..b90dee7 100644 --- a/frontend/src/utils/locales/fr.ts +++ b/frontend/src/utils/locales/fr.ts @@ -63,6 +63,7 @@ export const fr = { debugMode: "Mode débogage", debugModeDescription: "Afficher ou masquer les messages de la console (nécessite une actualisation)", + pauseOnFocusLoss: "Pause lors de la perte de focus", tagsManagement: "Gestion des tags", newTag: "Nouveau tag", tags: "Tags", diff --git a/frontend/src/utils/locales/ja.ts b/frontend/src/utils/locales/ja.ts index 39a87cd..8d827ad 100644 --- a/frontend/src/utils/locales/ja.ts +++ b/frontend/src/utils/locales/ja.ts @@ -63,6 +63,7 @@ export const ja = { debugMode: "デバッグモード", debugModeDescription: "コンソールメッセージを表示または非表示にします(更新が必要)", + pauseOnFocusLoss: "フォーカス喪失時に一時停止", tagsManagement: "タグ管理", newTag: "新しいタグ", tags: "タグ", diff --git a/frontend/src/utils/locales/ko.ts b/frontend/src/utils/locales/ko.ts index 7296b14..d74beca 100644 --- a/frontend/src/utils/locales/ko.ts +++ b/frontend/src/utils/locales/ko.ts @@ -60,6 +60,7 @@ export const ko = { settingsFailed: "설정 저장 실패", debugMode: "디버그 모드", debugModeDescription: "콘솔 메시지 표시 또는 숨기기 (새로 고침 필요)", + pauseOnFocusLoss: "포커스 손실 시 일시 중지", tagsManagement: "태그 관리", newTag: "새 태그", tags: "태그", diff --git a/frontend/src/utils/locales/pt.ts b/frontend/src/utils/locales/pt.ts index a8d19e6..ae8c71f 100644 --- a/frontend/src/utils/locales/pt.ts +++ b/frontend/src/utils/locales/pt.ts @@ -63,6 +63,7 @@ export const pt = { debugMode: "Modo de Depuração", debugModeDescription: "Mostrar ou ocultar mensagens do console (requer atualização)", + pauseOnFocusLoss: "Pausar ao perder o foco", tagsManagement: "Gerenciamento de Tags", newTag: "Nova Tag", tags: "Tags", diff --git a/frontend/src/utils/locales/ru.ts b/frontend/src/utils/locales/ru.ts index e59be92..adcce4a 100644 --- a/frontend/src/utils/locales/ru.ts +++ b/frontend/src/utils/locales/ru.ts @@ -72,6 +72,7 @@ export const ru = { debugMode: "Режим отладки", debugModeDescription: "Показать или скрыть сообщения консоли (требуется обновление)", + pauseOnFocusLoss: "Пауза при потере фокуса", tagsManagement: "Управление тегами", newTag: "Новый тег", tags: "Теги", diff --git a/frontend/src/utils/locales/zh.ts b/frontend/src/utils/locales/zh.ts index f088311..40ce542 100644 --- a/frontend/src/utils/locales/zh.ts +++ b/frontend/src/utils/locales/zh.ts @@ -58,6 +58,7 @@ export const zh = { settingsFailed: "保存设置失败", debugMode: "调试模式", debugModeDescription: "显示或隐藏控制台消息(需要刷新)", + pauseOnFocusLoss: "失去焦点时暂停", tagsManagement: "标签管理", newTag: "新标签", tags: "标签",