feat(api): add FFmpeg process status and management API endpoints
- Implement GET /api/ffmpeg/status to return current FFmpeg process status - Support filtering by videoId and response formatting (JSON or text table) - Include optional statistics in status response - Implement DELETE /api/ffmpeg/status for process cleanup operations - Allow killing processes by videoId, stale status, or all at once - Add cache-control and CORS headers for API responses - Add error handling with 500 response on failure
This commit is contained in:
parent
b93bd26825
commit
13d6874c00
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
|
|
@ -0,0 +1,358 @@
|
|||
# Anti-Jitter Progress System Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of Stash's anti-jitter mechanisms in NextAV to solve the streaming buffer jitter problem where progress bars jump backward as new data arrives.
|
||||
|
||||
## Problem Solved
|
||||
|
||||
**Before**: The progress bar would jump backward when:
|
||||
- Buffer underruns occurred
|
||||
- Network delays caused time to "catch up"
|
||||
- Raw video time updates were directly reflected in the UI
|
||||
|
||||
**After**: Smooth, consistent progress that:
|
||||
- Prevents backward jumps beyond a threshold
|
||||
- Throttles updates to prevent excessive re-renders
|
||||
- Allows small backward adjustments for smooth playback
|
||||
- Provides visual buffer state feedback
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Custom Hook: `useAntiJitterProgress`
|
||||
|
||||
**Location**: `src/lib/use-anti-jitter-progress.ts`
|
||||
|
||||
**Key Features**:
|
||||
- **Jitter Threshold**: 0.5 seconds - maximum allowed backward jump
|
||||
- **Update Throttling**: 100ms minimum between updates
|
||||
- **Progress Reference**: Maintains stable progress state
|
||||
- **Buffer Monitoring**: Tracks actual buffer state
|
||||
|
||||
**Core Logic**:
|
||||
```typescript
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
const now = Date.now();
|
||||
const video = videoRef.current;
|
||||
const rawTime = video.currentTime;
|
||||
const currentProgress = progressRef.current;
|
||||
|
||||
// Prevent backward jumps beyond threshold
|
||||
if (rawTime < currentProgress - jitterThreshold) {
|
||||
console.log(`[ANTI-JITTER] Blocked backward jump: ${rawTime}s -> ${currentProgress}s`);
|
||||
return; // Block this update
|
||||
}
|
||||
|
||||
// Throttle updates to prevent excessive re-renders
|
||||
if (now - lastUpdateRef.current < updateThrottle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and update progress
|
||||
if (rawTime >= 0 && rawTime <= (duration || Infinity)) {
|
||||
if (rawTime >= currentProgress - 0.1) {
|
||||
// Forward progress or small adjustment
|
||||
progressRef.current = rawTime;
|
||||
setCurrentTime(rawTime);
|
||||
lastUpdateRef.current = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Enhanced Progress Bar
|
||||
|
||||
**Features**:
|
||||
- **Buffer Visualization**: Blue overlay shows buffered content
|
||||
- **Smooth Progress**: Blue bar shows current playback position
|
||||
- **Seek Overlay**: Invisible range input for seeking
|
||||
- **Buffer Status**: Text display of buffered duration
|
||||
|
||||
**Visual Elements**:
|
||||
```tsx
|
||||
<div className="relative w-full h-2 bg-gray-600 rounded-lg overflow-hidden">
|
||||
{/* Buffer indicator */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-400/30 rounded-lg"
|
||||
style={{
|
||||
width: `${Math.min((bufferState.buffered / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-500 rounded-lg transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min((currentTime / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
{/* Seek input overlay */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Anti-Jitter Mechanisms
|
||||
|
||||
#### A. Backward Jump Prevention
|
||||
- **Threshold**: 0.5 seconds maximum backward jump
|
||||
- **Logic**: Blocks updates that would cause large backward movement
|
||||
- **Benefit**: Prevents jarring progress bar jumps
|
||||
|
||||
#### B. Update Throttling
|
||||
- **Frequency**: Maximum 10 updates per second (100ms throttle)
|
||||
- **Logic**: Skips updates that come too quickly
|
||||
- **Benefit**: Smoother UI performance, less CPU usage
|
||||
|
||||
#### C. Small Adjustment Allowance
|
||||
- **Threshold**: 0.1 seconds for small backward adjustments
|
||||
- **Logic**: Allows minor corrections for smooth playback
|
||||
- **Benefit**: Maintains playback quality while preventing large jumps
|
||||
|
||||
#### D. Progress Reference Management
|
||||
- **State**: Maintains stable progress reference
|
||||
- **Updates**: Only updates when progress is valid
|
||||
- **Reset**: Resets on video source changes
|
||||
|
||||
### 4. Buffer State Monitoring
|
||||
|
||||
**Features**:
|
||||
- **Real-time Tracking**: Monitors `progress` events
|
||||
- **Visual Feedback**: Shows buffered content in progress bar
|
||||
- **Status Display**: Shows buffered duration below progress bar
|
||||
|
||||
**Implementation**:
|
||||
```typescript
|
||||
const handleProgress = () => {
|
||||
if (videoRef.current) {
|
||||
const video = videoRef.current;
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
bufferStateRef.current = {
|
||||
buffered: bufferedEnd,
|
||||
lastBufferUpdate: Date.now()
|
||||
};
|
||||
console.log(`[BUFFER] Buffered to ${bufferedEnd}s`);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. In Video Components
|
||||
|
||||
```typescript
|
||||
import { useAntiJitterProgress } from '@/lib/use-anti-jitter-progress';
|
||||
|
||||
export default function VideoViewer({ video, isOpen, onClose }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Use the anti-jitter progress hook
|
||||
const {
|
||||
currentTime,
|
||||
bufferState,
|
||||
handleTimeUpdate,
|
||||
handleProgress,
|
||||
seekTo,
|
||||
resetProgress
|
||||
} = useAntiJitterProgress(videoRef, duration);
|
||||
|
||||
// Add event listeners
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.addEventListener('timeupdate', handleTimeUpdate);
|
||||
videoRef.current.addEventListener('progress', handleProgress);
|
||||
|
||||
return () => {
|
||||
videoRef.current?.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
videoRef.current?.removeEventListener('progress', handleProgress);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle seeking
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = parseFloat(e.target.value);
|
||||
seekTo(newTime);
|
||||
};
|
||||
|
||||
// Reset on video change
|
||||
useEffect(() => {
|
||||
resetProgress();
|
||||
}, [video]);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Enhanced Progress Bar
|
||||
|
||||
```tsx
|
||||
{/* Enhanced Progress bar with buffer visualization */}
|
||||
<div className="mb-4">
|
||||
<div className="relative w-full h-2 bg-gray-600 rounded-lg overflow-hidden">
|
||||
{/* Buffer indicator */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-400/30 rounded-lg"
|
||||
style={{
|
||||
width: `${Math.min((bufferState.buffered / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-500 rounded-lg transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min((currentTime / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
{/* Seek input overlay */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-white text-sm mt-1">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
{/* Buffer status */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div className="text-xs text-blue-300 mt-1">
|
||||
Buffered: {formatTime(bufferState.buffered)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Jitter Threshold
|
||||
```typescript
|
||||
const jitterThreshold = 0.5; // Maximum allowed backward jump in seconds
|
||||
```
|
||||
- **Lower values**: More strict, less jitter but potentially choppy
|
||||
- **Higher values**: More lenient, smoother but potential for jumps
|
||||
- **Recommended**: 0.5 seconds (good balance)
|
||||
|
||||
### Update Throttle
|
||||
```typescript
|
||||
const updateThrottle = 100; // Minimum ms between updates
|
||||
```
|
||||
- **Lower values**: More responsive but higher CPU usage
|
||||
- **Higher values**: Smoother but less responsive
|
||||
- **Recommended**: 100ms (10 FPS, good balance)
|
||||
|
||||
### Small Adjustment Threshold
|
||||
```typescript
|
||||
if (rawTime >= currentProgress - 0.1) // 0.1 seconds
|
||||
```
|
||||
- **Lower values**: Stricter progress validation
|
||||
- **Higher values**: More lenient with small adjustments
|
||||
- **Recommended**: 0.1 seconds (allows smooth playback)
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Script
|
||||
Run the test script to verify the anti-jitter system:
|
||||
```bash
|
||||
node test-anti-jitter.mjs
|
||||
```
|
||||
|
||||
### Test Scenarios
|
||||
1. **Normal Forward Progress**: All forward updates should pass
|
||||
2. **Small Backward Adjustment**: Small adjustments should pass
|
||||
3. **Large Backward Jump**: Large backward jumps should be blocked
|
||||
4. **Rapid Updates**: Rapid updates should be throttled
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. User Experience
|
||||
- **Smooth Progress**: No more jarring backward jumps
|
||||
- **Visual Feedback**: Clear buffer state indication
|
||||
- **Consistent Behavior**: Predictable progress bar movement
|
||||
|
||||
### 2. Performance
|
||||
- **Reduced Re-renders**: Throttled updates improve performance
|
||||
- **Stable State**: Progress reference prevents unnecessary updates
|
||||
- **Efficient Monitoring**: Smart event handling
|
||||
|
||||
### 3. Reliability
|
||||
- **Jitter Prevention**: Blocks problematic time updates
|
||||
- **Buffer Awareness**: Tracks actual buffered content
|
||||
- **Error Handling**: Graceful fallbacks for edge cases
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 1. Adaptive Thresholds
|
||||
- **Dynamic Jitter Threshold**: Adjust based on video quality
|
||||
- **Network-Aware Throttling**: Adapt to connection speed
|
||||
- **Quality-Based Settings**: Different thresholds for different scenarios
|
||||
|
||||
### 2. Advanced Buffer Management
|
||||
- **Predictive Buffering**: Anticipate buffer needs
|
||||
- **Quality Adaptation**: Switch quality based on buffer state
|
||||
- **Network Monitoring**: Track connection health
|
||||
|
||||
### 3. Enhanced Visualization
|
||||
- **Buffer Prediction**: Show predicted buffer state
|
||||
- **Quality Indicators**: Visual quality level indicators
|
||||
- **Network Status**: Connection health indicators
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Progress Bar Not Moving
|
||||
- Check if `handleTimeUpdate` is being called
|
||||
- Verify `jitterThreshold` isn't too restrictive
|
||||
- Ensure video element has valid duration
|
||||
|
||||
#### 2. Excessive Throttling
|
||||
- Increase `updateThrottle` value
|
||||
- Check for rapid timeupdate events
|
||||
- Verify video source stability
|
||||
|
||||
#### 3. Buffer Not Showing
|
||||
- Ensure `handleProgress` is attached to `progress` event
|
||||
- Check if video has buffered ranges
|
||||
- Verify buffer state updates
|
||||
|
||||
### Debug Logging
|
||||
The system provides comprehensive logging:
|
||||
```
|
||||
[ANTI-JITTER] Blocked backward jump: 2s -> 5s
|
||||
[THROTTLE] Skipped update: 50ms < 100ms
|
||||
[BUFFER] Buffered to 10s
|
||||
[SEEK] Seeking to 15s, updated progress reference
|
||||
[PROGRESS] Forward progress: 16s
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The anti-jitter progress system successfully implements Stash's approach to solve streaming buffer jitter. By preventing backward jumps, throttling updates, and providing visual feedback, it creates a smooth, professional video playback experience.
|
||||
|
||||
The system is:
|
||||
- **Configurable**: Easy to adjust thresholds and behavior
|
||||
- **Reusable**: Shared hook for multiple components
|
||||
- **Efficient**: Minimal performance impact
|
||||
- **Reliable**: Handles edge cases gracefully
|
||||
|
||||
This implementation provides the foundation for professional-grade video streaming without the jittery behavior common in basic implementations.
|
||||
|
|
@ -0,0 +1,456 @@
|
|||
# Stash Analysis and Solution Plan
|
||||
|
||||
## 🔍 **Real Problem Analysis**
|
||||
|
||||
### **The Core Issue: FFmpeg Process Management, Not Duration Protection**
|
||||
|
||||
After analyzing the docs and your Stash discovery, the real problem is **NOT** duration corruption or progress bar jitter. The real problem is:
|
||||
|
||||
1. **❌ Poor FFmpeg Process Management**: Our current system doesn't handle FFmpeg processes robustly
|
||||
2. **❌ No Seek-Optimized Transcoding**: We don't restart FFmpeg with `--ss` for seeking like Stash does
|
||||
3. **❌ Resource Leaks**: FFmpeg processes may not terminate properly, causing system issues
|
||||
4. **❌ Inefficient Seeking**: Seeking requires transcoding from the beginning instead of from the seek point
|
||||
|
||||
### **What We Were Fixing vs. What We Should Fix**
|
||||
|
||||
| What We Were Fixing | What We Should Fix |
|
||||
|---------------------|-------------------|
|
||||
| ❌ Duration corruption during transcoding | ✅ **FFmpeg process lifecycle management** |
|
||||
| ❌ Progress bar jitter | ✅ **Seek-optimized transcoding with `--ss`** |
|
||||
| ❌ Anti-jitter mechanisms | ✅ **Process cleanup and resource management** |
|
||||
| ❌ Complex duration validation | ✅ **Simple, reliable FFmpeg process handling** |
|
||||
|
||||
## 🎯 **Stash's Brilliant Approach**
|
||||
|
||||
### **Key Discovery: Process Restart on Seek**
|
||||
|
||||
From your `stash.md` analysis, Stash does something incredibly smart:
|
||||
|
||||
```
|
||||
PID 559: ffmpeg -ss 29.848541666666662 -i video.avi ... (seek to 29.8s)
|
||||
PID 711: ffmpeg -ss 77.374375 -i video.avi ... (seek to 77.3s)
|
||||
PID 741: ffmpeg -ss 103.31072916666666 -i video.avi ... (seek to 103.3s)
|
||||
```
|
||||
|
||||
**Every seek starts a NEW FFmpeg process with `--ss` (start time) parameter!**
|
||||
|
||||
### **Why This is Brilliant**
|
||||
|
||||
1. **🎯 Instant Seeking**: No need to wait for transcoding from beginning
|
||||
2. **🔄 Clean Process Management**: Each seek = new process, old process can be killed
|
||||
3. **💾 Memory Efficiency**: No need to buffer from start to seek point
|
||||
4. **⚡ Performance**: Direct start at desired position
|
||||
5. **🧹 Resource Cleanup**: Easy to kill old processes
|
||||
|
||||
### **Stash's Process Flow**
|
||||
|
||||
```
|
||||
User seeks to 30s → Kill current FFmpeg → Start new FFmpeg with -ss 30
|
||||
User seeks to 60s → Kill current FFmpeg → Start new FFmpeg with -ss 60
|
||||
User seeks to 90s → Kill current FFmpeg → Start new FFmpeg with -ss 90
|
||||
```
|
||||
|
||||
## 🏗️ **Proposed Solution Architecture**
|
||||
|
||||
### **Phase 1: Robust FFmpeg Process Management**
|
||||
|
||||
#### **1.1 Process Registry System**
|
||||
```typescript
|
||||
// lib/ffmpeg/process-registry.ts
|
||||
class FFmpegProcessRegistry {
|
||||
private processes = new Map<string, {
|
||||
process: ChildProcess;
|
||||
startTime: Date;
|
||||
seekTime: number;
|
||||
videoId: string;
|
||||
command: string[];
|
||||
}>();
|
||||
|
||||
// Register new process
|
||||
register(videoId: string, seekTime: number, process: ChildProcess, command: string[]) {
|
||||
const key = `${videoId}_${seekTime}`;
|
||||
|
||||
// Kill existing process for this video if different seek time
|
||||
this.killExisting(videoId, seekTime);
|
||||
|
||||
this.processes.set(key, {
|
||||
process,
|
||||
startTime: new Date(),
|
||||
seekTime,
|
||||
videoId,
|
||||
command
|
||||
});
|
||||
}
|
||||
|
||||
// Kill existing process for video (different seek time)
|
||||
private killExisting(videoId: string, newSeekTime: number) {
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
if (entry.videoId === videoId && entry.seekTime !== newSeekTime) {
|
||||
console.log(`[FFMPEG] Killing existing process for ${videoId} (seek: ${entry.seekTime}s)`);
|
||||
this.killProcess(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kill specific process
|
||||
killProcess(key: string) {
|
||||
const entry = this.processes.get(key);
|
||||
if (entry && !entry.process.killed) {
|
||||
entry.process.kill('SIGKILL');
|
||||
this.processes.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Kill all processes for a video
|
||||
killAllForVideo(videoId: string) {
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
if (entry.videoId === videoId) {
|
||||
this.killProcess(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get process info
|
||||
getProcessInfo(videoId: string) {
|
||||
return Array.from(this.processes.entries())
|
||||
.filter(([_, entry]) => entry.videoId === videoId)
|
||||
.map(([key, entry]) => ({
|
||||
key,
|
||||
seekTime: entry.seekTime,
|
||||
uptime: Date.now() - entry.startTime.getTime(),
|
||||
command: entry.command
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **1.2 Enhanced Transcoding API**
|
||||
```typescript
|
||||
// pages/api/stream/[id]/transcode.ts
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { id } = req.query;
|
||||
const { seek } = req.query; // New: seek time parameter
|
||||
|
||||
const seekTime = seek ? parseFloat(seek as string) : 0;
|
||||
|
||||
// Kill existing process for this video
|
||||
ffmpegRegistry.killAllForVideo(id as string);
|
||||
|
||||
// Build FFmpeg command with seek
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
'-v', 'error',
|
||||
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Add seek if specified
|
||||
'-i', videoPath,
|
||||
'-c:v', 'libx264',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-preset', 'veryfast',
|
||||
'-crf', '25',
|
||||
'-sc_threshold', '0',
|
||||
'-movflags', 'frag_keyframe+empty_moov',
|
||||
'-ac', '2',
|
||||
'-f', 'mp4',
|
||||
'pipe:1'
|
||||
];
|
||||
|
||||
console.log(`[FFMPEG] Starting transcoding for video ${id} with seek: ${seekTime}s`);
|
||||
console.log(`[FFMPEG] Command: ffmpeg ${ffmpegArgs.join(' ')}`);
|
||||
|
||||
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
|
||||
|
||||
// Register process
|
||||
ffmpegRegistry.register(id as string, seekTime, ffmpeg, ffmpegArgs);
|
||||
|
||||
// Handle cleanup
|
||||
req.on('close', () => {
|
||||
console.log(`[FFMPEG] Client disconnected, killing process for video ${id}`);
|
||||
ffmpegRegistry.killAllForVideo(id as string);
|
||||
});
|
||||
|
||||
// Stream response
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.setHeader('X-Content-Duration', videoDuration.toString());
|
||||
res.setHeader('X-Seek-Time', seekTime.toString());
|
||||
|
||||
ffmpeg.stdout.pipe(res);
|
||||
}
|
||||
```
|
||||
|
||||
### **Phase 2: Frontend Seek Integration**
|
||||
|
||||
#### **2.1 Enhanced Video Player**
|
||||
```typescript
|
||||
// components/video-viewer.tsx
|
||||
const handleSeek = (newTime: number) => {
|
||||
if (videoRef.current) {
|
||||
console.log(`[PLAYER] Seeking to ${newTime}s`);
|
||||
|
||||
// Kill current transcoding process
|
||||
const videoId = getVideoId();
|
||||
if (videoId && isTranscoding) {
|
||||
console.log(`[PLAYER] Killing current transcoding process`);
|
||||
// This will trigger a new transcoding process with seek
|
||||
}
|
||||
|
||||
// Update video source with seek parameter
|
||||
const seekUrl = `/api/stream/${videoId}/transcode?seek=${newTime}`;
|
||||
videoRef.current.src = seekUrl;
|
||||
videoRef.current.load();
|
||||
|
||||
// Set current time immediately for UI responsiveness
|
||||
setCurrentTime(newTime);
|
||||
|
||||
// Start new transcoding from seek point
|
||||
setIsTranscoding(true);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### **2.2 Process Monitoring**
|
||||
```typescript
|
||||
// hooks/use-ffmpeg-monitor.ts
|
||||
export const useFFmpegMonitor = (videoId: string) => {
|
||||
const [processInfo, setProcessInfo] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/ffmpeg/status?videoId=${videoId}`);
|
||||
const data = await response.json();
|
||||
setProcessInfo(data.processes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch FFmpeg status:', error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [videoId]);
|
||||
|
||||
return processInfo;
|
||||
};
|
||||
```
|
||||
|
||||
### **Phase 3: Advanced Features**
|
||||
|
||||
#### **3.1 Preemptive Process Management**
|
||||
```typescript
|
||||
// lib/ffmpeg/preemptive-manager.ts
|
||||
class PreemptiveFFmpegManager {
|
||||
// Start transcoding slightly ahead of current playback
|
||||
startPreemptiveTranscoding(videoId: string, currentTime: number) {
|
||||
const aheadTime = currentTime + 30; // 30 seconds ahead
|
||||
|
||||
// Start background process
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-ss', aheadTime.toString(),
|
||||
'-i', videoPath,
|
||||
// ... other args
|
||||
]);
|
||||
|
||||
// Register as preemptive (lower priority)
|
||||
ffmpegRegistry.register(videoId, aheadTime, ffmpeg, [], { preemptive: true });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **3.2 Quality Adaptation**
|
||||
```typescript
|
||||
// lib/ffmpeg/quality-manager.ts
|
||||
class QualityManager {
|
||||
// Adapt quality based on system load
|
||||
getOptimalQuality(systemLoad: number, availableMemory: number) {
|
||||
if (systemLoad > 0.8 || availableMemory < 1000000000) {
|
||||
return { crf: 28, preset: 'ultrafast' }; // Lower quality, faster
|
||||
} else if (systemLoad > 0.5) {
|
||||
return { crf: 25, preset: 'veryfast' }; // Medium quality
|
||||
} else {
|
||||
return { crf: 23, preset: 'fast' }; // Higher quality
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📋 **Implementation Plan**
|
||||
|
||||
### **Week 1: Foundation**
|
||||
- [ ] Implement `FFmpegProcessRegistry` class
|
||||
- [ ] Add seek parameter to transcoding API
|
||||
- [ ] Basic process cleanup on client disconnect
|
||||
|
||||
### **Week 2: Integration**
|
||||
- [ ] Update video player to use seek-optimized transcoding
|
||||
- [ ] Implement process monitoring in frontend
|
||||
- [ ] Add process status API endpoint
|
||||
|
||||
### **Week 3: Advanced Features**
|
||||
- [ ] Preemptive transcoding for smooth playback
|
||||
- [ ] Quality adaptation based on system load
|
||||
- [ ] Process health monitoring and auto-cleanup
|
||||
|
||||
### **Week 4: Testing & Optimization**
|
||||
- [ ] Load testing with multiple concurrent streams
|
||||
- [ ] Memory leak detection and prevention
|
||||
- [ ] Performance optimization
|
||||
|
||||
## 🎯 **Expected Results**
|
||||
|
||||
### **Before (Current Issues)**
|
||||
- ❌ Seeking requires transcoding from beginning
|
||||
- ❌ FFmpeg processes may leak resources
|
||||
- ❌ Poor performance on seek operations
|
||||
- ❌ Complex duration protection (unnecessary)
|
||||
|
||||
### **After (Stash-like Solution)**
|
||||
- ✅ **Instant seeking** with `--ss` parameter
|
||||
- ✅ **Clean process management** - one process per seek
|
||||
- ✅ **Resource efficiency** - no unnecessary buffering
|
||||
- ✅ **Simple architecture** - focus on process management, not duration protection
|
||||
- ✅ **Professional streaming experience** like Stash
|
||||
|
||||
## 🔧 **Key Implementation Details**
|
||||
|
||||
### **1. Process Lifecycle**
|
||||
```
|
||||
Seek Request → Kill Old Process → Start New Process with -ss → Stream Response
|
||||
```
|
||||
|
||||
### **2. Resource Management**
|
||||
- **Memory**: Kill old processes immediately
|
||||
- **CPU**: One active process per video
|
||||
- **Network**: Direct streaming without buffering
|
||||
|
||||
### **3. Error Handling**
|
||||
- **Process failures**: Auto-restart with exponential backoff
|
||||
- **Network issues**: Kill process, let client retry
|
||||
- **System overload**: Reduce quality, limit concurrent processes
|
||||
|
||||
## 🚀 **Why This Approach is Better**
|
||||
|
||||
1. **🎯 Solves Real Problem**: Process management, not duration corruption
|
||||
2. **🔄 Follows Stash Pattern**: Proven approach from successful application
|
||||
3. **⚡ Performance**: Instant seeking, no unnecessary transcoding
|
||||
4. **🧹 Clean Architecture**: Simple, maintainable code
|
||||
5. **📈 Scalable**: Easy to add features like quality adaptation
|
||||
|
||||
This approach transforms our video player from a basic transcoder into a **professional streaming solution** that rivals Stash's performance and reliability.
|
||||
|
||||
## 🎨 **UI Issues That Still Need Solving**
|
||||
|
||||
### **The Remaining Problems**
|
||||
|
||||
Even with solid FFmpeg process management, we still have these critical UI issues:
|
||||
|
||||
1. **❌ Progress Bar Jumping Backwards**: During buffering, the progress bar jumps backward as new data arrives
|
||||
2. **❌ Wrong Duration Display**: Shows buffered duration instead of real video duration
|
||||
3. **❌ Poor User Experience**: Users can't trust the progress bar or seek to accurate positions
|
||||
|
||||
### **Why These Issues Persist**
|
||||
|
||||
These UI issues are **separate from** the FFmpeg process management problem:
|
||||
|
||||
- **FFmpeg Process Management**: Solves seeking performance and resource leaks
|
||||
- **UI Progress Issues**: Caused by how the browser handles video metadata and buffering events
|
||||
|
||||
### **Focused UI Solution Strategy**
|
||||
|
||||
#### **1. Duration Source Priority**
|
||||
```typescript
|
||||
// Priority order for duration (highest to lowest)
|
||||
const getDuration = async (videoId: string) => {
|
||||
// 1. Database-stored duration (most reliable)
|
||||
const dbDuration = await getStoredDuration(videoId);
|
||||
if (dbDuration > 0) return dbDuration;
|
||||
|
||||
// 2. HTTP headers from transcoding endpoint
|
||||
const headerDuration = await getHeaderDuration(videoId);
|
||||
if (headerDuration > 0) return headerDuration;
|
||||
|
||||
// 3. Video element metadata (least reliable - can be buffered duration)
|
||||
return null; // Let video element handle it
|
||||
};
|
||||
```
|
||||
|
||||
#### **2. Progress Bar Anti-Jitter**
|
||||
```typescript
|
||||
// Simple, effective anti-jitter logic
|
||||
const useStableProgress = (videoRef: RefObject<HTMLVideoElement>, realDuration: number) => {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const lastStableTime = useRef(0);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const newTime = videoRef.current.currentTime;
|
||||
|
||||
// Only allow forward progress or very small backward adjustments
|
||||
if (newTime >= lastStableTime.current - 0.1) {
|
||||
setCurrentTime(newTime);
|
||||
lastStableTime.current = newTime;
|
||||
} else {
|
||||
console.log(`[PROGRESS] Blocked backward jump: ${newTime}s -> ${lastStableTime.current}s`);
|
||||
}
|
||||
};
|
||||
|
||||
return { currentTime, handleTimeUpdate };
|
||||
};
|
||||
```
|
||||
|
||||
#### **3. Duration Protection**
|
||||
```typescript
|
||||
// Protect against buffered duration corruption
|
||||
const useProtectedDuration = (videoId: string) => {
|
||||
const [duration, setDuration] = useState(0);
|
||||
const hasRealDuration = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Get real duration from database first
|
||||
const fetchRealDuration = async () => {
|
||||
const realDuration = await getDuration(videoId);
|
||||
if (realDuration > 0) {
|
||||
setDuration(realDuration);
|
||||
hasRealDuration.current = true;
|
||||
console.log(`[DURATION] Set real duration: ${realDuration}s`);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRealDuration();
|
||||
}, [videoId]);
|
||||
|
||||
// Block duration updates from video metadata if we have real duration
|
||||
const handleDurationChange = (newDuration: number) => {
|
||||
if (hasRealDuration.current) {
|
||||
console.log(`[DURATION] Blocked metadata duration: ${newDuration}s (using stored: ${duration}s)`);
|
||||
return; // Keep the real duration
|
||||
}
|
||||
|
||||
// Only accept duration if it's significantly larger (not buffered duration)
|
||||
if (newDuration > duration * 2.0) {
|
||||
setDuration(newDuration);
|
||||
console.log(`[DURATION] Updated duration: ${newDuration}s`);
|
||||
}
|
||||
};
|
||||
|
||||
return { duration, handleDurationChange };
|
||||
};
|
||||
```
|
||||
|
||||
### **Implementation Priority**
|
||||
|
||||
1. **🔥 High Priority**: Fix duration display (show real duration, not buffered)
|
||||
2. **🔥 High Priority**: Fix progress bar backward jumps
|
||||
3. **⚡ Medium Priority**: Integrate with FFmpeg process management
|
||||
4. **🎨 Low Priority**: Enhanced progress bar UI (buffer visualization, etc.)
|
||||
|
||||
### **Why This Approach Works**
|
||||
|
||||
- **🎯 Focused**: Solves specific UI problems without overcomplicating
|
||||
- **🔄 Compatible**: Works alongside the FFmpeg process management solution
|
||||
- **⚡ Simple**: Uses proven patterns (database-first duration, anti-jitter logic)
|
||||
- **🧪 Testable**: Each component can be tested independently
|
||||
|
||||
### **Expected UI Results**
|
||||
|
||||
- ✅ **Accurate Duration**: Always shows real video duration (e.g., 9 minutes, not 6 seconds)
|
||||
- ✅ **Stable Progress**: No more backward jumps during buffering
|
||||
- ✅ **Reliable Seeking**: Seek bar represents actual video timeline
|
||||
- ✅ **Professional Feel**: Progress bar behaves like YouTube/Netflix
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# UI Implementation Plan: Duration and Progress Bar Fixes
|
||||
|
||||
## 🎯 **Goal**
|
||||
Fix two critical UI issues:
|
||||
1. Duration showing buffered duration instead of real video duration
|
||||
2. Progress bar jumping backwards during buffering
|
||||
|
||||
## 🔍 **Root Cause**
|
||||
- **Duration Issue**: Video metadata events fire before real duration is loaded
|
||||
- **Progress Issue**: Browser buffering reports backward timestamps
|
||||
|
||||
## 🏗️ **Solution Strategy**
|
||||
|
||||
### **1. Duration Protection Hook**
|
||||
```typescript
|
||||
// hooks/use-protected-duration.ts
|
||||
export const useProtectedDuration = (videoId: string) => {
|
||||
const [duration, setDuration] = useState(0);
|
||||
const hasRealDuration = useRef(false);
|
||||
|
||||
// Priority: Database > Headers > Video Metadata
|
||||
const fetchRealDuration = async () => {
|
||||
// 1. Try database first
|
||||
const dbDuration = await getStoredDuration(videoId);
|
||||
if (dbDuration > 0) {
|
||||
setDuration(dbDuration);
|
||||
hasRealDuration.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers
|
||||
const headerDuration = await getHeaderDuration(videoId);
|
||||
if (headerDuration > 0) {
|
||||
setDuration(headerDuration);
|
||||
hasRealDuration.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Block metadata duration if we have real duration
|
||||
const handleDurationChange = (newDuration: number) => {
|
||||
if (hasRealDuration.current) {
|
||||
return; // Keep real duration
|
||||
}
|
||||
// Only accept significantly larger durations
|
||||
if (newDuration > duration * 2.0) {
|
||||
setDuration(newDuration);
|
||||
}
|
||||
};
|
||||
|
||||
return { duration, handleDurationChange };
|
||||
};
|
||||
```
|
||||
|
||||
### **2. Stable Progress Hook**
|
||||
```typescript
|
||||
// hooks/use-stable-progress.ts
|
||||
export const useStableProgress = (videoRef: RefObject<HTMLVideoElement>) => {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const lastStableTime = useRef(0);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const newTime = videoRef.current.currentTime;
|
||||
|
||||
// Prevent backward jumps
|
||||
if (newTime < lastStableTime.current - 0.1) {
|
||||
console.log(`[PROGRESS] Blocked backward jump: ${newTime}s -> ${lastStableTime.current}s`);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(newTime);
|
||||
lastStableTime.current = newTime;
|
||||
};
|
||||
|
||||
return { currentTime, handleTimeUpdate };
|
||||
};
|
||||
```
|
||||
|
||||
## 📋 **Implementation Steps**
|
||||
|
||||
### **Day 1: Create Hooks**
|
||||
- [ ] Create `use-protected-duration.ts`
|
||||
- [ ] Create `use-stable-progress.ts`
|
||||
- [ ] Test hooks independently
|
||||
|
||||
### **Day 2: Update Video Viewer**
|
||||
- [ ] Remove old duration/progress logic
|
||||
- [ ] Integrate new hooks
|
||||
- [ ] Test duration protection
|
||||
|
||||
### **Day 3: Test and Debug**
|
||||
- [ ] Test with direct videos
|
||||
- [ ] Test with transcoded streams
|
||||
- [ ] Verify no backward jumps
|
||||
|
||||
## 🎯 **Expected Results**
|
||||
- ✅ Duration always shows real video length (9 min, not 6 sec)
|
||||
- ✅ Progress bar never jumps backward
|
||||
- ✅ Professional streaming experience
|
||||
- ✅ Simple, maintainable code
|
||||
|
||||
## 🚀 **Next Steps**
|
||||
After UI fixes, implement FFmpeg process management for seek optimization.
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
ffprobe
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
339 root 0:33 /usr/bin/ffmpeg -hide_banner -v error -i /mnt/thd_media_f/Porn/分类合集精选/KINGMASTER/
|
||||
370 root 0:00 ps aux
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
371 root 0:11 /usr/bin/ffmpeg -hide_banner -v error -ss 93.56497005988024 -i /mnt/thd_media_f/Porn/分类
|
||||
402 root 0:00 ps aux
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
403 root 0:13 /usr/bin/ffmpeg -hide_banner -v error -ss 129.2849301397206 -i /mnt/thd_media_f/Porn/分类
|
||||
434 root 0:00 ps aux
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
435 root 0:07 /usr/bin/ffmpeg -hide_banner -v error -ss 183.66457085828344 -i /mnt/thd_media_f/Porn/分类
|
||||
466 root 0:00 ps aux
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
467 root 0:09 /usr/bin/ffmpeg -hide_banner -v error -ss 223.11646706586828 -i /mnt/thd_media_f/Porn/分类
|
||||
498 root 0:00 ps aux
|
||||
|
||||
|
||||
|
||||
----------more detail---
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 3:06 stash
|
||||
260 root 0:00 /bin/sh
|
||||
559 root 0:43 /usr/bin/ffmpeg -hide_banner -v error -ss 29.848541666666662 -i /mnt/thd_media_f/Porn/
|
||||
706 root 0:00 ps aux
|
||||
/usr/bin # cat /proc/503/cmdline | tr '\0' ' '
|
||||
cat: can't open '/proc/503/cmdline': No such file or directory
|
||||
/usr/bin # cat /proc/559/cmdline | tr '\0' ' '
|
||||
/usr/bin/ffmpeg -hide_banner -v error -ss 29.848541666666662 -i /mnt/thd_media_f/Porn/分类合集精选/uuuuuuuu.avi -c:v libx264 -pix_fmt yuv420p -preset veryfast -crf 25 -sc_threshold 0 -movflags frag_keyframe+empty_moov -ac 2 -f mp4 pipe: /usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 3:06 stash
|
||||
260 root 0:00 /bin/sh
|
||||
711 root 0:08 /usr/bin/ffmpeg -hide_banner -v error -ss 77.374375 -i /mnt/thd_media_f/Porn/分类合
|
||||
738 root 0:00 ps aux
|
||||
/usr/bin # cat /proc/711/cmdline | tr '\0' ' '
|
||||
/usr/bin/ffmpeg -hide_banner -v error -ss 77.374375 -i /mnt/thd_media_f/Porn/分类合集精选/uuuuuuuu.avi -c:v libx264 -pix_fmt yuv420p -preset veryfast -crf 25 -sc_threshold 0 -movflags frag_keyframe+empty_moov -ac 2 -f mp4 pipe: /usr/bin #
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 3:06 stash
|
||||
260 root 0:00 /bin/sh
|
||||
741 root 0:07 /usr/bin/ffmpeg -hide_banner -v error -ss 103.31072916666666 -i /mnt/thd_media_f/Porn/
|
||||
768 root 0:00 ps aux
|
||||
/usr/bin # cat /proc/741/cmdline | tr '\0' ' '
|
||||
/usr/bin/ffmpeg -hide_banner -v error -ss 103.31072916666666 -i /mnt/thd_media_f/Porn/分类合集精选/uuuuuuuu.avi -c:v libx264 -pix_fmt yuv420p -preset veryfast -crf 25 -sc_threshold 0 -movflags frag_keyframe+empty_moov -ac 2 -f mp4 pipe: /usr/bin #
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
|
||||
|
||||
/**
|
||||
* GET /api/ffmpeg/status
|
||||
*
|
||||
* Returns the current status of all FFmpeg processes.
|
||||
*
|
||||
* Query parameters:
|
||||
* - videoId: Filter processes by video ID
|
||||
* - includeStats: Include additional statistics (default: false)
|
||||
* - format: Response format - 'json' or 'table' (default: 'json')
|
||||
*
|
||||
* Response format:
|
||||
* {
|
||||
* "totalProcesses": 3,
|
||||
* "activeProcesses": [...],
|
||||
* "stats": {
|
||||
* "totalUptime": 12345,
|
||||
* "averageSeekTime": 120.5,
|
||||
* "mostActiveVideo": "video_123"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const videoId = searchParams.get('videoId');
|
||||
const includeStats = searchParams.get('includeStats') === 'true';
|
||||
const format = searchParams.get('format') || 'json';
|
||||
|
||||
let processes;
|
||||
if (videoId) {
|
||||
processes = ffmpegRegistry.getProcessesForVideo(videoId);
|
||||
} else {
|
||||
processes = ffmpegRegistry.getAllProcesses();
|
||||
}
|
||||
|
||||
const totalProcesses = ffmpegRegistry.getTotalProcessCount();
|
||||
|
||||
if (format === 'table') {
|
||||
// Return a simple text table format for debugging
|
||||
const table = formatAsTable(processes);
|
||||
return new Response(table, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const response: any = {
|
||||
totalProcesses,
|
||||
activeProcesses: processes,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (includeStats) {
|
||||
response.stats = calculateStats(processes);
|
||||
}
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('FFmpeg status API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ffmpeg/status
|
||||
*
|
||||
* Cleanup FFmpeg processes
|
||||
* Query parameters:
|
||||
* - videoId: Kill all processes for specific video ID
|
||||
* - stale: Kill stale processes older than maxAge (default: false)
|
||||
* - maxAge: Maximum age in milliseconds for stale cleanup (default: 10min)
|
||||
* - all: Kill all processes (default: false)
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const videoId = searchParams.get('videoId');
|
||||
const stale = searchParams.get('stale') === 'true';
|
||||
const all = searchParams.get('all') === 'true';
|
||||
const maxAge = parseInt(searchParams.get('maxAge') || '600000'); // 10 minutes
|
||||
|
||||
let killedCount = 0;
|
||||
|
||||
if (all) {
|
||||
killedCount = ffmpegRegistry.cleanupAll();
|
||||
} else if (videoId) {
|
||||
killedCount = ffmpegRegistry.killAllForVideo(videoId);
|
||||
} else if (stale) {
|
||||
killedCount = ffmpegRegistry.cleanupStaleProcesses(maxAge);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
killedCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('FFmpeg cleanup API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ffmpeg/status
|
||||
*
|
||||
* Trigger cleanup operations
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { operation, videoId, maxAge } = body;
|
||||
|
||||
let result;
|
||||
|
||||
switch (operation) {
|
||||
case 'cleanup':
|
||||
result = ffmpegRegistry.cleanupStaleProcesses(maxAge || 600000);
|
||||
break;
|
||||
case 'killAll':
|
||||
result = ffmpegRegistry.cleanupAll();
|
||||
break;
|
||||
case 'killVideo':
|
||||
if (!videoId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'videoId is required for killVideo operation' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
result = ffmpegRegistry.killAllForVideo(videoId);
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid operation. Use: cleanup, killAll, or killVideo' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
operation,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('FFmpeg operation API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS handler for CORS
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate statistics for active processes
|
||||
*/
|
||||
function calculateStats(processes: any[]) {
|
||||
if (processes.length === 0) {
|
||||
return {
|
||||
totalUptime: 0,
|
||||
averageSeekTime: 0,
|
||||
mostActiveVideo: null,
|
||||
processCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totalUptime = processes.reduce((sum, p) => sum + p.uptime, 0);
|
||||
const averageSeekTime = processes.reduce((sum, p) => sum + p.seekTime, 0) / processes.length;
|
||||
|
||||
// Find most active video
|
||||
const videoCounts = processes.reduce((acc, p) => {
|
||||
acc[p.videoId] = (acc[p.videoId] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const mostActiveVideo = Object.entries(videoCounts)
|
||||
.sort(([, a], [, b]) => (b as number) - (a as number))[0]?.[0] || null;
|
||||
|
||||
return {
|
||||
totalUptime,
|
||||
averageSeekTime,
|
||||
mostActiveVideo,
|
||||
processCount: processes.length,
|
||||
videoCounts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format processes as a simple text table for debugging
|
||||
*/
|
||||
function formatAsTable(processes: any[]): string {
|
||||
if (processes.length === 0) {
|
||||
return 'No active FFmpeg processes';
|
||||
}
|
||||
|
||||
const header = 'VIDEO ID'.padEnd(10) + 'SEEK'.padEnd(8) + 'UPTIME'.padEnd(12) + 'QUALITY'.padEnd(8);
|
||||
const separator = '-'.repeat(header.length);
|
||||
|
||||
const rows = processes.map(p => {
|
||||
const uptime = formatUptime(p.uptime);
|
||||
const seek = p.seekTime.toFixed(1).padStart(6) + 's';
|
||||
const quality = (p.quality || 'default').padEnd(7);
|
||||
return p.videoId.padEnd(10) + seek.padEnd(8) + uptime.padEnd(12) + quality;
|
||||
});
|
||||
|
||||
return [header, separator, ...rows].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format uptime in a human-readable way
|
||||
*/
|
||||
function formatUptime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { getDatabase } from '@/db';
|
|||
import fs from 'fs';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { Readable } from 'stream';
|
||||
import { processManager } from '@/lib/process-manager';
|
||||
import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
|
||||
|
||||
export async function OPTIONS(
|
||||
request: NextRequest,
|
||||
|
|
@ -68,9 +68,15 @@ export async function GET(
|
|||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get quality parameter
|
||||
// Get parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const quality = searchParams.get('quality') || '720p';
|
||||
const seek = searchParams.get('seek') || '0';
|
||||
const seekTime = parseFloat(seek);
|
||||
|
||||
// Kill existing processes for this video (Stash-like behavior)
|
||||
console.log(`[TRANSCODE] Killing existing processes for video ${id}`);
|
||||
ffmpegRegistry.killAllForVideo(id);
|
||||
|
||||
// Configure transcoding based on quality
|
||||
const qualitySettings = {
|
||||
|
|
@ -84,6 +90,35 @@ export async function GET(
|
|||
// Create a readable stream from FFmpeg
|
||||
console.log(`[TRANSCODE] Starting FFmpeg process for: ${filePath}`);
|
||||
|
||||
// Build FFmpeg command with seek support
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
'-v', 'error',
|
||||
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek support like Stash
|
||||
'-i', filePath,
|
||||
'-c:v', 'libx264',
|
||||
'-c:a', 'aac',
|
||||
'-b:v', settings.bitrate,
|
||||
'-s', `${settings.width}x${settings.height}`,
|
||||
'-preset', 'fast',
|
||||
'-crf', '23',
|
||||
'-movflags', 'frag_keyframe+empty_moov+faststart',
|
||||
'-f', 'mp4',
|
||||
'-g', '60',
|
||||
'-keyint_min', '60',
|
||||
'-sc_threshold', '0',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-profile:v', 'baseline',
|
||||
'-level', '3.0',
|
||||
'-map_metadata', '0',
|
||||
'-map_metadata:s:v', '0:s:v',
|
||||
'-map_metadata:s:a', '0:s:a',
|
||||
'-fflags', '+genpts',
|
||||
'-avoid_negative_ts', 'make_zero'
|
||||
];
|
||||
|
||||
console.log(`[TRANSCODE] FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}`);
|
||||
|
||||
const ffmpegCommand = ffmpeg(filePath)
|
||||
.format('mp4')
|
||||
.videoCodec('libx264')
|
||||
|
|
@ -94,7 +129,6 @@ export async function GET(
|
|||
'-preset', 'fast',
|
||||
'-crf', '23',
|
||||
'-movflags', 'frag_keyframe+empty_moov+faststart',
|
||||
'-f', 'mp4',
|
||||
'-g', '60',
|
||||
'-keyint_min', '60',
|
||||
'-sc_threshold', '0',
|
||||
|
|
@ -130,30 +164,22 @@ export async function GET(
|
|||
// Create a readable stream
|
||||
const stream = ffmpegCommand.pipe();
|
||||
|
||||
// Track FFmpeg process for cleanup
|
||||
// Track FFmpeg process for cleanup with seek support
|
||||
let ffmpegProcess: any = null;
|
||||
let processId = `transcode_${id}_${Date.now()}`;
|
||||
let processId = `transcode_${id}_${seekTime}_${Date.now()}`;
|
||||
|
||||
ffmpegCommand.on('start', (commandLine) => {
|
||||
// Store process reference for cleanup
|
||||
ffmpegProcess = (ffmpegCommand as any).ffmpegProc;
|
||||
|
||||
// Register process with process manager
|
||||
const cleanup = () => {
|
||||
if (ffmpegProcess) {
|
||||
try {
|
||||
console.log(`[TRANSCODE] Cleaning up FFmpeg process ${processId}`);
|
||||
ffmpegProcess.kill('SIGKILL');
|
||||
} catch (error) {
|
||||
console.error(`[TRANSCODE] Error killing FFmpeg process:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processManager.register(processId, ffmpegProcess, id, cleanup);
|
||||
// Register process with enhanced registry
|
||||
if (ffmpegProcess) {
|
||||
ffmpegRegistry.register(id, seekTime, ffmpegProcess, ffmpegArgs, quality);
|
||||
console.log(`[TRANSCODE] Registered FFmpeg process for video ${id} with seek ${seekTime}s`);
|
||||
}
|
||||
});
|
||||
|
||||
// Set response headers for streaming with duration
|
||||
// Set response headers for streaming with duration and seek info
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'video/mp4',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
|
|
@ -163,6 +189,7 @@ export async function GET(
|
|||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'X-Content-Duration': duration.toString(),
|
||||
'X-Seek-Time': seekTime.toString(),
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
// Add additional headers for better streaming
|
||||
'Accept-Ranges': 'bytes',
|
||||
|
|
@ -172,14 +199,22 @@ export async function GET(
|
|||
// Convert Node.js stream to Web Stream for Next.js
|
||||
const readableStream = Readable.toWeb(stream as any) as ReadableStream;
|
||||
|
||||
console.log(`[TRANSCODE] Sending response with ${settings.width}x${settings.height} video stream, duration: ${duration}s`);
|
||||
console.log(`[TRANSCODE] Sending response with ${settings.width}x${settings.height} video stream, duration: ${duration}s, seek: ${seekTime}s`);
|
||||
|
||||
// Create response
|
||||
// Create response with cleanup
|
||||
const response = new Response(readableStream, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Cleanup when response is closed
|
||||
response.body?.getReader().closed.then(() => {
|
||||
console.log(`[TRANSCODE] Response closed, cleaning up processes for video ${id}`);
|
||||
ffmpegRegistry.killAllForVideo(id);
|
||||
}).catch((error) => {
|
||||
console.error(`[TRANSCODE] Error during response cleanup:`, error);
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -199,8 +234,9 @@ export async function DELETE(
|
|||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Use process manager to cleanup all processes for this video ID
|
||||
processManager.removeByVideoId(id);
|
||||
// Use enhanced registry to cleanup all processes for this video ID
|
||||
const killedCount = ffmpegRegistry.killAllForVideo(id);
|
||||
console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useState, useEffect, useRef } from 'react';
|
|||
import { X, Play, Pause, Volume2, VolumeX, Maximize, Star, Bookmark } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useProtectedDuration } from '@/lib/hooks/use-protected-duration';
|
||||
import { useStableProgress, formatTime } from '@/lib/hooks/use-stable-progress';
|
||||
|
||||
interface Video {
|
||||
id: number;
|
||||
|
|
@ -53,13 +55,35 @@ export default function VideoViewer({
|
|||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
const [bookmarkCount, setBookmarkCount] = useState(0);
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
|
||||
// Use protected duration hook for accurate duration display
|
||||
const {
|
||||
duration,
|
||||
isLoading: isDurationLoading,
|
||||
error: durationError,
|
||||
handleDurationChange: protectedHandleDurationChange,
|
||||
refreshDuration
|
||||
} = useProtectedDuration({
|
||||
videoId: video && 'id' in video && video.id !== undefined ? video.id.toString() : ''
|
||||
});
|
||||
|
||||
// Use stable progress hook for anti-jitter
|
||||
const {
|
||||
currentTime,
|
||||
bufferState,
|
||||
isDragging,
|
||||
handleTimeUpdate: stableHandleTimeUpdate,
|
||||
handleProgress: stableHandleProgress,
|
||||
handleSeek: stableHandleSeek,
|
||||
handleSeekStart: stableHandleSeekStart,
|
||||
handleSeekEnd: stableHandleSeekEnd,
|
||||
resetProgress
|
||||
} = useStableProgress(videoRef, duration);
|
||||
|
||||
// Heartbeat mechanism
|
||||
const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
|
|
@ -139,6 +163,10 @@ export default function VideoViewer({
|
|||
const videoId = getVideoId();
|
||||
if (!videoId) return;
|
||||
|
||||
// Reset hooks for new video
|
||||
resetProgress();
|
||||
// Let the useProtectedDuration hook handle duration fetching internally
|
||||
|
||||
videoRef.current.src = `/api/stream/${videoId}`;
|
||||
videoRef.current.load();
|
||||
|
||||
|
|
@ -163,41 +191,24 @@ export default function VideoViewer({
|
|||
}
|
||||
};
|
||||
|
||||
// Handle metadata loaded to get duration
|
||||
// Handle metadata loaded to get duration (with protection)
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
|
||||
console.log(`[PLAYER] Duration from metadata: ${videoDuration}s`);
|
||||
setDuration(videoDuration);
|
||||
console.log(`[PLAYER] Metadata duration: ${videoDuration}s`);
|
||||
protectedHandleDurationChange(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle response headers to get duration for transcoded streams
|
||||
const handleResponseHeaders = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stream/${videoId}${isTranscoding ? '/transcode' : ''}`);
|
||||
const contentDuration = response.headers.get('X-Content-Duration');
|
||||
if (contentDuration) {
|
||||
const durationValue = parseFloat(contentDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
console.log(`[PLAYER] Duration from headers: ${durationValue}s (transcoded: ${isTranscoding})`);
|
||||
setDuration(durationValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not fetch duration from headers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle duration change events
|
||||
// Handle duration change events (with protection)
|
||||
const handleDurationChange = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
|
||||
console.log(`[PLAYER] Duration changed: ${videoDuration}s`);
|
||||
setDuration(videoDuration);
|
||||
console.log(`[PLAYER] Duration change: ${videoDuration}s`);
|
||||
protectedHandleDurationChange(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -207,9 +218,6 @@ export default function VideoViewer({
|
|||
videoRef.current.addEventListener('durationchange', handleDurationChange);
|
||||
videoRef.current.addEventListener('error', handleError);
|
||||
|
||||
// Try to get duration from headers
|
||||
handleResponseHeaders();
|
||||
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener('loadeddata', handleLoadedData);
|
||||
|
|
@ -224,31 +232,30 @@ export default function VideoViewer({
|
|||
}
|
||||
}, [isOpen, video, isTranscoding]);
|
||||
|
||||
// Fetch duration when transcoding state changes
|
||||
// Separate effect for hook event listeners to avoid infinite re-renders
|
||||
useEffect(() => {
|
||||
if (isTranscoding) {
|
||||
const videoId = getVideoId();
|
||||
if (videoId) {
|
||||
const fetchTranscodedDuration = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const contentDuration = response.headers.get('X-Content-Duration');
|
||||
if (contentDuration) {
|
||||
const durationValue = parseFloat(contentDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
console.log(`[PLAYER] Transcoding duration: ${durationValue}s`);
|
||||
setDuration(durationValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not fetch transcoded duration:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTranscodedDuration();
|
||||
}
|
||||
if (!isOpen || !videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
// Add event listeners for the hooks
|
||||
video.addEventListener('timeupdate', stableHandleTimeUpdate);
|
||||
video.addEventListener('progress', stableHandleProgress);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', stableHandleTimeUpdate);
|
||||
video.removeEventListener('progress', stableHandleProgress);
|
||||
};
|
||||
}, [isOpen]); // Only depend on isOpen, not the functions
|
||||
|
||||
// Reset hooks when video changes
|
||||
useEffect(() => {
|
||||
if (isOpen && video) {
|
||||
resetProgress();
|
||||
// Don't call refreshDuration here - let the hook handle it internally
|
||||
}
|
||||
}, [isTranscoding, video]);
|
||||
}, [isOpen, video]); // Remove function dependencies
|
||||
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
|
|
@ -323,26 +330,24 @@ export default function VideoViewer({
|
|||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0) {
|
||||
setDuration(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleSeek = (newTime: number) => {
|
||||
const videoId = getVideoId();
|
||||
if (!videoId || !videoRef.current) return;
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = parseFloat(e.target.value);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
// For transcoded videos, use seek-optimized transcoding
|
||||
if (isTranscoding) {
|
||||
console.log(`[PLAYER] Seek-optimized transcoding to ${newTime}s`);
|
||||
|
||||
// Kill current transcoding process
|
||||
fetch(`/api/stream/${videoId}`, { method: 'DELETE' });
|
||||
|
||||
// Start new transcoding with seek parameter
|
||||
videoRef.current.src = `/api/stream/${videoId}/transcode?seek=${newTime}`;
|
||||
videoRef.current.load();
|
||||
} else {
|
||||
// Direct video seeking
|
||||
stableHandleSeek(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -415,11 +420,6 @@ export default function VideoViewer({
|
|||
return 0;
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!isOpen || typeof window === 'undefined') return null;
|
||||
|
||||
|
|
@ -451,8 +451,6 @@ export default function VideoViewer({
|
|||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onMouseMove={() => setShowControls(true)}
|
||||
|
|
@ -468,20 +466,54 @@ export default function VideoViewer({
|
|||
|
||||
{/* Controls overlay */}
|
||||
<div className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Progress bar */}
|
||||
{/* Enhanced Progress bar with buffer visualization */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
<div className="relative w-full h-2 bg-gray-600 rounded-lg overflow-hidden">
|
||||
{/* Buffer indicator */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-400/30 rounded-lg"
|
||||
style={{
|
||||
width: `${Math.min((bufferState.buffered / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-500 rounded-lg transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min((currentTime / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
{/* Seek input overlay */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={(e) => handleSeek(parseFloat(e.target.value))}
|
||||
onMouseDown={stableHandleSeekStart}
|
||||
onMouseUp={stableHandleSeekEnd}
|
||||
disabled={isDurationLoading}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-white text-sm mt-1">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
{isDurationLoading ? (
|
||||
<span className="text-gray-400">Loading...</span>
|
||||
) : (
|
||||
<span>{formatTime(duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Buffer status */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div className="text-xs text-blue-300 mt-1">
|
||||
Buffered: {formatTime(bufferState.buffered)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video Info Bar (similar to photo viewer) */}
|
||||
|
|
@ -491,7 +523,9 @@ export default function VideoViewer({
|
|||
<h3 className="text-white font-medium">{getVideoTitle()}</h3>
|
||||
<p className="text-gray-300 text-sm">{getVideoSize()}</p>
|
||||
{duration > 0 && (
|
||||
<p className="text-gray-300 text-sm">Duration: {formatTime(duration)}</p>
|
||||
<p className="text-gray-300 text-sm">Duration: {formatTime(duration)}
|
||||
{isTranscoding && <span className="text-yellow-400 ml-1">(Transcoded)</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(showBookmarks || showRatings) && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
import { ChildProcess } from 'child_process';
|
||||
|
||||
interface FFmpegProcessInfo {
|
||||
process: ChildProcess;
|
||||
startTime: Date;
|
||||
seekTime: number;
|
||||
videoId: string;
|
||||
command: string[];
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced FFmpeg Process Registry for seek-optimized transcoding
|
||||
* Inspired by Stash's approach: kill old processes, start new ones with -ss parameter
|
||||
*/
|
||||
export class FFmpegProcessRegistry {
|
||||
private processes = new Map<string, FFmpegProcessInfo>();
|
||||
|
||||
/**
|
||||
* Register a new FFmpeg process
|
||||
* @param videoId The video ID
|
||||
* @param seekTime The seek time in seconds
|
||||
* @param process The FFmpeg child process
|
||||
* @param command The FFmpeg command arguments
|
||||
* @param quality Optional quality setting
|
||||
*/
|
||||
register(
|
||||
videoId: string,
|
||||
seekTime: number,
|
||||
process: ChildProcess,
|
||||
command: string[],
|
||||
quality?: string
|
||||
): string {
|
||||
const key = `${videoId}_${seekTime}_${quality || 'default'}`;
|
||||
|
||||
// Kill existing process for this video if different seek time
|
||||
this.killExisting(videoId, seekTime, quality);
|
||||
|
||||
this.processes.set(key, {
|
||||
process,
|
||||
startTime: new Date(),
|
||||
seekTime,
|
||||
videoId,
|
||||
command,
|
||||
quality
|
||||
});
|
||||
|
||||
console.log(`[FFMPEG_REGISTRY] Registered process: ${key} (seek: ${seekTime}s)`);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill existing processes for a video that have different seek times
|
||||
*/
|
||||
private killExisting(videoId: string, newSeekTime: number, quality?: string): void {
|
||||
const processesToKill: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
const [entryVideoId, entrySeekTime, entryQuality] = key.split('_');
|
||||
|
||||
if (entryVideoId === videoId &&
|
||||
(entrySeekTime !== newSeekTime.toString() ||
|
||||
(entryQuality && entryQuality !== (quality || 'default')))) {
|
||||
processesToKill.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
processesToKill.forEach(key => this.killProcess(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a specific process
|
||||
*/
|
||||
killProcess(key: string): boolean {
|
||||
const entry = this.processes.get(key);
|
||||
if (entry && !entry.process.killed) {
|
||||
try {
|
||||
console.log(`[FFMPEG_REGISTRY] Killing process: ${key}`);
|
||||
entry.process.kill('SIGKILL');
|
||||
this.processes.delete(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[FFMPEG_REGISTRY] Error killing process ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all processes for a specific video
|
||||
*/
|
||||
killAllForVideo(videoId: string): number {
|
||||
const processesToKill: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
if (entry.videoId === videoId) {
|
||||
processesToKill.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let killedCount = 0;
|
||||
processesToKill.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
killedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (killedCount > 0) {
|
||||
console.log(`[FFMPEG_REGISTRY] Killed ${killedCount} processes for video: ${videoId}`);
|
||||
}
|
||||
|
||||
return killedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active processes for a video
|
||||
*/
|
||||
getProcessesForVideo(videoId: string): Array<{
|
||||
key: string;
|
||||
seekTime: number;
|
||||
uptime: number;
|
||||
command: string[];
|
||||
quality?: string;
|
||||
}> {
|
||||
return Array.from(this.processes.entries())
|
||||
.filter(([_, entry]) => entry.videoId === videoId)
|
||||
.map(([key, entry]) => ({
|
||||
key,
|
||||
seekTime: entry.seekTime,
|
||||
uptime: Date.now() - entry.startTime.getTime(),
|
||||
command: entry.command,
|
||||
quality: entry.quality
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active processes
|
||||
*/
|
||||
getAllProcesses(): Array<{
|
||||
key: string;
|
||||
videoId: string;
|
||||
seekTime: number;
|
||||
uptime: number;
|
||||
command: string[];
|
||||
quality?: string;
|
||||
}> {
|
||||
return Array.from(this.processes.entries()).map(([key, entry]) => ({
|
||||
key,
|
||||
videoId: entry.videoId,
|
||||
seekTime: entry.seekTime,
|
||||
uptime: Date.now() - entry.startTime.getTime(),
|
||||
command: entry.command,
|
||||
quality: entry.quality
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get process count for a video
|
||||
*/
|
||||
getProcessCountForVideo(videoId: string): number {
|
||||
return Array.from(this.processes.values())
|
||||
.filter(entry => entry.videoId === videoId)
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total process count
|
||||
*/
|
||||
getTotalProcessCount(): number {
|
||||
return this.processes.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale processes (older than maxAge milliseconds)
|
||||
*/
|
||||
cleanupStaleProcesses(maxAge: number = 10 * 60 * 1000): number {
|
||||
const now = Date.now();
|
||||
const staleProcesses: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
if (now - entry.startTime.getTime() > maxAge) {
|
||||
staleProcesses.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let cleanedCount = 0;
|
||||
staleProcesses.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
cleanedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`[FFMPEG_REGISTRY] Cleaned up ${cleanedCount} stale processes`);
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all processes
|
||||
*/
|
||||
cleanupAll(): number {
|
||||
const processKeys = Array.from(this.processes.keys());
|
||||
let cleanedCount = 0;
|
||||
|
||||
processKeys.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
cleanedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[FFMPEG_REGISTRY] Cleaned up all ${cleanedCount} processes`);
|
||||
return cleanedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ffmpegRegistry = new FFmpegProcessRegistry();
|
||||
|
||||
// Cleanup on process exit
|
||||
if (typeof process !== 'undefined') {
|
||||
process.on('exit', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface UseProtectedDurationOptions {
|
||||
videoId?: string;
|
||||
fallbackDuration?: number;
|
||||
}
|
||||
|
||||
interface UseProtectedDurationReturn {
|
||||
duration: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
handleDurationChange: (newDuration: number) => void;
|
||||
refreshDuration: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration protection hook that ensures real video duration is displayed
|
||||
* instead of buffered duration. Uses existing codec_info from database.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Database-stored duration (codec_info.duration) - most reliable
|
||||
* 2. HTTP headers from transcoding endpoint
|
||||
* 3. Video element metadata (fallback, can be buffered duration)
|
||||
*/
|
||||
export function useProtectedDuration({
|
||||
videoId,
|
||||
fallbackDuration = 0
|
||||
}: UseProtectedDurationOptions): UseProtectedDurationReturn {
|
||||
const [duration, setDuration] = useState(fallbackDuration);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasRealDuration = useRef(false);
|
||||
const lastFetchedVideoId = useRef<string | null>(null);
|
||||
|
||||
/**
|
||||
* Handle duration changes from video element
|
||||
* Only accept significant changes if we don't have real duration
|
||||
*/
|
||||
const handleDurationChange = useCallback((newDuration: number): void => {
|
||||
// If we have real duration from database/headers, ignore metadata changes
|
||||
if (hasRealDuration.current) {
|
||||
console.log(`[DURATION] Blocked metadata duration: ${newDuration}s (using stored: ${duration}s)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only accept significantly larger durations (not buffered durations)
|
||||
if (newDuration > duration * 2.0 && newDuration > 0) {
|
||||
console.log(`[DURATION] Accepted metadata duration: ${newDuration}s`);
|
||||
setDuration(newDuration);
|
||||
hasRealDuration.current = true; // Mark as having real duration
|
||||
} else if (newDuration <= 0) {
|
||||
console.log(`[DURATION] Ignored invalid duration: ${newDuration}s`);
|
||||
} else {
|
||||
console.log(`[DURATION] Ignored buffered duration: ${newDuration}s (current: ${duration}s)`);
|
||||
}
|
||||
}, [duration]); // Only depend on duration
|
||||
|
||||
/**
|
||||
* Refresh duration from database
|
||||
*/
|
||||
const refreshDuration = useCallback(async (): Promise<void> => {
|
||||
hasRealDuration.current = false; // Reset to allow re-fetch
|
||||
lastFetchedVideoId.current = null; // Force re-fetch by clearing the cache
|
||||
|
||||
// Re-run the effect by updating a dummy state
|
||||
setError(null);
|
||||
}, []); // No dependencies to prevent re-creation
|
||||
|
||||
// Fetch duration when videoId changes
|
||||
useEffect(() => {
|
||||
if (videoId && videoId !== lastFetchedVideoId.current) {
|
||||
hasRealDuration.current = false; // Reset for new video
|
||||
|
||||
const fetchDuration = async () => {
|
||||
if (!videoId) {
|
||||
setDuration(fallbackDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Try database first (codec_info has real duration)
|
||||
const response = await fetch(`/api/videos/${videoId}`);
|
||||
if (response.ok) {
|
||||
const videoData = await response.json();
|
||||
console.log(`[DURATION] Video data:`, videoData);
|
||||
if (videoData.codec_info) {
|
||||
const codecInfo = JSON.parse(videoData.codec_info);
|
||||
console.log(`[DURATION] Codec info:`, codecInfo);
|
||||
if (codecInfo.duration && codecInfo.duration > 0) {
|
||||
setDuration(codecInfo.duration);
|
||||
hasRealDuration.current = true;
|
||||
console.log(`[DURATION] Using database duration: ${codecInfo.duration}s`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[DURATION] API response not ok:`, response.status, response.statusText);
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers if transcoding
|
||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
||||
if (headerDuration) {
|
||||
const durationValue = parseFloat(headerDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
setDuration(durationValue);
|
||||
hasRealDuration.current = true;
|
||||
console.log(`[DURATION] Using header duration: ${durationValue}s`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use fallback
|
||||
console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`);
|
||||
setDuration(fallbackDuration);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DURATION] Error fetching duration:', error);
|
||||
setError(error instanceof Error ? error.message : 'Unknown error');
|
||||
setDuration(fallbackDuration);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDuration();
|
||||
lastFetchedVideoId.current = videoId;
|
||||
}
|
||||
}, [videoId, fallbackDuration]); // Simple dependencies
|
||||
|
||||
return {
|
||||
duration,
|
||||
isLoading,
|
||||
error,
|
||||
handleDurationChange,
|
||||
refreshDuration
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get duration directly from API
|
||||
*/
|
||||
export async function getRealDuration(videoId: string): Promise<number> {
|
||||
try {
|
||||
// 1. Try database first
|
||||
const response = await fetch(`/api/videos/${videoId}`);
|
||||
if (response.ok) {
|
||||
const videoData = await response.json();
|
||||
if (videoData.codec_info) {
|
||||
const codecInfo = JSON.parse(videoData.codec_info);
|
||||
if (codecInfo.duration && codecInfo.duration > 0) {
|
||||
return codecInfo.duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers
|
||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
||||
if (headerDuration) {
|
||||
const durationValue = parseFloat(headerDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
return durationValue;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error('[DURATION] Error getting real duration:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
import { useState, useRef, useEffect, useCallback, RefObject } from 'react';
|
||||
|
||||
interface BufferState {
|
||||
buffered: number;
|
||||
lastBufferUpdate: number;
|
||||
}
|
||||
|
||||
interface UseStableProgressReturn {
|
||||
currentTime: number;
|
||||
bufferState: BufferState;
|
||||
isDragging: boolean;
|
||||
handleTimeUpdate: () => void;
|
||||
handleProgress: () => void;
|
||||
handleSeek: (time: number) => void;
|
||||
handleSeekStart: () => void;
|
||||
handleSeekEnd: () => void;
|
||||
resetProgress: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable progress hook that prevents backward jumps during buffering
|
||||
* and provides smooth progress updates like Stash
|
||||
*/
|
||||
export function useStableProgress(
|
||||
videoRef: RefObject<HTMLVideoElement>,
|
||||
duration: number,
|
||||
options: {
|
||||
jitterThreshold?: number;
|
||||
updateThrottle?: number;
|
||||
smallAdjustmentThreshold?: number;
|
||||
} = {}
|
||||
): UseStableProgressReturn {
|
||||
const {
|
||||
jitterThreshold = 0.5, // Maximum allowed backward jump in seconds
|
||||
updateThrottle = 100, // Minimum ms between updates
|
||||
smallAdjustmentThreshold = 0.1 // Allow small backward adjustments
|
||||
} = options;
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [bufferState, setBufferState] = useState<BufferState>({
|
||||
buffered: 0,
|
||||
lastBufferUpdate: 0
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Refs for internal state management
|
||||
const lastStableTime = useRef(0);
|
||||
const lastUpdateTime = useRef(0);
|
||||
const bufferStateRef = useRef(bufferState);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// Refs for configuration values to prevent unnecessary re-renders
|
||||
const configRef = useRef({
|
||||
updateThrottle,
|
||||
smallAdjustmentThreshold,
|
||||
jitterThreshold
|
||||
});
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
bufferStateRef.current = bufferState;
|
||||
}, [bufferState]);
|
||||
|
||||
useEffect(() => {
|
||||
isDraggingRef.current = isDragging;
|
||||
}, [isDragging]);
|
||||
|
||||
// Update config refs when options change
|
||||
useEffect(() => {
|
||||
configRef.current = {
|
||||
updateThrottle,
|
||||
smallAdjustmentThreshold,
|
||||
jitterThreshold
|
||||
};
|
||||
}, [updateThrottle, smallAdjustmentThreshold, jitterThreshold]);
|
||||
|
||||
|
||||
/**
|
||||
* Internal time update handler that manages the state directly
|
||||
*/
|
||||
const internalTimeUpdate = useCallback((): void => {
|
||||
if (!videoRef.current || isDraggingRef.current) return;
|
||||
|
||||
const now = Date.now();
|
||||
const video = videoRef.current;
|
||||
const rawTime = video.currentTime;
|
||||
|
||||
// Throttle updates to prevent excessive re-renders
|
||||
if (now - lastUpdateTime.current < configRef.current.updateThrottle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow forward progress or small backward adjustments
|
||||
if (rawTime >= lastStableTime.current - configRef.current.smallAdjustmentThreshold) {
|
||||
setCurrentTime(rawTime);
|
||||
lastStableTime.current = rawTime;
|
||||
lastUpdateTime.current = now;
|
||||
} else {
|
||||
// Block large backward jumps (jitter prevention)
|
||||
console.log(`[ANTI-JITTER] Blocked backward jump: ${rawTime.toFixed(2)}s -> ${lastStableTime.current.toFixed(2)}s (threshold: ${configRef.current.jitterThreshold}s)`);
|
||||
|
||||
// Optionally clamp to last stable time to prevent visual jumping
|
||||
if (Math.abs(rawTime - lastStableTime.current) > configRef.current.jitterThreshold) {
|
||||
video.currentTime = lastStableTime.current;
|
||||
}
|
||||
}
|
||||
}, []); // Remove videoRef dependency to prevent re-creation
|
||||
|
||||
/**
|
||||
* Handle progress events for buffer visualization
|
||||
*/
|
||||
const handleProgress = useCallback((): void => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
const newBufferState = {
|
||||
buffered: bufferedEnd,
|
||||
lastBufferUpdate: Date.now()
|
||||
};
|
||||
|
||||
setBufferState(newBufferState);
|
||||
|
||||
// Log for debugging
|
||||
if (Math.abs(bufferedEnd - bufferStateRef.current.buffered) > 1) {
|
||||
console.log(`[BUFFER] Updated buffer to ${bufferedEnd.toFixed(2)}s`);
|
||||
}
|
||||
}
|
||||
}, []); // Remove videoRef dependency to prevent re-creation
|
||||
|
||||
/**
|
||||
* Handle seeking with proper state management
|
||||
*/
|
||||
const handleSeek = useCallback((time: number): void => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, duration || Infinity));
|
||||
|
||||
// Update both refs and state immediately for responsive UI
|
||||
lastStableTime.current = clampedTime;
|
||||
setCurrentTime(clampedTime);
|
||||
|
||||
// Update video element
|
||||
videoRef.current.currentTime = clampedTime;
|
||||
|
||||
console.log(`[SEEK] Seeking to ${clampedTime.toFixed(2)}s`);
|
||||
}, [duration]); // Only depend on duration
|
||||
|
||||
/**
|
||||
* Handle seek start (mouse down on progress bar)
|
||||
*/
|
||||
const handleSeekStart = useCallback((): void => {
|
||||
setIsDragging(true);
|
||||
isDraggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle seek end (mouse up on progress bar)
|
||||
*/
|
||||
const handleSeekEnd = useCallback((): void => {
|
||||
setIsDragging(false);
|
||||
isDraggingRef.current = false;
|
||||
|
||||
// Sync with video element after seek
|
||||
if (videoRef.current) {
|
||||
const finalTime = videoRef.current.currentTime;
|
||||
lastStableTime.current = finalTime;
|
||||
setCurrentTime(finalTime);
|
||||
}
|
||||
}, []); // Remove videoRef dependency
|
||||
|
||||
/**
|
||||
* Reset progress state (for video changes)
|
||||
*/
|
||||
const resetProgress = useCallback((): void => {
|
||||
// Use functional updates to avoid dependencies on current state
|
||||
setCurrentTime(() => 0);
|
||||
setBufferState(() => ({ buffered: 0, lastBufferUpdate: 0 }));
|
||||
lastStableTime.current = 0;
|
||||
lastUpdateTime.current = 0;
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(() => false);
|
||||
}, []); // Empty deps array is safe with functional updates
|
||||
|
||||
// Reset when video source changes
|
||||
useEffect(() => {
|
||||
resetProgress();
|
||||
}, []); // Only reset on mount/unmount
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
bufferState,
|
||||
isDragging,
|
||||
handleTimeUpdate: internalTimeUpdate,
|
||||
handleProgress,
|
||||
handleSeek,
|
||||
handleSeekStart,
|
||||
handleSeekEnd,
|
||||
resetProgress
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to format time for display
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds < 0) return '0:00';
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for monitoring buffer state separately
|
||||
*/
|
||||
export function useBufferState(videoRef: RefObject<HTMLVideoElement>) {
|
||||
const [bufferState, setBufferState] = useState<BufferState>({
|
||||
buffered: 0,
|
||||
lastBufferUpdate: 0
|
||||
});
|
||||
|
||||
const handleProgress = (): void => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
setBufferState({
|
||||
buffered: bufferedEnd,
|
||||
lastBufferUpdate: Date.now()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.addEventListener('progress', handleProgress);
|
||||
return () => video.removeEventListener('progress', handleProgress);
|
||||
}, [videoRef]);
|
||||
|
||||
return bufferState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing seek state
|
||||
*/
|
||||
export function useSeekState() {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleSeekStart = (): void => setIsDragging(true);
|
||||
const handleSeekEnd = (): void => setIsDragging(false);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
handleSeekStart,
|
||||
handleSeekEnd
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration presets for different scenarios
|
||||
*/
|
||||
export const progressConfig = {
|
||||
strict: {
|
||||
jitterThreshold: 0.2,
|
||||
updateThrottle: 50,
|
||||
smallAdjustmentThreshold: 0.05
|
||||
},
|
||||
balanced: {
|
||||
jitterThreshold: 0.5,
|
||||
updateThrottle: 100,
|
||||
smallAdjustmentThreshold: 0.1
|
||||
},
|
||||
relaxed: {
|
||||
jitterThreshold: 1.0,
|
||||
updateThrottle: 200,
|
||||
smallAdjustmentThreshold: 0.2
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue