diff --git a/CHANGELOG.md b/CHANGELOG.md
index d745ab6f..d5532173 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Update docker/dev-build.sh to support private registries, multiple architectures and pushing. Now you can do things like `dev-build.sh -p -r my.private.registry -a linux/arm64,linux/amd64` - Thanks [@jdblack](https://github.com/jblack)
- Updated dependencies: Django (5.2.4 → 5.2.9) includes CVE security patch, psycopg2-binary (2.9.10 → 2.9.11), celery (5.5.3 → 5.6.0), djangorestframework (3.16.0 → 3.16.1), requests (2.32.4 → 2.32.5), psutil (7.0.0 → 7.1.3), gevent (25.5.1 → 25.9.1), rapidfuzz (3.13.0 → 3.14.3), torch (2.7.1 → 2.9.1), sentence-transformers (5.1.0 → 5.2.0), lxml (6.0.0 → 6.0.2) (Closes #662)
- Frontend dependencies updated: Vite (6.2.0 → 7.1.7), ESLint (9.21.0 → 9.27.0), and related packages; added npm `overrides` to enforce js-yaml@^4.1.1 for transitive security fix. All 6 reported vulnerabilities resolved with `npm audit fix`.
+- Floating video player now supports resizing via a drag handles, with minimum size enforcement and viewport/page boundary constraints to keep it visible.
### Fixed
diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx
index 611d7e2a..857f67aa 100644
--- a/frontend/src/components/FloatingVideo.jsx
+++ b/frontend/src/components/FloatingVideo.jsx
@@ -30,6 +30,79 @@ export default function FloatingVideo() {
const MIN_WIDTH = 220;
const MIN_HEIGHT = 124;
const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging
+ const HANDLE_SIZE = 18;
+ const HANDLE_OFFSET = 0;
+ const resizeHandleBaseStyle = {
+ position: 'absolute',
+ width: HANDLE_SIZE,
+ height: HANDLE_SIZE,
+ backgroundColor: 'transparent',
+ borderRadius: 6,
+ zIndex: 8,
+ touchAction: 'none',
+ };
+ const resizeHandles = [
+ {
+ id: 'bottom-right',
+ cursor: 'nwse-resize',
+ xDir: 1,
+ yDir: 1,
+ isLeft: false,
+ isTop: false,
+ style: {
+ bottom: HANDLE_OFFSET,
+ right: HANDLE_OFFSET,
+ borderBottom: '2px solid rgba(255, 255, 255, 0.9)',
+ borderRight: '2px solid rgba(255, 255, 255, 0.9)',
+ borderRadius: '0 0 6px 0',
+ },
+ },
+ {
+ id: 'bottom-left',
+ cursor: 'nesw-resize',
+ xDir: -1,
+ yDir: 1,
+ isLeft: true,
+ isTop: false,
+ style: {
+ bottom: HANDLE_OFFSET,
+ left: HANDLE_OFFSET,
+ borderBottom: '2px solid rgba(255, 255, 255, 0.9)',
+ borderLeft: '2px solid rgba(255, 255, 255, 0.9)',
+ borderRadius: '0 0 0 6px',
+ },
+ },
+ {
+ id: 'top-right',
+ cursor: 'nesw-resize',
+ xDir: 1,
+ yDir: -1,
+ isLeft: false,
+ isTop: true,
+ style: {
+ top: HANDLE_OFFSET,
+ right: HANDLE_OFFSET,
+ borderTop: '2px solid rgba(255, 255, 255, 0.9)',
+ borderRight: '2px solid rgba(255, 255, 255, 0.9)',
+ borderRadius: '0 6px 0 0',
+ },
+ },
+ {
+ id: 'top-left',
+ cursor: 'nwse-resize',
+ xDir: -1,
+ yDir: -1,
+ isLeft: true,
+ isTop: true,
+ style: {
+ top: HANDLE_OFFSET,
+ left: HANDLE_OFFSET,
+ borderTop: '2px solid rgba(255, 255, 255, 0.9)',
+ borderLeft: '2px solid rgba(255, 255, 255, 0.9)',
+ borderRadius: '6px 0 0 0',
+ },
+ },
+ ];
// Safely destroy the mpegts player to prevent errors
const safeDestroyPlayer = () => {
@@ -344,6 +417,23 @@ export default function FloatingVideo() {
[VISIBLE_MARGIN, videoSize.height, videoSize.width]
);
+ const clampToVisibleWithSize = useCallback(
+ (x, y, width, height) => {
+ if (typeof window === 'undefined') return { x, y };
+
+ const minX = -(width - VISIBLE_MARGIN);
+ const minY = -(height - VISIBLE_MARGIN);
+ const maxX = window.innerWidth - VISIBLE_MARGIN;
+ const maxY = window.innerHeight - VISIBLE_MARGIN;
+
+ return {
+ x: Math.min(Math.max(x, minX), maxX),
+ y: Math.min(Math.max(y, minY), maxY),
+ };
+ },
+ [VISIBLE_MARGIN]
+ );
+
const handleResizeMove = useCallback(
(event) => {
if (!resizeStateRef.current) return;
@@ -353,61 +443,111 @@ export default function FloatingVideo() {
const clientY =
event.touches && event.touches.length ? event.touches[0].clientY : event.clientY;
- const deltaX = clientX - resizeStateRef.current.startX;
- const deltaY = clientY - resizeStateRef.current.startY;
- const aspectRatio = resizeStateRef.current.aspectRatio || aspectRatioRef.current;
+ const {
+ startX,
+ startY,
+ startWidth,
+ startHeight,
+ startPos,
+ handle,
+ aspectRatio,
+ } = resizeStateRef.current;
+ const deltaX = clientX - startX;
+ const deltaY = clientY - startY;
+ const widthDelta = deltaX * handle.xDir;
+ const heightDelta = deltaY * handle.yDir;
+ const ratio = aspectRatio || aspectRatioRef.current;
// Derive width/height while keeping the original aspect ratio
- let nextWidth = resizeStateRef.current.startWidth + deltaX;
- let nextHeight = nextWidth / aspectRatio;
+ let nextWidth = startWidth + widthDelta;
+ let nextHeight = nextWidth / ratio;
// Allow vertical-driven resize if the user drags mostly vertically
if (Math.abs(deltaY) > Math.abs(deltaX)) {
- nextHeight = resizeStateRef.current.startHeight + deltaY;
- nextWidth = nextHeight * aspectRatio;
+ nextHeight = startHeight + heightDelta;
+ nextWidth = nextHeight * ratio;
}
// Respect minimums while keeping the ratio
if (nextWidth < MIN_WIDTH) {
nextWidth = MIN_WIDTH;
- nextHeight = nextWidth / aspectRatio;
+ nextHeight = nextWidth / ratio;
}
if (nextHeight < MIN_HEIGHT) {
nextHeight = MIN_HEIGHT;
- nextWidth = nextHeight * aspectRatio;
+ nextWidth = nextHeight * ratio;
}
// Keep within viewport with a margin based on current position
- const posX = dragPositionRef.current?.x ?? 0;
- const posY = dragPositionRef.current?.y ?? 0;
+ const posX = startPos?.x ?? 0;
+ const posY = startPos?.y ?? 0;
const margin = VISIBLE_MARGIN;
+ let maxWidth = null;
+ let maxHeight = null;
- const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - posX - margin);
- const maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - posY - margin);
-
- if (nextWidth > maxWidth) {
- nextWidth = maxWidth;
- nextHeight = nextWidth / aspectRatio;
+ if (!handle.isLeft) {
+ maxWidth = Math.max(MIN_WIDTH, window.innerWidth - posX - margin);
}
- if (nextHeight > maxHeight) {
+ if (!handle.isTop) {
+ maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - posY - margin);
+ }
+
+ if (maxWidth != null && nextWidth > maxWidth) {
+ nextWidth = maxWidth;
+ nextHeight = nextWidth / ratio;
+ }
+
+ if (maxHeight != null && nextHeight > maxHeight) {
nextHeight = maxHeight;
- nextWidth = nextHeight * aspectRatio;
+ nextWidth = nextHeight * ratio;
}
// Final pass to honor both bounds while keeping the ratio
- if (nextWidth > maxWidth) {
+ if (maxWidth != null && nextWidth > maxWidth) {
nextWidth = maxWidth;
- nextHeight = nextWidth / aspectRatio;
+ nextHeight = nextWidth / ratio;
}
setVideoSize({
width: Math.round(nextWidth),
height: Math.round(nextHeight),
});
+
+ if (handle.isLeft || handle.isTop) {
+ let nextX = posX;
+ let nextY = posY;
+
+ if (handle.isLeft) {
+ nextX = posX + (startWidth - nextWidth);
+ }
+
+ if (handle.isTop) {
+ nextY = posY + (startHeight - nextHeight);
+ }
+
+ const clamped = clampToVisibleWithSize(
+ nextX,
+ nextY,
+ nextWidth,
+ nextHeight
+ );
+
+ if (handle.isLeft) {
+ nextX = clamped.x;
+ }
+
+ if (handle.isTop) {
+ nextY = clamped.y;
+ }
+
+ const nextPos = { x: nextX, y: nextY };
+ setDragPosition(nextPos);
+ dragPositionRef.current = nextPos;
+ }
},
- [MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN]
+ [MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN, clampToVisibleWithSize]
);
const endResize = useCallback(() => {
@@ -419,7 +559,7 @@ export default function FloatingVideo() {
window.removeEventListener('touchend', endResize);
}, [handleResizeMove]);
- const startResize = (event) => {
+ const startResize = (event, handle) => {
event.stopPropagation();
event.preventDefault();
@@ -431,6 +571,10 @@ export default function FloatingVideo() {
const aspectRatio =
videoSize.height > 0 ? videoSize.width / videoSize.height : aspectRatioRef.current;
aspectRatioRef.current = aspectRatio;
+ const startPos =
+ dragPositionRef.current ||
+ initialPositionRef.current ||
+ { x: 0, y: 0 };
resizeStateRef.current = {
startX: clientX,
@@ -438,6 +582,8 @@ export default function FloatingVideo() {
startWidth: videoSize.width,
startHeight: videoSize.height,
aspectRatio,
+ startPos,
+ handle,
};
setIsResizing(true);
@@ -666,27 +812,6 @@ export default function FloatingVideo() {
)}
- {/* Resize handle */}
-
{/* Error message below video - doesn't block controls */}
@@ -703,6 +828,21 @@ export default function FloatingVideo() {
)}
+
+ {/* Resize handles */}
+ {resizeHandles.map((handle) => (
+ startResize(event, handle)}
+ onTouchStart={(event) => startResize(event, handle)}
+ style={{
+ ...resizeHandleBaseStyle,
+ ...handle.style,
+ cursor: handle.cursor,
+ }}
+ />
+ ))}
);