feat: Add pause on focus loss functionality
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -84,4 +84,5 @@ export interface Settings {
|
||||
videoColumns?: number;
|
||||
cloudflaredTunnelEnabled?: boolean;
|
||||
cloudflaredToken?: string;
|
||||
pauseOnFocusLoss?: boolean;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ar = {
|
||||
settingsFailed: "فشل حفظ الإعدادات",
|
||||
debugMode: "وضع التصحيح",
|
||||
debugModeDescription: "إظهار أو إخفاء رسائل وحدة التحكم (يتطلب التحديث)",
|
||||
pauseOnFocusLoss: "إيقاف مؤقت عند فقدان التركيز",
|
||||
tagsManagement: "إدارة العلامات",
|
||||
newTag: "علامة جديدة",
|
||||
tags: "العلامات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const ja = {
|
||||
debugMode: "デバッグモード",
|
||||
debugModeDescription:
|
||||
"コンソールメッセージを表示または非表示にします(更新が必要)",
|
||||
pauseOnFocusLoss: "フォーカス喪失時に一時停止",
|
||||
tagsManagement: "タグ管理",
|
||||
newTag: "新しいタグ",
|
||||
tags: "タグ",
|
||||
|
||||
@@ -60,6 +60,7 @@ export const ko = {
|
||||
settingsFailed: "설정 저장 실패",
|
||||
debugMode: "디버그 모드",
|
||||
debugModeDescription: "콘솔 메시지 표시 또는 숨기기 (새로 고침 필요)",
|
||||
pauseOnFocusLoss: "포커스 손실 시 일시 중지",
|
||||
tagsManagement: "태그 관리",
|
||||
newTag: "새 태그",
|
||||
tags: "태그",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -72,6 +72,7 @@ export const ru = {
|
||||
debugMode: "Режим отладки",
|
||||
debugModeDescription:
|
||||
"Показать или скрыть сообщения консоли (требуется обновление)",
|
||||
pauseOnFocusLoss: "Пауза при потере фокуса",
|
||||
tagsManagement: "Управление тегами",
|
||||
newTag: "Новый тег",
|
||||
tags: "Теги",
|
||||
|
||||
@@ -58,6 +58,7 @@ export const zh = {
|
||||
settingsFailed: "保存设置失败",
|
||||
debugMode: "调试模式",
|
||||
debugModeDescription: "显示或隐藏控制台消息(需要刷新)",
|
||||
pauseOnFocusLoss: "失去焦点时暂停",
|
||||
tagsManagement: "标签管理",
|
||||
newTag: "新标签",
|
||||
tags: "标签",
|
||||
|
||||
Reference in New Issue
Block a user