mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-20 16:51:10 +00:00
1108 lines
35 KiB
JavaScript
1108 lines
35 KiB
JavaScript
// frontend/src/components/FloatingVideo.js
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import Draggable from 'react-draggable';
|
|
import useVideoStore from '../store/useVideoStore';
|
|
import useAuthStore from '../store/auth';
|
|
import mpegts from 'mpegts.js';
|
|
import Hls from 'hls.js';
|
|
import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
|
|
import {
|
|
applyConstraints,
|
|
calculateNewDimensions,
|
|
getClientCoordinates,
|
|
getLivePlayerErrorMessage,
|
|
getVODPlayerErrorMessage,
|
|
getPlayerPrefs,
|
|
savePlayerPrefs,
|
|
} from '../utils/components/FloatingVideoUtils.js';
|
|
|
|
const ResizeHandles = ({ startResize }) => {
|
|
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',
|
|
},
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
{/* Resize handles */}
|
|
{resizeHandles.map((handle) => (
|
|
<Box
|
|
key={handle.id}
|
|
className="floating-video-no-drag"
|
|
onMouseDown={(event) => startResize(event, handle)}
|
|
onTouchStart={(event) => startResize(event, handle)}
|
|
style={{
|
|
...resizeHandleBaseStyle,
|
|
...handle.style,
|
|
cursor: handle.cursor,
|
|
}}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default function FloatingVideo() {
|
|
const isVisible = useVideoStore((s) => s.isVisible);
|
|
const streamUrl = useVideoStore((s) => s.streamUrl);
|
|
const contentType = useVideoStore((s) => s.contentType);
|
|
const metadata = useVideoStore((s) => s.metadata);
|
|
const hideVideo = useVideoStore((s) => s.hideVideo);
|
|
|
|
const videoRef = useRef(null);
|
|
const playerRef = useRef(null);
|
|
const videoContainerRef = useRef(null);
|
|
const resizeStateRef = useRef(null);
|
|
const overlayTimeoutRef = useRef(null);
|
|
const aspectRatioRef = useRef(320 / 180);
|
|
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(false);
|
|
const [showControls, setShowControls] = useState(false);
|
|
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);
|
|
|
|
const MIN_WIDTH = 220;
|
|
const MIN_HEIGHT = 124;
|
|
const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging
|
|
const HEADER_HEIGHT = 38; // height of the close button header area
|
|
const ERROR_HEIGHT = 45; // approximate height of error message area when displayed
|
|
|
|
// Safely destroy the mpegts player to prevent errors
|
|
const safeDestroyPlayer = () => {
|
|
try {
|
|
if (playerRef.current) {
|
|
setIsLoading(false);
|
|
setLoadError(null);
|
|
|
|
if (videoRef.current) {
|
|
videoRef.current.removeAttribute('src');
|
|
videoRef.current.load();
|
|
}
|
|
|
|
try {
|
|
playerRef.current.pause();
|
|
} catch (e) {
|
|
// Ignore pause errors
|
|
}
|
|
|
|
try {
|
|
playerRef.current.destroy();
|
|
} catch (error) {
|
|
if (
|
|
error.name !== 'AbortError' &&
|
|
!error.message?.includes('aborted')
|
|
) {
|
|
console.log('Error during player destruction:', error.message);
|
|
}
|
|
} finally {
|
|
playerRef.current = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Error during player cleanup:', error);
|
|
playerRef.current = null;
|
|
}
|
|
|
|
// Clear overlay timer
|
|
if (overlayTimeoutRef.current) {
|
|
clearTimeout(overlayTimeoutRef.current);
|
|
overlayTimeoutRef.current = null;
|
|
}
|
|
};
|
|
|
|
// Start overlay auto-hide timer
|
|
const startOverlayTimer = () => {
|
|
if (overlayTimeoutRef.current) {
|
|
clearTimeout(overlayTimeoutRef.current);
|
|
}
|
|
overlayTimeoutRef.current = setTimeout(() => {
|
|
setShowOverlay(false);
|
|
}, 4000); // Hide after 4 seconds
|
|
};
|
|
|
|
// Initialize VOD player (native HTML5 with enhanced controls)
|
|
const initializeVODPlayer = () => {
|
|
if (!videoRef.current || !streamUrl) return;
|
|
|
|
setIsLoading(true);
|
|
setLoadError(null);
|
|
setShowOverlay(false);
|
|
setShowControls(false);
|
|
|
|
console.log('Initializing VOD player for:', streamUrl);
|
|
|
|
const video = videoRef.current;
|
|
|
|
// Enhanced video element configuration for VOD
|
|
video.preload = 'metadata';
|
|
video.crossOrigin = 'anonymous';
|
|
|
|
// Restore saved volume
|
|
const { volume: savedVolume, muted: savedMuted } = getPlayerPrefs();
|
|
if (typeof savedVolume === 'number') video.volume = savedVolume;
|
|
if (typeof savedMuted === 'boolean') video.muted = savedMuted;
|
|
// Always start playback from the beginning of the seekable range.
|
|
let hasSeekedToStart = false;
|
|
const seekToStart = () => {
|
|
if (hasSeekedToStart) return;
|
|
try {
|
|
let target = 0;
|
|
if (video.seekable && video.seekable.length > 0) {
|
|
target = video.seekable.start(0);
|
|
}
|
|
// Only apply if we're not already at/near the start. Avoid
|
|
// setting currentTime when the video has no duration yet.
|
|
if (
|
|
Number.isFinite(target) &&
|
|
Math.abs((video.currentTime || 0) - target) > 0.25
|
|
) {
|
|
video.currentTime = target;
|
|
}
|
|
hasSeekedToStart = true;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const handleLoadStart = () => setIsLoading(true);
|
|
const handleLoadedMetadata = () => {
|
|
seekToStart();
|
|
};
|
|
const handleLoadedData = () => {
|
|
setIsLoading(false);
|
|
// hls.js applies its `startPosition` after MEDIA_ATTACHED, which can
|
|
// run later than `loadedmetadata`. Re-seek here as a safety net so a
|
|
// hls.js live playlist doesn't snap to the live edge after our first
|
|
// seek attempt happened against an empty seekable range.
|
|
seekToStart();
|
|
};
|
|
const handleCanPlay = () => {
|
|
setIsLoading(false);
|
|
// Final fallback for the Safari native-HLS path where seekable.start(0)
|
|
// is sometimes only valid by the time `canplay` fires.
|
|
seekToStart();
|
|
// Auto-play for VOD content
|
|
video.play().catch((e) => {
|
|
console.log('Auto-play prevented:', e);
|
|
setLoadError('Auto-play was prevented. Click play to start.');
|
|
});
|
|
// Show overlay briefly when video is ready, then auto-hide
|
|
setShowOverlay(true);
|
|
startOverlayTimer();
|
|
};
|
|
const handleError = (e) => {
|
|
setIsLoading(false);
|
|
|
|
setLoadError(getVODPlayerErrorMessage(e.target.error));
|
|
};
|
|
|
|
// Enhanced progress tracking for VOD
|
|
const handleProgress = () => {
|
|
// if (video.buffered.length > 0) {
|
|
// const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
|
// const duration = video.duration;
|
|
// if (duration > 0) {
|
|
// const bufferedPercent = (bufferedEnd / duration) * 100;
|
|
// // You could emit this to a store for UI feedback
|
|
// }
|
|
// }
|
|
};
|
|
|
|
// Add event listeners
|
|
video.addEventListener('loadstart', handleLoadStart);
|
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.addEventListener('loadeddata', handleLoadedData);
|
|
video.addEventListener('canplay', handleCanPlay);
|
|
video.addEventListener('error', handleError);
|
|
video.addEventListener('progress', handleProgress);
|
|
|
|
// HLS handling: in-progress recordings expose .m3u8 playlists (and ended
|
|
// recordings whose MKV concat hasn't completed yet still serve via /hls/).
|
|
// Native <video src="*.m3u8"> only works on Safari/iOS, and even there it
|
|
// cannot follow a growing playlist with a useful seekable window. Use
|
|
// hls.js when supported so the user gets a full DVR/timeshift window
|
|
// across all browsers. The HLS playlist uses #EXT-X-PLAYLIST-TYPE=EVENT-
|
|
// style growth (omit_endlist + hls_list_size 0), so hls.js will treat the
|
|
// entire recorded duration as the seekable range while the recording is
|
|
// still in progress and as a complete VOD once it ends.
|
|
const isHls =
|
|
typeof streamUrl === 'string' &&
|
|
(streamUrl.includes('.m3u8') ||
|
|
streamUrl.includes('/hls/index') ||
|
|
streamUrl.includes('application/vnd.apple.mpegurl'));
|
|
let hls = null;
|
|
|
|
if (isHls && Hls.isSupported()) {
|
|
try {
|
|
hls = new Hls({
|
|
// Open at the very beginning of the recording rather than the live
|
|
// edge. Without this, an in-progress recording would start at "now"
|
|
// and hide everything already recorded. hls.js applies this AFTER
|
|
// MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is
|
|
// also kept as a safety net for the Safari native-HLS path and for
|
|
// edge cases where this initial-position logic loses to the user's
|
|
// first interaction.
|
|
startPosition: 0,
|
|
// Allow seeking back to the start of the recording, regardless of
|
|
// current playhead position. Recordings can be hours long and the
|
|
// user may want to scrub anywhere; we explicitly disable buffer
|
|
// eviction by setting a very large back-buffer length.
|
|
backBufferLength: 90 * 60, // 90 minutes
|
|
maxBufferLength: 60,
|
|
maxMaxBufferLength: 600,
|
|
// Leave liveMaxLatencyDurationCount at the hls.js default (Infinity).
|
|
// A finite value forces the playhead to the live edge during playback.
|
|
enableWorker: true,
|
|
lowLatencyMode: false,
|
|
// Inject the JWT into every playlist + segment XHR. Read the token
|
|
// from the auth store at request time rather than capturing the
|
|
// closure value at hls.js init, so a refreshed access token mid-
|
|
// playback is picked up on the next segment fetch.
|
|
xhrSetup: (xhr) => {
|
|
const token = useAuthStore.getState().accessToken;
|
|
if (token) {
|
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
}
|
|
},
|
|
});
|
|
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
|
if (data.fatal) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('HLS fatal error:', data.type, data.details);
|
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
try {
|
|
hls.startLoad();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
try {
|
|
hls.recoverMediaError();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} else {
|
|
setLoadError(`HLS playback error: ${data.details || data.type}`);
|
|
}
|
|
}
|
|
});
|
|
hls.attachMedia(video);
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
hls.loadSource(streamUrl);
|
|
});
|
|
} catch (error) {
|
|
setIsLoading(false);
|
|
setLoadError(`HLS initialization error: ${error.message}`);
|
|
return;
|
|
}
|
|
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
// Safari path: native HLS support, including seekable DVR windows.
|
|
video.src = streamUrl;
|
|
video.load();
|
|
} else {
|
|
// Plain progressive file (MKV/MP4): native HTML5.
|
|
video.src = streamUrl;
|
|
video.load();
|
|
}
|
|
|
|
// Store cleanup function
|
|
playerRef.current = {
|
|
destroy: () => {
|
|
video.removeEventListener('loadstart', handleLoadStart);
|
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.removeEventListener('loadeddata', handleLoadedData);
|
|
video.removeEventListener('canplay', handleCanPlay);
|
|
video.removeEventListener('error', handleError);
|
|
video.removeEventListener('progress', handleProgress);
|
|
if (hls) {
|
|
try {
|
|
hls.destroy();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
},
|
|
};
|
|
};
|
|
|
|
// Initialize live stream player (mpegts.js)
|
|
const initializeLivePlayer = () => {
|
|
if (!videoRef.current || !streamUrl) return;
|
|
|
|
setIsLoading(true);
|
|
setLoadError(null);
|
|
setShowControls(false);
|
|
|
|
try {
|
|
if (!mpegts.getFeatureList().mseLivePlayback) {
|
|
setIsLoading(false);
|
|
setLoadError(
|
|
"Your browser doesn't support live video streaming. Please try Chrome or Edge."
|
|
);
|
|
return;
|
|
}
|
|
|
|
// mpegts.js workers run in WorkerGlobalScope where relative URLs are
|
|
// not resolved against the page origin. Always pass an absolute URL.
|
|
const absoluteStreamUrl =
|
|
streamUrl.startsWith('/') && typeof window !== 'undefined'
|
|
? `${window.location.origin}${streamUrl}`
|
|
: streamUrl;
|
|
|
|
// Read the JWT from the auth store at player-creation time rather than
|
|
// relying on the closure-captured `accessToken` value. mpegts.js has
|
|
// no per-request setup hook (unlike hls.js's xhrSetup), so this header
|
|
// is baked into the IO loader for the life of the player; we just want
|
|
// to be sure we use the freshest token available at the moment of
|
|
// connection rather than whatever React-render snapshot we closed over.
|
|
const liveAccessToken = useAuthStore.getState().accessToken;
|
|
|
|
const player = mpegts.createPlayer(
|
|
{
|
|
type: 'mpegts',
|
|
url: absoluteStreamUrl,
|
|
isLive: true,
|
|
cors: true,
|
|
},
|
|
{
|
|
enableWorker: true,
|
|
enableStashBuffer: false,
|
|
liveBufferLatencyChasing: false,
|
|
liveSync: false,
|
|
autoCleanupSourceBuffer: true,
|
|
autoCleanupMaxBackwardDuration: 120,
|
|
autoCleanupMinBackwardDuration: 60,
|
|
reuseRedirectedURL: true,
|
|
headers: liveAccessToken
|
|
? { Authorization: `Bearer ${liveAccessToken}` }
|
|
: undefined,
|
|
}
|
|
);
|
|
|
|
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);
|
|
});
|
|
|
|
player.on(mpegts.Events.METADATA_ARRIVED, () => {
|
|
setIsLoading(false);
|
|
});
|
|
|
|
player.on(mpegts.Events.ERROR, (errorType, errorDetail) => {
|
|
setIsLoading(false);
|
|
|
|
if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) {
|
|
console.error('Player error:', errorType, errorDetail);
|
|
|
|
setLoadError(getLivePlayerErrorMessage(errorType, errorDetail));
|
|
}
|
|
});
|
|
|
|
player.load();
|
|
|
|
player.on(mpegts.Events.MEDIA_INFO, () => {
|
|
setIsLoading(false);
|
|
try {
|
|
player.play().catch((e) => {
|
|
console.log('Auto-play prevented:', e);
|
|
setLoadError('Auto-play was prevented. Click play to start.');
|
|
});
|
|
} catch (e) {
|
|
console.log('Error during play:', e);
|
|
setLoadError(`Playback error: ${e.message}`);
|
|
}
|
|
});
|
|
|
|
playerRef.current = player;
|
|
} catch (error) {
|
|
setIsLoading(false);
|
|
console.error('Error initializing player:', error);
|
|
|
|
if (
|
|
error.message?.includes('codec') ||
|
|
error.message?.includes('format')
|
|
) {
|
|
setLoadError(
|
|
'Codec not supported by your browser. Please try a different browser (Chrome/Edge recommended).'
|
|
);
|
|
} else {
|
|
setLoadError(`Initialization error: ${error.message}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isVisible || !streamUrl) {
|
|
safeDestroyPlayer();
|
|
return;
|
|
}
|
|
|
|
// Clean up any existing player
|
|
safeDestroyPlayer();
|
|
|
|
// Initialize the appropriate player based on content type
|
|
if (contentType === 'vod') {
|
|
initializeVODPlayer();
|
|
} else {
|
|
initializeLivePlayer();
|
|
}
|
|
|
|
// 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]);
|
|
|
|
// Modified hideVideo handler to clean up player first
|
|
const handleClose = (e) => {
|
|
if (e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
safeDestroyPlayer();
|
|
setTimeout(() => {
|
|
hideVideo();
|
|
}, 50);
|
|
};
|
|
|
|
const clampToVisible = useCallback(
|
|
(x, y) => {
|
|
if (typeof window === 'undefined') return { x, y };
|
|
|
|
const totalHeight = videoSize.height + HEADER_HEIGHT + ERROR_HEIGHT;
|
|
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),
|
|
};
|
|
},
|
|
[HEADER_HEIGHT, ERROR_HEIGHT, videoSize.height, videoSize.width]
|
|
);
|
|
|
|
const clampToVisibleWithSize = useCallback(
|
|
(x, y, width, height) => {
|
|
if (typeof window === 'undefined') return { x, y };
|
|
|
|
const totalHeight = height + HEADER_HEIGHT + ERROR_HEIGHT;
|
|
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),
|
|
};
|
|
},
|
|
[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,
|
|
startY,
|
|
startWidth,
|
|
startHeight,
|
|
startPos,
|
|
handle,
|
|
aspectRatio,
|
|
} = resizeStateRef.current;
|
|
|
|
const ratio = aspectRatio || aspectRatioRef.current;
|
|
const { width: nextWidth, height: nextHeight } = calculateNewDimensions(
|
|
clientX - startX,
|
|
clientY - startY,
|
|
startWidth,
|
|
startHeight,
|
|
handle,
|
|
ratio
|
|
);
|
|
|
|
const constrainedSize = applyConstraints(
|
|
nextWidth,
|
|
nextHeight,
|
|
ratio,
|
|
startPos,
|
|
handle,
|
|
MIN_WIDTH,
|
|
MIN_HEIGHT,
|
|
VISIBLE_MARGIN
|
|
);
|
|
|
|
setVideoSize({
|
|
width: Math.round(constrainedSize.width),
|
|
height: Math.round(constrainedSize.height),
|
|
});
|
|
|
|
updatePositionIfNeeded(
|
|
handle,
|
|
startPos,
|
|
startWidth,
|
|
startHeight,
|
|
constrainedSize
|
|
);
|
|
},
|
|
[MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN, clampToVisibleWithSize]
|
|
);
|
|
|
|
const updatePositionIfNeeded = (
|
|
handle,
|
|
startPos,
|
|
startWidth,
|
|
startHeight,
|
|
newSize
|
|
) => {
|
|
if (!handle.isLeft && !handle.isTop) return;
|
|
|
|
const posX = startPos?.x ?? 0;
|
|
const posY = startPos?.y ?? 0;
|
|
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 nextPos = {
|
|
x: handle.isLeft ? clamped.x : nextX,
|
|
y: handle.isTop ? clamped.y : nextY,
|
|
};
|
|
|
|
setDragPosition(nextPos);
|
|
dragPositionRef.current = nextPos;
|
|
};
|
|
|
|
const endResize = useCallback(() => {
|
|
setIsResizing(false);
|
|
resizeStateRef.current = null;
|
|
window.removeEventListener('mousemove', handleResizeMove);
|
|
window.removeEventListener('mouseup', endResize);
|
|
window.removeEventListener('touchmove', handleResizeMove);
|
|
window.removeEventListener('touchend', endResize);
|
|
}, [handleResizeMove]);
|
|
|
|
const startResize = (event, handle) => {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
const { clientX, clientY } = getClientCoordinates(event);
|
|
|
|
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,
|
|
startY: clientY,
|
|
startWidth: videoSize.width,
|
|
startHeight: videoSize.height,
|
|
aspectRatio,
|
|
startPos,
|
|
handle,
|
|
};
|
|
|
|
setIsResizing(true);
|
|
|
|
window.addEventListener('mousemove', handleResizeMove);
|
|
window.addEventListener('mouseup', endResize);
|
|
window.addEventListener('touchmove', handleResizeMove);
|
|
window.addEventListener('touchend', endResize);
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
endResize();
|
|
};
|
|
}, [endResize]);
|
|
|
|
useEffect(() => {
|
|
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);
|
|
const pos = clampToVisible(initialX, initialY);
|
|
|
|
initialPositionRef.current = pos;
|
|
setDragPosition(pos);
|
|
dragPositionRef.current = pos;
|
|
}, [
|
|
clampToVisible,
|
|
videoSize.height,
|
|
videoSize.width,
|
|
HEADER_HEIGHT,
|
|
ERROR_HEIGHT,
|
|
]);
|
|
|
|
const handleDragStart = useCallback(
|
|
(event, data) => {
|
|
const clientX = event.touches?.[0]?.clientX ?? event.clientX;
|
|
const clientY = event.touches?.[0]?.clientY ?? event.clientY;
|
|
const rect = videoContainerRef.current?.getBoundingClientRect();
|
|
|
|
if (clientX != null && clientY != null && rect) {
|
|
dragOffsetRef.current = {
|
|
x: clientX - rect.left,
|
|
y: clientY - rect.top,
|
|
};
|
|
} else {
|
|
dragOffsetRef.current = { x: 0, y: 0 };
|
|
}
|
|
|
|
const clamped = clampToVisible(data?.x ?? 0, data?.y ?? 0);
|
|
setDragPosition(clamped);
|
|
dragPositionRef.current = clamped;
|
|
},
|
|
[clampToVisible]
|
|
);
|
|
|
|
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;
|
|
|
|
const nextX = clientX - (dragOffsetRef.current?.x ?? 0);
|
|
const nextY = clientY - (dragOffsetRef.current?.y ?? 0);
|
|
const clamped = clampToVisible(nextX, nextY);
|
|
setDragPosition(clamped);
|
|
dragPositionRef.current = clamped;
|
|
},
|
|
[clampToVisible]
|
|
);
|
|
|
|
const handleDragStop = useCallback(
|
|
(_, data) => {
|
|
const clamped = clampToVisible(data?.x ?? 0, data?.y ?? 0);
|
|
setDragPosition(clamped);
|
|
dragPositionRef.current = clamped;
|
|
savePlayerPrefs({ position: clamped });
|
|
},
|
|
[clampToVisible]
|
|
);
|
|
|
|
// If the floating video is hidden or no URL is selected, do not render
|
|
if (!isVisible || !streamUrl) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Draggable
|
|
nodeRef={videoContainerRef}
|
|
cancel=".floating-video-no-drag"
|
|
disabled={isResizing}
|
|
position={dragPosition || undefined}
|
|
defaultPosition={initialPositionRef.current || { x: 0, y: 0 }}
|
|
onStart={handleDragStart}
|
|
onDrag={handleDrag}
|
|
onStop={handleDragStop}
|
|
>
|
|
<div
|
|
ref={videoContainerRef}
|
|
style={{
|
|
position: 'fixed',
|
|
top: 0,
|
|
left: 0,
|
|
width: `${videoSize.width}px`,
|
|
zIndex: 9999,
|
|
backgroundColor: '#333',
|
|
borderRadius: '8px',
|
|
overflow: 'visible',
|
|
boxShadow: '0 2px 10px rgba(0,0,0,0.7)',
|
|
}}
|
|
>
|
|
{/* Header row with optional title and close button */}
|
|
<Flex
|
|
justify="space-between"
|
|
align="center"
|
|
style={{
|
|
padding: '3px 3px 3px 8px',
|
|
minHeight: '38px',
|
|
cursor: 'grab',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{metadata?.name ? (
|
|
<Text
|
|
size="xs"
|
|
fw={600}
|
|
style={{
|
|
color: 'rgba(255,255,255,0.9)',
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
whiteSpace: 'nowrap',
|
|
textOverflow: 'ellipsis',
|
|
paddingRight: 4,
|
|
textShadow: '0px 1px 3px rgba(0,0,0,0.8)',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{metadata.name}
|
|
</Text>
|
|
) : (
|
|
<Box style={{ flex: 1 }} />
|
|
)}
|
|
<CloseButton
|
|
onClick={handleClose}
|
|
onTouchEnd={handleClose}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onTouchStart={(e) => e.stopPropagation()}
|
|
style={{
|
|
minHeight: '32px',
|
|
minWidth: '32px',
|
|
cursor: 'pointer',
|
|
touchAction: 'manipulation',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
</Flex>
|
|
|
|
{/* Video container with relative positioning for the overlay */}
|
|
<Box
|
|
style={{ position: 'relative' }}
|
|
onMouseEnter={() => {
|
|
setShowControls(true);
|
|
if (contentType === 'vod' && !isLoading) {
|
|
setShowOverlay(true);
|
|
if (overlayTimeoutRef.current) {
|
|
clearTimeout(overlayTimeoutRef.current);
|
|
}
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (contentType === 'vod' && !isLoading) {
|
|
startOverlayTimer();
|
|
}
|
|
}}
|
|
>
|
|
{/* Enhanced video element with better controls for VOD */}
|
|
<video
|
|
ref={videoRef}
|
|
controls={showControls}
|
|
className="floating-video-no-drag"
|
|
style={{
|
|
width: '100%',
|
|
height: `${videoSize.height}px`,
|
|
backgroundColor: '#000',
|
|
borderRadius: '0 0 8px 8px',
|
|
// Better controls styling for VOD
|
|
...(contentType === 'vod' && {
|
|
controlsList: 'nodownload',
|
|
playsInline: true,
|
|
}),
|
|
}}
|
|
// Add poster for VOD if available
|
|
{...(contentType === 'vod' && {
|
|
poster: metadata?.logo?.url, // Use VOD poster if available
|
|
})}
|
|
/>
|
|
|
|
{/* VOD title overlay when not loading - auto-hides after 4 seconds */}
|
|
{!isLoading && metadata && contentType === 'vod' && showOverlay && (
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
background: 'linear-gradient(rgba(0,0,0,0.8), transparent)',
|
|
padding: '10px 10px 20px',
|
|
color: 'white',
|
|
pointerEvents: 'none', // Allow clicks to pass through to video controls
|
|
transition: 'opacity 0.3s ease-in-out',
|
|
opacity: showOverlay ? 1 : 0,
|
|
}}
|
|
>
|
|
<Text
|
|
size="sm"
|
|
weight={500}
|
|
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.8)' }}
|
|
>
|
|
{metadata.name}
|
|
</Text>
|
|
{metadata.year && (
|
|
<Text
|
|
size="xs"
|
|
color="dimmed"
|
|
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.8)' }}
|
|
>
|
|
{metadata.year}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Loading overlay - only show when loading */}
|
|
{isLoading && (
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 5,
|
|
}}
|
|
>
|
|
<Loader color="cyan" size="md" />
|
|
<Text color="white" size="sm" mt={10}>
|
|
Loading {contentType === 'vod' ? 'video' : 'stream'}...
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Error message below video - doesn't block controls */}
|
|
{!isLoading && loadError && (
|
|
<Box
|
|
style={{
|
|
padding: '10px',
|
|
backgroundColor: '#2d1b2e',
|
|
borderTop: '1px solid #444',
|
|
}}
|
|
>
|
|
<Text color="red" size="xs" style={{ textAlign: 'center' }}>
|
|
{loadError}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
<ResizeHandles startResize={startResize} />
|
|
</div>
|
|
</Draggable>
|
|
);
|
|
}
|