Enhance mobile video playback with fullscreen optimizations and MIME type handling

This commit is contained in:
Salman Qureshi
2025-08-10 04:45:51 +05:30
parent 74968c344a
commit eac87fff35
4 changed files with 406 additions and 74 deletions

View File

@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/leaf.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, minimal-ui, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<title>SeedBox Lite - Stream Torrents Instantly</title>
</head>
<body>

View File

@@ -917,6 +917,67 @@
background: #000;
}
/* Mobile Safari specific fullscreen optimizations */
@supports (-webkit-touch-callout: none) {
/* iOS Safari */
.video-element {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* Force native fullscreen behavior */
.video-element::-webkit-media-controls-fullscreen-button {
display: none;
}
.video-player-container.fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 99999 !important;
background: #000 !important;
}
.video-player-container.fullscreen .video-element {
width: 100vw !important;
height: 100vh !important;
object-fit: contain !important;
background: #000 !important;
}
}
/* Force mobile browsers to hide address bar in fullscreen */
.video-player-container.mobile-fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
height: 100dvh !important; /* Dynamic viewport height for mobile */
z-index: 99999 !important;
border-radius: 0 !important;
background: #000 !important;
/* Mobile Safari optimizations */
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.video-player-container.mobile-fullscreen .video-element {
width: 100vw !important;
height: 100vh !important;
height: 100dvh !important;
max-height: 100vh !important;
max-height: 100dvh !important;
object-fit: contain !important;
background: #000 !important;
}
/* Additional mobile fullscreen enhancements */
@media (max-width: 768px) {
.video-player-container.fullscreen {
@@ -942,6 +1003,39 @@
left: 0 !important;
right: 0 !important;
}
/* Enhanced fullscreen button for mobile */
.fullscreen-button {
background: rgba(0, 0, 0, 0.8) !important;
border: 2px solid #4ade80 !important;
color: #4ade80 !important;
font-weight: bold !important;
min-width: 48px !important;
min-height: 48px !important;
border-radius: 50% !important;
position: relative !important;
}
.fullscreen-button:hover {
background: rgba(74, 222, 128, 0.2) !important;
transform: scale(1.1) !important;
}
.fullscreen-button:active {
transform: scale(0.95) !important;
background: rgba(74, 222, 128, 0.4) !important;
}
/* Add visual feedback for double-tap */
.video-element {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
touch-action: manipulation;
}
}
/* Resume Dialog */

View File

@@ -469,6 +469,66 @@ const VideoPlayer = ({
};
}, [src, initialTime, onTimeUpdate, onProgress, updateBufferedProgress, torrentHash, fileIndex, title, hasShownResumeDialog, hasAppliedInitialTime]);
// Mobile video initialization
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
// Mobile-specific video event handlers
const handleLoadStart = () => {
console.log('📱 Mobile: Video load started');
setIsLoading(true);
};
const handleCanPlay = () => {
console.log('📱 Mobile: Video can play');
setIsLoading(false);
};
const handleWaiting = () => {
console.log('📱 Mobile: Video waiting for data');
setIsLoading(true);
};
const handleStalled = () => {
console.log('📱 Mobile: Video stalled, retrying...');
setIsLoading(true);
// On mobile, try to reload the video source if it stalls
setTimeout(() => {
if (video.paused && !isPlaying) {
video.load();
}
}, 2000);
};
const handleError = (e) => {
console.error('📱 Mobile video error:', e);
setIsLoading(false);
// Try to recover from error
setTimeout(() => {
video.load();
}, 1000);
};
video.addEventListener('loadstart', handleLoadStart);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('stalled', handleStalled);
video.addEventListener('error', handleError);
return () => {
video.removeEventListener('loadstart', handleLoadStart);
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
video.removeEventListener('stalled', handleStalled);
video.removeEventListener('error', handleError);
};
}
}, [src, isPlaying]);
// Fullscreen event listeners for mobile compatibility
useEffect(() => {
const handleFullscreenChange = () => {
@@ -481,21 +541,76 @@ const VideoPlayer = ({
setIsFullscreen(isCurrentlyFullscreen);
};
// iOS Safari specific fullscreen events
const handleWebkitFullscreenChange = () => {
const video = videoRef.current;
if (video) {
const isVideoFullscreen = video.webkitDisplayingFullscreen;
setIsFullscreen(isVideoFullscreen);
}
};
// Add event listeners for all browser prefixes
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
// iOS Safari specific
const video = videoRef.current;
if (video) {
video.addEventListener('webkitbeginfullscreen', () => setIsFullscreen(true));
video.addEventListener('webkitendfullscreen', () => setIsFullscreen(false));
}
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
if (video) {
video.removeEventListener('webkitbeginfullscreen', () => setIsFullscreen(true));
video.removeEventListener('webkitendfullscreen', () => setIsFullscreen(false));
}
};
}, []);
// Optimized play/pause for instant streaming
// Mobile viewport optimization for fullscreen
useEffect(() => {
const optimizeMobileViewport = () => {
// Ensure viewport meta tag allows user scaling for fullscreen
let viewportMeta = document.querySelector('meta[name="viewport"]');
if (!viewportMeta) {
viewportMeta = document.createElement('meta');
viewportMeta.name = 'viewport';
document.head.appendChild(viewportMeta);
}
if (isFullscreen) {
// Optimize for fullscreen - allow zooming and remove address bar
viewportMeta.content = 'width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, minimal-ui, viewport-fit=cover';
// Additional mobile Safari optimizations
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
// Force viewport recalculation
window.scrollTo(0, 1);
setTimeout(() => {
window.scrollTo(0, 0);
// Trigger a resize to ensure fullscreen
window.dispatchEvent(new Event('resize'));
}, 100);
}
} else {
// Reset viewport for normal viewing
viewportMeta.content = 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no';
}
};
optimizeMobileViewport();
}, [isFullscreen]);
// Optimized play/pause for mobile and instant streaming
const togglePlay = async () => {
if (!videoRef.current) return;
@@ -505,65 +620,124 @@ const VideoPlayer = ({
setIsPlaying(false);
} else {
const video = videoRef.current;
const buffered = video.buffered;
const currentTime = video.currentTime;
// Check for instant play capability
let canPlayInstantly = false;
// Mobile-specific optimizations
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (buffered.length > 0) {
for (let i = 0; i < buffered.length; i++) {
const start = buffered.start(i);
const end = buffered.end(i);
if (isMobile) {
// For mobile devices, ensure we have user interaction before playing
try {
// Start loading the video if not already loaded
if (video.readyState < 2) { // HAVE_CURRENT_DATA
video.load();
setIsLoading(true);
// Wait for enough data to start playing
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 10000);
const onCanPlay = () => {
clearTimeout(timeout);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onError);
setIsLoading(false);
resolve();
};
const onError = (e) => {
clearTimeout(timeout);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onError);
setIsLoading(false);
reject(e);
};
video.addEventListener('canplay', onCanPlay);
video.addEventListener('error', onError);
});
}
// Check if current position has any buffered data
if (start <= currentTime && end > currentTime) {
// For instant streaming, require minimal buffer (1 second)
if (end - currentTime >= 1) {
canPlayInstantly = true;
break;
// Play with mobile-specific handling
const playPromise = video.play();
if (playPromise !== undefined) {
await playPromise;
setIsPlaying(true);
}
} catch (error) {
console.warn('Mobile playback failed, trying fallback:', error);
setIsLoading(false);
// Fallback: simple play attempt
try {
await video.play();
setIsPlaying(true);
} catch (fallbackError) {
console.error('Video playback failed:', fallbackError);
setIsLoading(false);
}
}
} else {
// Desktop playback with buffering check
const buffered = video.buffered;
const currentTime = video.currentTime;
// Check for instant play capability
let canPlayInstantly = false;
if (buffered.length > 0) {
for (let i = 0; i < buffered.length; i++) {
const start = buffered.start(i);
const end = buffered.end(i);
// Check if current position has any buffered data
if (start <= currentTime && end > currentTime) {
// For instant streaming, require minimal buffer (1 second)
if (end - currentTime >= 1) {
canPlayInstantly = true;
break;
}
}
}
}
}
// Instant play logic - be aggressive about starting playback
if (canPlayInstantly || bufferHealth > 30 || instantPlayEnabled) {
try {
await video.play();
setIsPlaying(true);
setIsLoading(false);
} catch (playError) {
console.log('Instant play failed, buffering...', playError);
// Desktop instant play logic
if (canPlayInstantly || bufferHealth > 30 || instantPlayEnabled) {
try {
await video.play();
setIsPlaying(true);
setIsLoading(false);
} catch (playError) {
console.log('Instant play failed, buffering...', playError);
setIsLoading(true);
// Retry after a short buffer
setTimeout(async () => {
try {
await video.play();
setIsPlaying(true);
setIsLoading(false);
} catch (retryError) {
console.log('Retry play failed:', retryError);
setIsLoading(false);
}
}, 1000);
}
} else {
// Show loading state while building initial buffer
setIsLoading(true);
// Retry after a short buffer
setTimeout(async () => {
try {
await video.play();
setIsPlaying(true);
setIsLoading(false);
} catch (retryError) {
console.log('Retry play failed:', retryError);
setIsLoading(false);
console.log('Building buffer for smooth playback...');
// Try to play after minimal buffer is ready
setTimeout(() => {
if (videoRef.current && !isPlaying) {
videoRef.current.play().then(() => {
setIsPlaying(true);
setIsLoading(false);
}).catch(() => {
setIsLoading(false);
});
}
}, 1000);
}
} else {
// Show loading state while building initial buffer
setIsLoading(true);
console.log('Building buffer for smooth playback...');
// Try to play after minimal buffer is ready
setTimeout(() => {
if (videoRef.current && !isPlaying) {
videoRef.current.play().then(() => {
setIsPlaying(true);
setIsLoading(false);
}).catch(() => {
setIsLoading(false);
});
}
}, 1000);
}
}
} catch (error) {
@@ -616,27 +790,47 @@ const VideoPlayer = ({
const container = videoRef.current.parentElement;
const video = videoRef.current;
// Detect mobile devices
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0);
if (!isFullscreen) {
// Try to enter fullscreen
if (container.requestFullscreen) {
container.requestFullscreen();
} else if (container.webkitRequestFullscreen) {
// Safari
container.webkitRequestFullscreen();
} else if (container.mozRequestFullScreen) {
// Firefox
container.mozRequestFullScreen();
} else if (container.msRequestFullscreen) {
// IE/Edge
container.msRequestFullscreen();
} else if (video.webkitEnterFullscreen) {
// iOS Safari - use video element fullscreen
video.webkitEnterFullscreen();
} else if (video.requestFullscreen) {
// Fallback to video element
video.requestFullscreen();
if (isMobile) {
// For mobile devices, especially iOS Safari
if (video.webkitEnterFullscreen) {
// iOS Safari - use video element fullscreen (hides address bar)
video.webkitEnterFullscreen();
} else if (video.requestFullscreen) {
// Android Chrome/Firefox
video.requestFullscreen();
} else if (container.webkitRequestFullscreen) {
// Fallback for mobile Safari
container.webkitRequestFullscreen();
} else {
// Fallback: simulate fullscreen with CSS
setIsFullscreen(true);
// Trigger viewport change to hide address bar
window.scrollTo(0, 1);
setTimeout(() => window.scrollTo(0, 0), 100);
}
} else {
// Desktop fullscreen
if (container.requestFullscreen) {
container.requestFullscreen();
} else if (container.webkitRequestFullscreen) {
container.webkitRequestFullscreen();
} else if (container.mozRequestFullScreen) {
container.mozRequestFullScreen();
} else if (container.msRequestFullscreen) {
container.msRequestFullscreen();
}
}
if (!isMobile || !video.webkitEnterFullscreen) {
setIsFullscreen(true);
}
setIsFullscreen(true);
} else {
// Try to exit fullscreen
if (document.exitFullscreen) {
@@ -650,8 +844,14 @@ const VideoPlayer = ({
} else if (video.webkitExitFullscreen) {
// iOS Safari
video.webkitExitFullscreen();
} else {
// CSS fullscreen fallback
setIsFullscreen(false);
}
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
setIsFullscreen(false);
}
setIsFullscreen(false);
}
};
@@ -770,7 +970,7 @@ const VideoPlayer = ({
return (
<div
className={`video-player-container ${isFullscreen ? 'fullscreen' : ''}`}
className={`video-player-container ${isFullscreen ? 'fullscreen' : ''} ${isFullscreen && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ? 'mobile-fullscreen' : ''}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
@@ -793,6 +993,14 @@ const VideoPlayer = ({
onDoubleClick={toggleFullscreen}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
playsInline={false}
webkit-playsinline="false"
controls={false}
preload="none"
crossOrigin="anonymous"
muted={false}
autoPlay={false}
poster=""
/>
{isLoading && (

View File

@@ -1116,7 +1116,21 @@ app.get('/api/torrents/:identifier/files/:fileIdx/stream', async (req, res) => {
console.log(`🎬 Streaming: ${file.name} (${(file.length / 1024 / 1024).toFixed(1)} MB)`);
// Handle range requests
// Detect file type for proper MIME type
const ext = file.name.split('.').pop().toLowerCase();
const mimeTypes = {
'mp4': 'video/mp4',
'mkv': 'video/x-matroska',
'avi': 'video/x-msvideo',
'mov': 'video/quicktime',
'wmv': 'video/x-ms-wmv',
'flv': 'video/x-flv',
'webm': 'video/webm',
'm4v': 'video/mp4'
};
const contentType = mimeTypes[ext] || 'video/mp4';
// Handle range requests (crucial for mobile video playback)
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
@@ -1128,7 +1142,13 @@ app.get('/api/torrents/:identifier/files/:fileIdx/stream', async (req, res) => {
'Content-Range': `bytes ${start}-${end}/${file.length}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize,
'Content-Type': 'video/mp4'
'Content-Type': contentType,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Range, Content-Type',
'Access-Control-Expose-Headers': 'Content-Range, Accept-Ranges, Content-Length'
});
const stream = file.createReadStream({ start, end });
@@ -1136,7 +1156,14 @@ app.get('/api/torrents/:identifier/files/:fileIdx/stream', async (req, res) => {
} else {
res.writeHead(200, {
'Content-Length': file.length,
'Content-Type': 'video/mp4'
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Range, Content-Type',
'Access-Control-Expose-Headers': 'Content-Range, Accept-Ranges, Content-Length'
});
file.createReadStream().pipe(res);
}