Enhancement: Floating video player now persists size, position, volume level, and mute state across sessions using a single dispatcharr-player-prefs localStorage key. Size and position are restored on next open (clamped to the current viewport); volume and mute are restored when the player initialises.

This commit is contained in:
SergeantPanda 2026-03-12 16:10:20 -05:00
parent 0bc4c40400
commit dbe409d769
4 changed files with 269 additions and 34 deletions

View file

@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. — Thanks [@nick4810](https://github.com/nick4810)
- Unit tests for DVR port resolution (`build_dvr_candidates`) and selective Redis flush behavior in modular mode. — Thanks [@CodeBormen](https://github.com/CodeBormen)
- Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal.
- Floating video player now persists size, position, volume level, and mute state across sessions using a single `dispatcharr-player-prefs` localStorage key. Size and position are restored on next open (clamped to the current viewport); volume and mute are restored when the player initialises.
- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)".
- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012)
- Sort icons added to the Group and EPG column headers in the Channels table, and to the Group column header in the Streams table. Clicking a sort icon cycles through ascending/descending/unsorted states. EPG sorting required a backend change (`epg_data__name` added to `ChannelViewSet.ordering_fields`); Group sorting was already supported by the API in both tables. (Closes #854) — Thanks [@CodeBormen](https://github.com/CodeBormen)

View file

@ -10,6 +10,8 @@ import {
getClientCoordinates,
getLivePlayerErrorMessage,
getVODPlayerErrorMessage,
getPlayerPrefs,
savePlayerPrefs,
} from '../utils/components/FloatingVideoUtils.js';
const ResizeHandles = ({ startResize }) => {
@ -107,7 +109,7 @@ const ResizeHandles = ({ startResize }) => {
))}
</>
);
}
};
export default function FloatingVideo() {
const isVisible = useVideoStore((s) => s.isVisible);
@ -129,7 +131,12 @@ export default function FloatingVideo() {
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(null);
const [showOverlay, setShowOverlay] = useState(true);
const [videoSize, setVideoSize] = useState({ width: 320, height: 180 });
const [videoSize, setVideoSize] = useState(() => {
const prefs = getPlayerPrefs();
if (prefs.size?.width >= 220 && prefs.size?.height >= 124)
return prefs.size;
return { width: 320, height: 180 };
});
const [isResizing, setIsResizing] = useState(false);
const [dragPosition, setDragPosition] = useState(null);
@ -208,7 +215,10 @@ export default function FloatingVideo() {
video.preload = 'metadata';
video.crossOrigin = 'anonymous';
// Set up event listeners
// Restore saved volume
const { volume: savedVolume, muted: savedMuted } = getPlayerPrefs();
if (typeof savedVolume === 'number') video.volume = savedVolume;
if (typeof savedMuted === 'boolean') video.muted = savedMuted;
const handleLoadStart = () => setIsLoading(true);
const handleLoadedData = () => setIsLoading(false);
const handleCanPlay = () => {
@ -299,6 +309,12 @@ export default function FloatingVideo() {
player.attachMediaElement(videoRef.current);
// Restore saved volume
const { volume: savedVolume, muted: savedMuted } = getPlayerPrefs();
if (typeof savedVolume === 'number')
videoRef.current.volume = savedVolume;
if (typeof savedMuted === 'boolean') videoRef.current.muted = savedMuted;
player.on(mpegts.Events.LOADING_COMPLETE, () => {
setIsLoading(false);
});
@ -366,8 +382,14 @@ export default function FloatingVideo() {
initializeLivePlayer();
}
// Cleanup when component unmounts or streamUrl changes
// Attach volume-change listener now that the video element is in the DOM
const video = videoRef.current;
const handleVolumeChange = () =>
savePlayerPrefs({ volume: video.volume, muted: video.muted });
video?.addEventListener('volumechange', handleVolumeChange);
return () => {
video?.removeEventListener('volumechange', handleVolumeChange);
safeDestroyPlayer();
};
}, [isVisible, streamUrl, contentType]);
@ -478,7 +500,13 @@ export default function FloatingVideo() {
[MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN, clampToVisibleWithSize]
);
const updatePositionIfNeeded = (handle, startPos, startWidth, startHeight, newSize) => {
const updatePositionIfNeeded = (
handle,
startPos,
startWidth,
startHeight,
newSize
) => {
if (!handle.isLeft && !handle.isTop) return;
const posX = startPos?.x ?? 0;
@ -486,7 +514,12 @@ export default function FloatingVideo() {
let nextX = handle.isLeft ? posX + (startWidth - newSize.width) : posX;
let nextY = handle.isTop ? posY + (startHeight - newSize.height) : posY;
const clamped = clampToVisibleWithSize(nextX, nextY, newSize.width, newSize.height);
const clamped = clampToVisibleWithSize(
nextX,
nextY,
newSize.width,
newSize.height
);
const nextPos = {
x: handle.isLeft ? clamped.x : nextX,
y: handle.isTop ? clamped.y : nextY,
@ -547,10 +580,32 @@ export default function FloatingVideo() {
dragPositionRef.current = dragPosition;
}, [dragPosition]);
// Persist size whenever it changes
useEffect(() => {
savePlayerPrefs({ size: videoSize });
}, [videoSize]);
// Persist position when a resize ends
useEffect(() => {
if (!isResizing && dragPositionRef.current) {
savePlayerPrefs({ position: dragPositionRef.current });
}
}, [isResizing]);
// Initialize the floating window near bottom-right once
useEffect(() => {
if (initialPositionRef.current || typeof window === 'undefined') return;
// Try to restore the last saved position
const savedPos = getPlayerPrefs().position;
if (savedPos) {
const clamped = clampToVisible(savedPos.x, savedPos.y);
initialPositionRef.current = clamped;
setDragPosition(clamped);
dragPositionRef.current = clamped;
return;
}
const totalHeight = videoSize.height + HEADER_HEIGHT + ERROR_HEIGHT;
const initialX = Math.max(10, window.innerWidth - videoSize.width - 20);
const initialY = Math.max(10, window.innerHeight - totalHeight - 20);
@ -609,6 +664,7 @@ export default function FloatingVideo() {
const clamped = clampToVisible(data?.x ?? 0, data?.y ?? 0);
setDragPosition(clamped);
dragPositionRef.current = clamped;
savePlayerPrefs({ position: clamped });
},
[clampToVisible]
);
@ -801,7 +857,7 @@ export default function FloatingVideo() {
</Box>
)}
<ResizeHandles startResize={startResize}/>
<ResizeHandles startResize={startResize} />
</div>
</Draggable>
);

View file

@ -1,3 +1,22 @@
export const PLAYER_PREFS_KEY = 'dispatcharr-player-prefs';
export const getPlayerPrefs = () => {
try {
return JSON.parse(localStorage.getItem(PLAYER_PREFS_KEY) || '{}');
} catch {
return {};
}
};
export const savePlayerPrefs = (updates) => {
try {
localStorage.setItem(
PLAYER_PREFS_KEY,
JSON.stringify({ ...getPlayerPrefs(), ...updates })
);
} catch {}
};
export const getLivePlayerErrorMessage = (errorType, errorDetail) => {
if (errorType !== 'MediaError') {
return errorDetail

View file

@ -1,26 +1,34 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
getLivePlayerErrorMessage,
getVODPlayerErrorMessage,
getClientCoordinates,
calculateNewDimensions,
applyConstraints,
PLAYER_PREFS_KEY,
getPlayerPrefs,
savePlayerPrefs,
} from '../FloatingVideoUtils';
describe('FloatingVideoUtils', () => {
describe('getLivePlayerErrorMessage', () => {
it('should return formatted error for non-MediaError types', () => {
expect(getLivePlayerErrorMessage('NetworkError', 'Connection failed')).toBe(
'Error: NetworkError - Connection failed'
);
expect(
getLivePlayerErrorMessage('NetworkError', 'Connection failed')
).toBe('Error: NetworkError - Connection failed');
});
it('should return error type only when no detail provided', () => {
expect(getLivePlayerErrorMessage('NetworkError')).toBe('Error: NetworkError');
expect(getLivePlayerErrorMessage('NetworkError')).toBe(
'Error: NetworkError'
);
});
it('should return audio codec message for audio-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'audio codec not supported');
const result = getLivePlayerErrorMessage(
'MediaError',
'audio codec not supported'
);
expect(result).toBe(
'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'
);
@ -32,21 +40,30 @@ describe('FloatingVideoUtils', () => {
});
it('should return video codec message for video-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'video codec h264 failed');
const result = getLivePlayerErrorMessage(
'MediaError',
'video codec h264 failed'
);
expect(result).toBe(
'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'
);
});
it('should return MSE message for MSE-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'MSE not supported');
const result = getLivePlayerErrorMessage(
'MediaError',
'MSE not supported'
);
expect(result).toBe(
"Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."
);
});
it('should return generic codec message for other MediaError cases', () => {
const result = getLivePlayerErrorMessage('MediaError', 'unknown codec issue');
const result = getLivePlayerErrorMessage(
'MediaError',
'unknown codec issue'
);
expect(result).toBe(
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
);
@ -67,22 +84,30 @@ describe('FloatingVideoUtils', () => {
it('should return aborted message for MEDIA_ERR_ABORTED', () => {
const error = { code: 1, MEDIA_ERR_ABORTED: 1 };
expect(getVODPlayerErrorMessage(error)).toBe('Video playback was aborted');
expect(getVODPlayerErrorMessage(error)).toBe(
'Video playback was aborted'
);
});
it('should return network message for MEDIA_ERR_NETWORK', () => {
const error = { code: 2, MEDIA_ERR_NETWORK: 2 };
expect(getVODPlayerErrorMessage(error)).toBe('Network error while loading video');
expect(getVODPlayerErrorMessage(error)).toBe(
'Network error while loading video'
);
});
it('should return codec message for MEDIA_ERR_DECODE', () => {
const error = { code: 3, MEDIA_ERR_DECODE: 3 };
expect(getVODPlayerErrorMessage(error)).toBe('Video codec not supported by your browser');
expect(getVODPlayerErrorMessage(error)).toBe(
'Video codec not supported by your browser'
);
});
it('should return format message for MEDIA_ERR_SRC_NOT_SUPPORTED', () => {
const error = { code: 4, MEDIA_ERR_SRC_NOT_SUPPORTED: 4 };
expect(getVODPlayerErrorMessage(error)).toBe('Video format not supported by your browser');
expect(getVODPlayerErrorMessage(error)).toBe(
'Video format not supported by your browser'
);
});
it('should return error message for unknown error codes', () => {
@ -99,12 +124,18 @@ describe('FloatingVideoUtils', () => {
describe('getClientCoordinates', () => {
it('should extract coordinates from mouse event', () => {
const event = { clientX: 100, clientY: 200 };
expect(getClientCoordinates(event)).toEqual({ clientX: 100, clientY: 200 });
expect(getClientCoordinates(event)).toEqual({
clientX: 100,
clientY: 200,
});
});
it('should extract coordinates from touch event', () => {
const event = { touches: [{ clientX: 150, clientY: 250 }] };
expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
expect(getClientCoordinates(event)).toEqual({
clientX: 150,
clientY: 250,
});
});
it('should prioritize touch coordinates over mouse coordinates', () => {
@ -113,12 +144,18 @@ describe('FloatingVideoUtils', () => {
clientX: 100,
clientY: 200,
};
expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
expect(getClientCoordinates(event)).toEqual({
clientX: 150,
clientY: 250,
});
});
it('should handle undefined coordinates', () => {
const event = {};
expect(getClientCoordinates(event)).toEqual({ clientX: undefined, clientY: undefined });
expect(getClientCoordinates(event)).toEqual({
clientX: undefined,
clientY: undefined,
});
});
});
@ -165,13 +202,28 @@ describe('FloatingVideoUtils', () => {
const visibleMargin = 50;
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
Object.defineProperty(window, 'innerWidth', {
writable: true,
value: 1920,
});
Object.defineProperty(window, 'innerHeight', {
writable: true,
value: 1080,
});
});
it('should apply minimum width constraint', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(100, 50, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
const result = applyConstraints(
100,
50,
ratio,
{ x: 0, y: 0 },
handle,
minWidth,
minHeight,
visibleMargin
);
expect(result.width).toBe(minWidth);
expect(result.height).toBeCloseTo(minWidth / ratio, 1);
@ -179,7 +231,16 @@ describe('FloatingVideoUtils', () => {
it('should apply minimum height constraint', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(300, 100, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
const result = applyConstraints(
300,
100,
ratio,
{ x: 0, y: 0 },
handle,
minWidth,
minHeight,
visibleMargin
);
expect(result.height).toBe(minHeight);
expect(result.width).toBeCloseTo(minHeight * ratio, 1);
@ -188,7 +249,16 @@ describe('FloatingVideoUtils', () => {
it('should apply maximum width constraint based on viewport', () => {
const handle = { isLeft: false, isTop: false };
const startPos = { x: 1200, y: 100 };
const result = applyConstraints(800, 450, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const result = applyConstraints(
800,
450,
ratio,
startPos,
handle,
minWidth,
minHeight,
visibleMargin
);
const maxWidth = 1920 - 1200 - 50; // 670
expect(result.width).toBe(maxWidth);
@ -198,18 +268,35 @@ describe('FloatingVideoUtils', () => {
it('should apply maximum height constraint based on viewport', () => {
const handle = { isLeft: false, isTop: false };
const startPos = { x: 100, y: 700 };
const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const result = applyConstraints(
500,
400,
ratio,
startPos,
handle,
minWidth,
minHeight,
visibleMargin
);
const maxHeight = 1080 - 700 - 50; // 330
expect(result.height).toBe(maxHeight);
expect(result.width).toBeCloseTo(maxHeight * ratio, 1);
});
it('should not apply max width constraint for left handle', () => {
const handle = { isLeft: true, isTop: false };
const startPos = { x: 1800, y: 100 };
const result = applyConstraints(500, 281.25, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const result = applyConstraints(
500,
281.25,
ratio,
startPos,
handle,
minWidth,
minHeight,
visibleMargin
);
expect(result.width).toBe(500);
expect(result.height).toBeCloseTo(281.25, 1);
@ -218,7 +305,16 @@ describe('FloatingVideoUtils', () => {
it('should not apply max height constraint for top handle', () => {
const handle = { isLeft: false, isTop: true };
const startPos = { x: 100, y: 1000 };
const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const result = applyConstraints(
500,
400,
ratio,
startPos,
handle,
minWidth,
minHeight,
visibleMargin
);
expect(result.width).toBe(500);
expect(result.height).toBe(400);
@ -226,10 +322,73 @@ describe('FloatingVideoUtils', () => {
it('should handle null startPos', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(300, 168.75, ratio, null, handle, minWidth, minHeight, visibleMargin);
const result = applyConstraints(
300,
168.75,
ratio,
null,
handle,
minWidth,
minHeight,
visibleMargin
);
expect(result.width).toBe(300);
expect(result.height).toBeCloseTo(168.75, 1);
});
});
describe('getPlayerPrefs / savePlayerPrefs', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('should return an empty object when nothing is stored', () => {
expect(getPlayerPrefs()).toEqual({});
});
it('should return an empty object when stored value is invalid JSON', () => {
localStorage.setItem(PLAYER_PREFS_KEY, 'not-json');
expect(getPlayerPrefs()).toEqual({});
});
it('should save and retrieve a volume value', () => {
savePlayerPrefs({ volume: 0.5 });
expect(getPlayerPrefs().volume).toBe(0.5);
});
it('should save and retrieve a muted value', () => {
savePlayerPrefs({ muted: true });
expect(getPlayerPrefs().muted).toBe(true);
});
it('should save and retrieve size', () => {
savePlayerPrefs({ size: { width: 640, height: 360 } });
expect(getPlayerPrefs().size).toEqual({ width: 640, height: 360 });
});
it('should save and retrieve position', () => {
savePlayerPrefs({ position: { x: 100, y: 200 } });
expect(getPlayerPrefs().position).toEqual({ x: 100, y: 200 });
});
it('should merge updates without losing existing keys', () => {
savePlayerPrefs({ volume: 0.8, muted: false });
savePlayerPrefs({ size: { width: 320, height: 180 } });
const prefs = getPlayerPrefs();
expect(prefs.volume).toBe(0.8);
expect(prefs.muted).toBe(false);
expect(prefs.size).toEqual({ width: 320, height: 180 });
});
it('should overwrite an existing key on update', () => {
savePlayerPrefs({ volume: 0.5 });
savePlayerPrefs({ volume: 1.0 });
expect(getPlayerPrefs().volume).toBe(1.0);
});
it('should use PLAYER_PREFS_KEY as the storage key', () => {
savePlayerPrefs({ volume: 0.7 });
expect(localStorage.getItem(PLAYER_PREFS_KEY)).not.toBeNull();
});
});
});