Merge branch 'dev' into fix/1078-puid-pgid-postgres-conflict

This commit is contained in:
Matt Grutza 2026-03-12 20:27:07 -05:00 committed by GitHub
commit 4cc460fe06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 437 additions and 52 deletions

View file

@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Non-admin users see `Channels`, `TV Guide`, and `Settings`, with the `Settings` item not hideable.
- 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 improvements
- **Title display**: The channel, stream, or VOD title is now shown 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.
- **Persistent state**: Size, position, volume level, and mute state are now saved 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)
@ -48,11 +50,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
<<<<<<< fix/1078-puid-pgid-postgres-conflict
- Container startup failure when `PUID`/`PGID` is set, caused by `/data/db` ownership conflicts between the `postgres` system user (UID 102) and the configured PUID/PGID. PostgreSQL now runs as the PUID/PGID user in AIO mode, eliminating all `chown`-to-UID-102 operations and unifying `/data` ownership. (Fixes #1078)
- Existing installations where PUID/PGID differs from the current `/data/db` owner are migrated automatically on first startup; a sentinel file prevents redundant recursive `chown` on subsequent boots.
- PUID/PGID auto-detected from existing data ownership when not explicitly set, avoiding cross-UID `chown` failures on restricted filesystems (NFS `root_squash`, CIFS).
- PUID/PGID validated as positive non-zero integers before any user/group operations.
- UID collisions with the `postgres` system user (e.g. PUID=102) are now handled gracefully.
=======
- Floating video player bug fixes
- **Resize stuck after releasing mouse outside window**: The `mouseup` event is not delivered when the pointer leaves the viewport, leaving the `mousemove` listener active indefinitely. Fixed by checking `event.buttons === 0` at the top of `handleResizeMove`; when no button is held the resize session is torn down immediately.
- **Drag stuck after releasing mouse outside window**: Same root cause as the resize bug. Fixed by detecting `event.buttons === 0` in the `onDrag` handler and dispatching a synthetic `mouseup` event so react-draggable cleanly ends the drag session.
- **Player draggable off screen**: The player could be dragged off any edge, making the header (and drag handle) unreachable. The player is now fully bounded: left and top edges are clamped to `x ≥ 0` / `y ≥ 0` so the header is always reachable, and right/bottom edges are clamped to the viewport. Size and position are also re-clamped automatically when the browser window is resized, with proportional scale-down if the saved size exceeds the new viewport.
>>>>>>> dev
- **Security**: Any authenticated user could self-escalate privileges by sending `user_level`, `is_staff`, or `is_superuser` in a `PATCH /api/accounts/users/me/` request. The `/me/` endpoint now enforces an allowlist (`custom_properties`, `first_name`, `last_name`, `email`, `password`); any other fields return HTTP 400. Privilege-sensitive fields can only be changed via the admin-only `PATCH /api/accounts/users/{id}/` endpoint.
- Double error notification when saving user preferences: `API.updateMe` was catching errors internally and displaying a notification before re-throwing, causing callers to display a second notification for the same failure.
- Navigation preference saves from concurrent sessions could overwrite each other due to a double-merge race: the frontend was pre-merging `custom_properties` before sending, then the backend merged again against the DB value, causing the second session's write to silently drop keys set by the first. The frontend now sends only the delta; the backend merges authoritatively against the stored value.

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);
@ -125,11 +127,34 @@ export default function FloatingVideo() {
const dragPositionRef = useRef(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const initialPositionRef = useRef(null);
// Ref kept in sync with videoSize state for use inside event handlers
// where closures over state would be stale.
const videoSizeRef = useRef(null);
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();
const saved = prefs.size;
if (saved?.width >= 220 && saved?.height >= 124) {
if (typeof window !== 'undefined') {
// Cap to viewport minus a margin so the header is always reachable on
// first render even if the saved size was set on a larger display.
const maxW = window.innerWidth - 48; // VISIBLE_MARGIN
const maxH = window.innerHeight - 83; // HEADER_HEIGHT(38) + VISIBLE_MARGIN(48) - 1 extra row
if (saved.width > maxW || saved.height > maxH) {
const scale = Math.min(maxW / saved.width, maxH / saved.height);
return {
width: Math.max(220, Math.round(saved.width * scale)),
height: Math.max(124, Math.round(saved.height * scale)),
};
}
}
return saved;
}
return { width: 320, height: 180 };
});
const [isResizing, setIsResizing] = useState(false);
const [dragPosition, setDragPosition] = useState(null);
@ -208,7 +233,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 +327,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 +400,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]);
@ -389,23 +429,19 @@ export default function FloatingVideo() {
if (typeof window === 'undefined') return { x, y };
const totalHeight = videoSize.height + HEADER_HEIGHT + ERROR_HEIGHT;
const minX = -(videoSize.width - VISIBLE_MARGIN);
const minY = -(totalHeight - VISIBLE_MARGIN);
const maxX = window.innerWidth - videoSize.width;
const maxY = window.innerHeight - totalHeight;
const minX = 0;
// minY = 0 ensures the header row is always within the viewport so the
// user can always grab and reposition the window.
const minY = 0;
const maxX = Math.max(0, window.innerWidth - videoSize.width);
const maxY = Math.max(0, window.innerHeight - totalHeight);
return {
x: Math.min(Math.max(x, minX), maxX),
y: Math.min(Math.max(y, minY), maxY),
};
},
[
VISIBLE_MARGIN,
HEADER_HEIGHT,
ERROR_HEIGHT,
videoSize.height,
videoSize.width,
]
[HEADER_HEIGHT, ERROR_HEIGHT, videoSize.height, videoSize.width]
);
const clampToVisibleWithSize = useCallback(
@ -413,23 +449,34 @@ export default function FloatingVideo() {
if (typeof window === 'undefined') return { x, y };
const totalHeight = height + HEADER_HEIGHT + ERROR_HEIGHT;
const minX = -(width - VISIBLE_MARGIN);
const minY = -(totalHeight - VISIBLE_MARGIN);
const maxX = window.innerWidth - width;
const maxY = window.innerHeight - totalHeight;
const minX = 0; // left edge must stay in viewport
const minY = 0; // header must always be reachable
const maxX = Math.max(0, window.innerWidth - width);
const maxY = Math.max(0, window.innerHeight - totalHeight);
return {
x: Math.min(Math.max(x, minX), maxX),
y: Math.min(Math.max(y, minY), maxY),
};
},
[VISIBLE_MARGIN, HEADER_HEIGHT, ERROR_HEIGHT]
[HEADER_HEIGHT, ERROR_HEIGHT]
);
const handleResizeMove = useCallback(
(event) => {
if (!resizeStateRef.current) return;
// If the mouse button was released outside the window, stop resizing.
// Remove the move listeners immediately; the mouseup/touchend listener
// (endResize) will clean itself up the next time the user clicks.
if (event.type === 'mousemove' && event.buttons === 0) {
resizeStateRef.current = null;
setIsResizing(false);
window.removeEventListener('mousemove', handleResizeMove);
window.removeEventListener('touchmove', handleResizeMove);
return;
}
const { clientX, clientY } = getClientCoordinates(event);
const {
startX,
@ -478,7 +525,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 +539,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 +605,85 @@ export default function FloatingVideo() {
dragPositionRef.current = dragPosition;
}, [dragPosition]);
// Keep videoSizeRef current so the window-resize handler never closes over
// a stale videoSize value.
useEffect(() => {
videoSizeRef.current = videoSize;
}, [videoSize]);
// Re-clamp size and position when the browser viewport changes
useEffect(() => {
const handleWindowResize = () => {
const maxW = window.innerWidth - VISIBLE_MARGIN;
const maxH = window.innerHeight - VISIBLE_MARGIN;
const { width: curW, height: curH } = videoSizeRef.current ?? {
width: 320,
height: 180,
};
let newW = curW;
let newH = curH;
if (curW > maxW || curH > maxH) {
const scale = Math.min(maxW / curW, maxH / curH);
newW = Math.max(MIN_WIDTH, Math.round(curW * scale));
newH = Math.max(MIN_HEIGHT, Math.round(curH * scale));
setVideoSize({ width: newW, height: newH });
}
if (dragPositionRef.current) {
const totalH = newH + HEADER_HEIGHT + ERROR_HEIGHT;
const clamped = {
x: Math.min(
Math.max(dragPositionRef.current.x, 0),
Math.max(0, window.innerWidth - newW)
),
y: Math.min(
Math.max(dragPositionRef.current.y, 0),
Math.max(0, window.innerHeight - totalH)
),
};
if (
clamped.x !== dragPositionRef.current.x ||
clamped.y !== dragPositionRef.current.y
) {
setDragPosition(clamped);
dragPositionRef.current = clamped;
}
}
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, [MIN_WIDTH, MIN_HEIGHT, VISIBLE_MARGIN, HEADER_HEIGHT, ERROR_HEIGHT]);
// Persist size whenever it changes, but skip high-frequency writes during
// an active resize drag the final value is captured when isResizing clears.
useEffect(() => {
if (!isResizing) {
savePlayerPrefs({ size: videoSize });
}
}, [videoSize, isResizing]);
// 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);
@ -591,6 +724,13 @@ export default function FloatingVideo() {
const handleDrag = useCallback(
(event) => {
// If the mouse button was released outside the browser window the
// mouseup event is never delivered to react-draggable. Detect this on
// the next mousemove and fire a synthetic mouseup so it stops dragging.
if (event.type === 'mousemove' && event.buttons === 0) {
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
return;
}
const clientX = event.touches?.[0]?.clientX ?? event.clientX;
const clientY = event.touches?.[0]?.clientY ?? event.clientY;
if (clientX == null || clientY == null) return;
@ -609,6 +749,7 @@ export default function FloatingVideo() {
const clamped = clampToVisible(data?.x ?? 0, data?.y ?? 0);
setDragPosition(clamped);
dragPositionRef.current = clamped;
savePlayerPrefs({ position: clamped });
},
[clampToVisible]
);
@ -650,6 +791,8 @@ export default function FloatingVideo() {
style={{
padding: '3px 3px 3px 8px',
minHeight: '38px',
cursor: 'grab',
userSelect: 'none',
}}
>
{metadata?.name ? (
@ -801,7 +944,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
@ -98,11 +117,19 @@ export const applyConstraints = (
// Apply viewport constraints
const posX = startPos?.x ?? 0;
const posY = startPos?.y ?? 0;
// Absolute caps ensure the player can never exceed the viewport even when
// its position is negative (partially off-screen on the opposite edge).
const maxWidth = !handle.isLeft
? Math.max(minWidth, window.innerWidth - posX - visibleMargin)
? Math.min(
window.innerWidth - visibleMargin,
Math.max(minWidth, window.innerWidth - posX - visibleMargin)
)
: null;
const maxHeight = !handle.isTop
? Math.max(minHeight, window.innerHeight - posY - visibleMargin)
? Math.min(
window.innerHeight - visibleMargin,
Math.max(minHeight, window.innerHeight - posY - visibleMargin)
)
: null;
if (maxWidth && width > maxWidth) {

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,82 @@ 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 cap maximum width at viewport width when position is negative', () => {
// Use a tall viewport so only the width constraint is the binding factor
window.innerHeight = 3000;
const handle = { isLeft: false, isTop: false };
const startPos = { x: -200, y: 0 };
const result = applyConstraints(
2000,
1125,
ratio,
startPos,
handle,
minWidth,
minHeight,
visibleMargin
);
// Without the fix: maxWidth would be 1920 - (-200) - 50 = 2070 (exceeds viewport)
// With the fix: capped at window.innerWidth - visibleMargin = 1870
const expectedWidth = 1920 - 50;
expect(result.width).toBe(expectedWidth);
expect(result.height).toBeCloseTo(expectedWidth / ratio, 1);
});
it('should cap maximum height at viewport height when position is negative', () => {
// Use a wide viewport so only the height constraint is the binding factor.
// Input height (1100) is above the absolute cap (1080-50=1030) but below the
// uncapped formula (1080-(-100)-50=1130), proving the absolute cap is enforced.
window.innerWidth = 4000;
const handle = { isLeft: false, isTop: false };
const startPos = { x: 0, y: -100 };
const inputHeight = 1100;
const result = applyConstraints(
inputHeight * ratio,
inputHeight,
ratio,
startPos,
handle,
minWidth,
minHeight,
visibleMargin
);
// Without the fix: maxHeight would be 1080 - (-100) - 50 = 1130, so 1100 < 1130 → no cap
// With the fix: capped at window.innerHeight - visibleMargin = 1030
const expectedHeight = 1080 - 50;
expect(result.height).toBe(expectedHeight);
expect(result.width).toBeCloseTo(expectedHeight * 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 +352,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 +369,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();
});
});
});