feat: Add pause on focus loss functionality

This commit is contained in:
Peifan Li
2025-12-29 16:42:35 -05:00
parent 67ca62aa75
commit a664baf7e7
18 changed files with 194 additions and 6 deletions

View File

@@ -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,
};

View File

@@ -24,6 +24,15 @@ const VideoDefaultSettings: React.FC<VideoDefaultSettingsProps> = ({ settings, o
}
label={t('autoPlay')}
/>
<FormControlLabel
control={
<Switch
checked={settings.pauseOnFocusLoss || false}
onChange={(e) => onChange('pauseOnFocusLoss', e.target.checked)}
/>
}
label={t('pauseOnFocusLoss') || "Pause video when window loses focus"}
/>
</Box>
</Box>
);

View File

@@ -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<HTMLVideoElement>;
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();
});
});

View File

@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react';
export const useFocusPause = (
videoRef: React.RefObject<HTMLVideoElement | null>,
enabled: boolean
) => {
// Track if the video was playing when we lost focus
const wasPlayingRef = useRef<boolean>(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]);
};

View File

@@ -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<VideoControlsProps> = ({
src,
autoPlay = false,
autoLoop = false,
pauseOnFocusLoss = false,
onTimeUpdate,
onLoadedMetadata,
startTime = 0,
@@ -48,6 +51,9 @@ const VideoControls: React.FC<VideoControlsProps> = ({
onLoadedMetadata
});
// Auto-pause on focus loss
useFocusPause(videoPlayer.videoRef, pauseOnFocusLoss);
// Fullscreen management
const fullscreen = useFullscreen(videoPlayer.videoRef);

View File

@@ -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

View File

@@ -86,7 +86,8 @@ const VideoPlayer: React.FC = () => {
subtitlesEnabled,
availableTags,
handleSubtitlesToggle,
handleLoopToggle
handleLoopToggle,
pauseOnFocusLoss
} = useVideoPlayerSettings();
const autoPlay = autoPlayNext || settingsAutoPlay;
@@ -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}

View File

@@ -84,4 +84,5 @@ export interface Settings {
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;
cloudflaredToken?: string;
pauseOnFocusLoss?: boolean;
}

View File

@@ -58,6 +58,7 @@ export const ar = {
settingsFailed: "فشل حفظ الإعدادات",
debugMode: "وضع التصحيح",
debugModeDescription: "إظهار أو إخفاء رسائل وحدة التحكم (يتطلب التحديث)",
pauseOnFocusLoss: "إيقاف مؤقت عند فقدان التركيز",
tagsManagement: "إدارة العلامات",
newTag: "علامة جديدة",
tags: "العلامات",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -63,6 +63,7 @@ export const ja = {
debugMode: "デバッグモード",
debugModeDescription:
"コンソールメッセージを表示または非表示にします(更新が必要)",
pauseOnFocusLoss: "フォーカス喪失時に一時停止",
tagsManagement: "タグ管理",
newTag: "新しいタグ",
tags: "タグ",

View File

@@ -60,6 +60,7 @@ export const ko = {
settingsFailed: "설정 저장 실패",
debugMode: "디버그 모드",
debugModeDescription: "콘솔 메시지 표시 또는 숨기기 (새로 고침 필요)",
pauseOnFocusLoss: "포커스 손실 시 일시 중지",
tagsManagement: "태그 관리",
newTag: "새 태그",
tags: "태그",

View File

@@ -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",

View File

@@ -72,6 +72,7 @@ export const ru = {
debugMode: "Режим отладки",
debugModeDescription:
"Показать или скрыть сообщения консоли (требуется обновление)",
pauseOnFocusLoss: "Пауза при потере фокуса",
tagsManagement: "Управление тегами",
newTag: "Новый тег",
tags: "Теги",

View File

@@ -58,6 +58,7 @@ export const zh = {
settingsFailed: "保存设置失败",
debugMode: "调试模式",
debugModeDescription: "显示或隐藏控制台消息(需要刷新)",
pauseOnFocusLoss: "失去焦点时暂停",
tagsManagement: "标签管理",
newTag: "新标签",
tags: "标签",