Implement working Porn Cinema media player with video library
- Added complete porn cinema media player with dedicated HTML page - Implemented PornCinema class with video playback, controls, keyboard shortcuts - Added VideoLibrary class with thumbnail generation and duration display - Canvas-based thumbnails with aspect ratio support for portrait/landscape videos - Playlist functionality with save/load, shuffle, auto-advance - Desktop file manager integration for video file access - Enhanced MIME type handling with fallback strategies for .mov files - Professional dark cinema theme with responsive design - One-handed keyboard controls for playback and navigation - Quality selection, theater mode, fullscreen support - Working video playback with proper file:// URL handling for Electron
This commit is contained in:
parent
c65a58bae9
commit
a2af9ba8f3
|
|
@ -198,7 +198,7 @@
|
|||
|
||||
<script>
|
||||
// Initialize cinema when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
console.log('🎬 Initializing Porn Cinema...');
|
||||
|
||||
// Initialize desktop file manager if in Electron environment
|
||||
|
|
@ -219,18 +219,23 @@
|
|||
|
||||
window.desktopFileManager = new DesktopFileManager(minimalDataManager);
|
||||
console.log('🖥️ Desktop File Manager initialized for porn cinema');
|
||||
|
||||
// Wait a moment for the desktop file manager to fully initialize
|
||||
// The init() method is async and sets up video directories
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
} else if (!window.electronAPI) {
|
||||
console.warn('⚠️ Running in browser mode - video management limited');
|
||||
}
|
||||
|
||||
// Initialize the cinema
|
||||
// Initialize the cinema after desktop file manager is ready
|
||||
window.pornCinema = new PornCinema();
|
||||
window.pornCinema.initialize();
|
||||
await window.pornCinema.initialize();
|
||||
|
||||
// Hide loading overlay
|
||||
setTimeout(() => {
|
||||
document.getElementById('cinema-loading').style.display = 'none';
|
||||
}, 1500);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Back to home functionality
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ class PornCinema {
|
|||
this.volume = 0.7;
|
||||
this.playbackRate = 1.0;
|
||||
this.currentQuality = 'auto';
|
||||
this.shouldAutoPlay = false;
|
||||
this.fallbackMimeTypes = null;
|
||||
this.currentVideoSrc = null;
|
||||
|
||||
// Playlist
|
||||
this.playlist = [];
|
||||
|
|
@ -176,16 +179,39 @@ class PornCinema {
|
|||
|
||||
async playVideo(video) {
|
||||
try {
|
||||
console.log(`🎬 Playing video: ${video.name}`);
|
||||
|
||||
this.currentVideo = video;
|
||||
this.showLoading();
|
||||
|
||||
// Update video source
|
||||
this.videoSource.src = video.path;
|
||||
this.videoSource.type = `video/${video.format || 'mp4'}`;
|
||||
// Convert path to proper format for Electron
|
||||
let videoSrc = video.path;
|
||||
if (window.electronAPI && video.path.match(/^[A-Za-z]:\\/)) {
|
||||
// Absolute Windows path - convert to file:// URL
|
||||
videoSrc = `file:///${video.path.replace(/\\/g, '/')}`;
|
||||
} else if (window.electronAPI && !video.path.startsWith('file://')) {
|
||||
// Relative path in Electron - use as is
|
||||
videoSrc = video.path;
|
||||
}
|
||||
|
||||
// Load and play
|
||||
// Check if browser can play this format
|
||||
const canPlayResult = this.checkCanPlay(video.format || video.path);
|
||||
console.log(`🎬 Playing ${video.name} (${video.format}) - Can play: ${canPlayResult.canPlay ? 'Yes' : 'No'}`);
|
||||
|
||||
// For .mov files, try multiple MIME types as fallback
|
||||
const mimeTypes = this.getVideoMimeTypes(video.format || video.path);
|
||||
console.log(`🎬 Trying MIME types: ${mimeTypes.join(', ')}`);
|
||||
|
||||
// Update video source - try primary MIME type first
|
||||
this.videoSource.src = videoSrc;
|
||||
this.videoSource.type = canPlayResult.mimeType || mimeTypes[0];
|
||||
|
||||
// Store fallback MIME types for error handling
|
||||
this.fallbackMimeTypes = mimeTypes.slice(1);
|
||||
this.currentVideoSrc = videoSrc;
|
||||
|
||||
// Set flag to auto-play when loaded
|
||||
this.shouldAutoPlay = true;
|
||||
|
||||
// Load the video
|
||||
this.videoElement.load();
|
||||
|
||||
// Update UI
|
||||
|
|
@ -199,6 +225,53 @@ class PornCinema {
|
|||
}
|
||||
}
|
||||
|
||||
checkCanPlay(formatOrPath) {
|
||||
let extension = formatOrPath;
|
||||
if (formatOrPath.includes('.')) {
|
||||
extension = formatOrPath.toLowerCase().split('.').pop();
|
||||
}
|
||||
|
||||
const testVideo = document.createElement('video');
|
||||
const mimeTypes = this.getVideoMimeTypes(formatOrPath);
|
||||
|
||||
for (const mimeType of mimeTypes) {
|
||||
const canPlay = testVideo.canPlayType(mimeType);
|
||||
if (canPlay === 'probably' || canPlay === 'maybe') {
|
||||
return { canPlay: true, mimeType };
|
||||
}
|
||||
}
|
||||
|
||||
return { canPlay: false, mimeType: null };
|
||||
}
|
||||
|
||||
getVideoMimeTypes(formatOrPath) {
|
||||
let extension = formatOrPath;
|
||||
if (formatOrPath.includes('.')) {
|
||||
extension = formatOrPath.toLowerCase().split('.').pop();
|
||||
}
|
||||
|
||||
switch (extension.toLowerCase()) {
|
||||
case 'mp4':
|
||||
return ['video/mp4'];
|
||||
case 'webm':
|
||||
return ['video/webm'];
|
||||
case 'mov':
|
||||
case 'qt':
|
||||
// Try multiple MIME types for .mov files
|
||||
return ['video/quicktime', 'video/mp4', 'video/x-quicktime'];
|
||||
case 'avi':
|
||||
return ['video/avi', 'video/x-msvideo'];
|
||||
case 'mkv':
|
||||
return ['video/x-matroska'];
|
||||
case 'ogg':
|
||||
return ['video/ogg'];
|
||||
case 'm4v':
|
||||
return ['video/mp4'];
|
||||
default:
|
||||
return ['video/mp4']; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
togglePlayPause() {
|
||||
if (!this.videoElement.src) {
|
||||
// No video loaded, try to play first video from library or playlist
|
||||
|
|
@ -337,19 +410,31 @@ class PornCinema {
|
|||
}
|
||||
|
||||
// Playlist management
|
||||
addToPlaylist(video) {
|
||||
async addToPlaylist(video) {
|
||||
if (!this.playlist.find(v => v.path === video.path)) {
|
||||
this.playlist.push({...video});
|
||||
// Create a copy of the video with duration loaded if missing
|
||||
const videoWithDuration = {...video};
|
||||
|
||||
// If duration is missing or 0, try to load it
|
||||
if (!videoWithDuration.duration || videoWithDuration.duration === 0) {
|
||||
try {
|
||||
videoWithDuration.duration = await this.getVideoDuration(video);
|
||||
} catch (error) {
|
||||
console.warn(`Could not load duration for ${video.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.playlist.push(videoWithDuration);
|
||||
this.updatePlaylistDisplay();
|
||||
console.log(`➕ Added to playlist: ${video.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
addCurrentToPlaylist() {
|
||||
async addCurrentToPlaylist() {
|
||||
if (this.currentVideo) {
|
||||
this.addToPlaylist(this.currentVideo);
|
||||
await this.addToPlaylist(this.currentVideo);
|
||||
} else if (this.videoLibrary && this.videoLibrary.getSelectedVideo()) {
|
||||
this.addToPlaylist(this.videoLibrary.getSelectedVideo());
|
||||
await this.addToPlaylist(this.videoLibrary.getSelectedVideo());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -533,6 +618,15 @@ class PornCinema {
|
|||
|
||||
onLoadedData() {
|
||||
this.hideLoading();
|
||||
|
||||
// Auto-play if requested
|
||||
if (this.shouldAutoPlay) {
|
||||
this.shouldAutoPlay = false;
|
||||
this.videoElement.play().catch(error => {
|
||||
console.error('🎬 Error auto-playing video:', error);
|
||||
this.showError('Failed to play video');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onCanPlay() {
|
||||
|
|
@ -544,18 +638,21 @@ class PornCinema {
|
|||
this.isPlaying = true;
|
||||
this.updatePlayButton();
|
||||
this.hidePlayOverlay();
|
||||
this.hideVideoOverlay();
|
||||
}
|
||||
|
||||
onPause() {
|
||||
this.isPlaying = false;
|
||||
this.updatePlayButton();
|
||||
this.showPlayOverlay();
|
||||
this.showVideoOverlay();
|
||||
}
|
||||
|
||||
onEnded() {
|
||||
this.isPlaying = false;
|
||||
this.updatePlayButton();
|
||||
this.showPlayOverlay();
|
||||
this.showVideoOverlay();
|
||||
|
||||
// Auto-play next video in playlist
|
||||
if (this.playlist.length > 0 && this.currentPlaylistIndex >= 0) {
|
||||
|
|
@ -573,9 +670,63 @@ class PornCinema {
|
|||
}
|
||||
|
||||
onError(event) {
|
||||
console.error('Video error:', event);
|
||||
const video = event.target;
|
||||
const error = video.error;
|
||||
|
||||
// Try fallback MIME types for .mov files
|
||||
if (this.fallbackMimeTypes && this.fallbackMimeTypes.length > 0) {
|
||||
const nextMimeType = this.fallbackMimeTypes.shift();
|
||||
console.log(`🔄 Trying fallback MIME type: ${nextMimeType}`);
|
||||
|
||||
this.videoSource.type = nextMimeType;
|
||||
this.videoElement.load();
|
||||
return; // Don't show error yet, try the fallback
|
||||
}
|
||||
|
||||
let errorMessage = 'Error loading video';
|
||||
let detailedMessage = 'Unknown error';
|
||||
|
||||
if (error) {
|
||||
switch (error.code) {
|
||||
case error.MEDIA_ERR_ABORTED:
|
||||
detailedMessage = 'Video loading was aborted';
|
||||
break;
|
||||
case error.MEDIA_ERR_NETWORK:
|
||||
detailedMessage = 'Network error while loading video';
|
||||
break;
|
||||
case error.MEDIA_ERR_DECODE:
|
||||
detailedMessage = 'Video format not supported or corrupted';
|
||||
errorMessage = 'Unsupported video format';
|
||||
break;
|
||||
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
detailedMessage = 'Video format or codec not supported';
|
||||
errorMessage = 'Video format not supported';
|
||||
break;
|
||||
default:
|
||||
detailedMessage = `Unknown error (code: ${error.code})`;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`🎬 Video error for ${this.currentVideo?.name || 'unknown'}:`, {
|
||||
code: error?.code,
|
||||
message: error?.message,
|
||||
details: detailedMessage,
|
||||
format: this.currentVideo?.format,
|
||||
src: video.src,
|
||||
mimeType: this.videoSource.type
|
||||
});
|
||||
|
||||
this.hideLoading();
|
||||
this.showError('Error loading video');
|
||||
this.showError(errorMessage);
|
||||
|
||||
// Reset fallback MIME types
|
||||
this.fallbackMimeTypes = null;
|
||||
|
||||
// If it's a .mov file that failed, suggest alternatives
|
||||
if (this.currentVideo?.format?.toLowerCase() === 'mov') {
|
||||
console.warn('💡 .mov file failed to play. This format may contain codecs not supported by browsers.');
|
||||
console.warn('💡 Consider converting .mov files to .mp4 for better compatibility.');
|
||||
}
|
||||
}
|
||||
|
||||
onFullscreenChange() {
|
||||
|
|
@ -641,6 +792,14 @@ class PornCinema {
|
|||
this.playOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
showVideoOverlay() {
|
||||
this.videoOverlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
hideVideoOverlay() {
|
||||
this.videoOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
showControls() {
|
||||
this.controls.container.classList.add('visible');
|
||||
}
|
||||
|
|
@ -714,6 +873,40 @@ class PornCinema {
|
|||
}
|
||||
}
|
||||
|
||||
async getVideoDuration(video) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create a temporary video element to load metadata
|
||||
const tempVideo = document.createElement('video');
|
||||
tempVideo.preload = 'metadata';
|
||||
|
||||
// Convert path to proper format for Electron
|
||||
let videoSrc = video.path;
|
||||
if (window.electronAPI && video.path.match(/^[A-Za-z]:\\/)) {
|
||||
// Absolute Windows path - convert to file:// URL
|
||||
videoSrc = `file:///${video.path.replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
tempVideo.addEventListener('loadedmetadata', () => {
|
||||
const duration = tempVideo.duration;
|
||||
tempVideo.remove(); // Clean up
|
||||
resolve(duration);
|
||||
});
|
||||
|
||||
tempVideo.addEventListener('error', (e) => {
|
||||
tempVideo.remove(); // Clean up
|
||||
reject(new Error(`Failed to load video metadata: ${e.message}`));
|
||||
});
|
||||
|
||||
// Set timeout to avoid hanging
|
||||
setTimeout(() => {
|
||||
tempVideo.remove();
|
||||
reject(new Error('Timeout loading video metadata'));
|
||||
}, 5000);
|
||||
|
||||
tempVideo.src = videoSrc;
|
||||
});
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,29 @@ class VideoLibrary {
|
|||
return;
|
||||
}
|
||||
|
||||
// Wait for desktop file manager to be fully initialized
|
||||
// Check if video directories have been set up
|
||||
let retries = 0;
|
||||
const maxRetries = 20; // Wait up to 2 seconds
|
||||
|
||||
while (retries < maxRetries) {
|
||||
if (window.desktopFileManager.videoDirectories &&
|
||||
window.desktopFileManager.videoDirectories.background) {
|
||||
console.log('✅ Desktop file manager video directories are ready');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`⏳ Waiting for video directories to initialize... (${retries + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (retries >= maxRetries) {
|
||||
console.warn('⚠️ Video directories not initialized after waiting, falling back to stored videos');
|
||||
this.loadStoredVideos();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get video files from all categories
|
||||
let allVideos = [];
|
||||
|
||||
|
|
@ -109,11 +132,17 @@ class VideoLibrary {
|
|||
}));
|
||||
|
||||
console.log(`📁 Loaded ${this.videos.length} videos`);
|
||||
if (this.videos.length > 0) {
|
||||
console.log('📁 Sample video object:', this.videos[0]);
|
||||
}
|
||||
|
||||
// Apply current filters and display
|
||||
this.applyFiltersAndSort();
|
||||
this.displayLibrary();
|
||||
|
||||
// Load durations for videos that don't have them
|
||||
this.loadMissingDurations();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading video library:', error);
|
||||
this.displayEmptyLibrary('Error loading video library');
|
||||
|
|
@ -238,13 +267,16 @@ class VideoLibrary {
|
|||
const duration = this.formatDuration(video.duration);
|
||||
const fileSize = this.formatFileSize(video.size);
|
||||
|
||||
// Generate or get thumbnail
|
||||
const thumbnailSrc = this.getThumbnailSrc(video);
|
||||
|
||||
if (this.currentView === 'grid') {
|
||||
return `
|
||||
<div class="video-card" data-video-path="${video.path}">
|
||||
<div class="video-thumbnail">
|
||||
${video.thumbnail ?
|
||||
`<img src="${video.thumbnail}" alt="${video.name}">` :
|
||||
'🎬'
|
||||
<div class="video-thumbnail" data-video-path="${video.path}">
|
||||
${thumbnailSrc ?
|
||||
`<img src="${thumbnailSrc}" alt="${video.name}" onload="this.style.opacity=1" style="opacity:0; transition: opacity 0.3s;">` :
|
||||
`<div class="thumbnail-placeholder" data-video-path="${video.path}">🎬</div>`
|
||||
}
|
||||
<div class="video-duration">${duration}</div>
|
||||
</div>
|
||||
|
|
@ -264,10 +296,10 @@ class VideoLibrary {
|
|||
} else {
|
||||
return `
|
||||
<div class="video-list-item" data-video-path="${video.path}">
|
||||
<div class="list-thumbnail">
|
||||
${video.thumbnail ?
|
||||
`<img src="${video.thumbnail}" alt="${video.name}" class="list-thumbnail">` :
|
||||
`<div class="list-thumbnail" style="display: flex; align-items: center; justify-content: center; font-size: 1.5rem;">🎬</div>`
|
||||
<div class="list-thumbnail" data-video-path="${video.path}">
|
||||
${thumbnailSrc ?
|
||||
`<img src="${thumbnailSrc}" alt="${video.name}" class="list-thumbnail" onload="this.style.opacity=1" style="opacity:0; transition: opacity 0.3s;">` :
|
||||
`<div class="thumbnail-placeholder list-thumbnail" data-video-path="${video.path}" style="display: flex; align-items: center; justify-content: center; font-size: 1.5rem;">🎬</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="list-details">
|
||||
|
|
@ -310,10 +342,10 @@ class VideoLibrary {
|
|||
// Add to playlist button events
|
||||
const playlistButtons = this.libraryContent.querySelectorAll('.add-to-playlist');
|
||||
playlistButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
button.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const videoPath = button.closest('[data-video-path]').dataset.videoPath;
|
||||
this.addToPlaylist(videoPath);
|
||||
await this.addToPlaylist(videoPath);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -347,10 +379,10 @@ class VideoLibrary {
|
|||
}
|
||||
}
|
||||
|
||||
addToPlaylist(videoPath) {
|
||||
async addToPlaylist(videoPath) {
|
||||
const video = this.videos.find(video => video.path === videoPath);
|
||||
if (video && this.pornCinema) {
|
||||
this.pornCinema.addToPlaylist(video);
|
||||
await this.pornCinema.addToPlaylist(video);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -442,4 +474,344 @@ class VideoLibrary {
|
|||
getFilteredVideos() {
|
||||
return this.filteredVideos;
|
||||
}
|
||||
|
||||
loadStoredVideos() {
|
||||
console.log('📁 Loading stored videos from localStorage...');
|
||||
|
||||
try {
|
||||
// Fallback to localStorage
|
||||
const storedVideos = JSON.parse(localStorage.getItem('videoFiles') || '{}');
|
||||
console.log('📁 Falling back to stored videos');
|
||||
|
||||
const allVideos = Object.values(storedVideos).flat();
|
||||
|
||||
if (!allVideos || allVideos.length === 0) {
|
||||
console.warn('📁 No videos found in storage either');
|
||||
this.displayEmptyLibrary('No videos found. Please add video files to your video directories.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store videos with enriched metadata
|
||||
this.videos = allVideos.map(video => ({
|
||||
...video,
|
||||
category: video.category || 'unknown',
|
||||
qualities: this.detectVideoQualities(video),
|
||||
addedDate: video.addedDate || new Date().toISOString()
|
||||
}));
|
||||
|
||||
console.log(`📁 Loaded ${this.videos.length} videos`);
|
||||
console.log('📁 Sample video object:', this.videos[0]);
|
||||
|
||||
// Apply current filters and display
|
||||
this.applyFiltersAndSort();
|
||||
this.displayLibrary();
|
||||
|
||||
// Load durations for videos that don't have them
|
||||
this.loadMissingDurations();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading stored videos:', error);
|
||||
this.displayEmptyLibrary('Error loading video library');
|
||||
}
|
||||
}
|
||||
|
||||
getThumbnailSrc(video) {
|
||||
// Check if video has existing thumbnail
|
||||
if (video.thumbnail) {
|
||||
// Convert thumbnail path for Electron if needed
|
||||
if (window.electronAPI && video.thumbnail.match(/^[A-Za-z]:\\/)) {
|
||||
return `file:///${video.thumbnail.replace(/\\/g, '/')}`;
|
||||
}
|
||||
return video.thumbnail;
|
||||
}
|
||||
|
||||
// Check if we have a cached thumbnail
|
||||
const cacheKey = `thumbnail_${video.path}`;
|
||||
const cacheVersionKey = `thumbnail_version_${video.path}`;
|
||||
const currentVersion = '2.0'; // Updated version for aspect ratio support
|
||||
|
||||
const cachedData = localStorage.getItem(cacheKey);
|
||||
const cachedVersion = localStorage.getItem(cacheVersionKey);
|
||||
|
||||
if (cachedData && cachedVersion === currentVersion) {
|
||||
try {
|
||||
const thumbnailData = JSON.parse(cachedData);
|
||||
return thumbnailData.dataUrl || cachedData; // Support both new and old format
|
||||
} catch (e) {
|
||||
// Old format - just a data URL string
|
||||
return cachedData;
|
||||
}
|
||||
} else if (cachedData) {
|
||||
// Clear old cache
|
||||
localStorage.removeItem(cacheKey);
|
||||
localStorage.removeItem(cacheVersionKey);
|
||||
}
|
||||
|
||||
// Generate thumbnail asynchronously
|
||||
this.generateThumbnail(video);
|
||||
|
||||
return null; // Will show placeholder initially
|
||||
}
|
||||
|
||||
async generateThumbnail(video) {
|
||||
try {
|
||||
// Convert video path for Electron if needed
|
||||
let videoSrc = video.path;
|
||||
if (window.electronAPI && video.path.match(/^[A-Za-z]:\\/)) {
|
||||
videoSrc = `file:///${video.path.replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
// Create temporary video element
|
||||
const tempVideo = document.createElement('video');
|
||||
tempVideo.crossOrigin = 'anonymous';
|
||||
tempVideo.preload = 'metadata';
|
||||
tempVideo.style.display = 'none';
|
||||
document.body.appendChild(tempVideo);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tempVideo.addEventListener('loadedmetadata', () => {
|
||||
// Seek to 10% of video duration for thumbnail
|
||||
tempVideo.currentTime = Math.min(tempVideo.duration * 0.1, 10);
|
||||
});
|
||||
|
||||
tempVideo.addEventListener('seeked', () => {
|
||||
try {
|
||||
// Create canvas to capture frame
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Get video dimensions
|
||||
const videoWidth = tempVideo.videoWidth;
|
||||
const videoHeight = tempVideo.videoHeight;
|
||||
const aspectRatio = videoWidth / videoHeight;
|
||||
|
||||
// Set canvas size based on aspect ratio
|
||||
const maxWidth = 320;
|
||||
const maxHeight = 240; // Increased max height for portrait videos
|
||||
|
||||
let canvasWidth, canvasHeight;
|
||||
|
||||
if (aspectRatio >= 1) {
|
||||
// Landscape or square video
|
||||
canvasWidth = Math.min(maxWidth, videoWidth);
|
||||
canvasHeight = canvasWidth / aspectRatio;
|
||||
} else {
|
||||
// Portrait video
|
||||
canvasHeight = Math.min(maxHeight, videoHeight);
|
||||
canvasWidth = canvasHeight * aspectRatio;
|
||||
}
|
||||
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
// Draw video frame to canvas maintaining aspect ratio
|
||||
ctx.drawImage(tempVideo, 0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// Convert to base64 data URL
|
||||
const thumbnailDataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
||||
|
||||
// Cache the thumbnail with metadata
|
||||
const cacheKey = `thumbnail_${video.path}`;
|
||||
const cacheVersionKey = `thumbnail_version_${video.path}`;
|
||||
const thumbnailData = {
|
||||
dataUrl: thumbnailDataUrl,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
aspectRatio: aspectRatio,
|
||||
isPortrait: aspectRatio < 1
|
||||
};
|
||||
localStorage.setItem(cacheKey, JSON.stringify(thumbnailData));
|
||||
localStorage.setItem(cacheVersionKey, '2.0');
|
||||
|
||||
// Update the display
|
||||
this.updateThumbnailDisplay(video.path, thumbnailData);
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(tempVideo);
|
||||
resolve(thumbnailDataUrl);
|
||||
|
||||
} catch (error) {
|
||||
document.body.removeChild(tempVideo);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tempVideo.addEventListener('error', (e) => {
|
||||
document.body.removeChild(tempVideo);
|
||||
reject(new Error(`Failed to load video for thumbnail: ${e.message}`));
|
||||
});
|
||||
|
||||
// Set timeout to avoid hanging
|
||||
setTimeout(() => {
|
||||
if (tempVideo.parentNode) {
|
||||
document.body.removeChild(tempVideo);
|
||||
}
|
||||
reject(new Error('Timeout generating thumbnail'));
|
||||
}, 10000);
|
||||
|
||||
tempVideo.src = videoSrc;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${video.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
updateThumbnailDisplay(videoPath, thumbnailData) {
|
||||
// Handle both old (string) and new (object) thumbnail data formats
|
||||
const thumbnailSrc = typeof thumbnailData === 'string' ? thumbnailData : thumbnailData.dataUrl;
|
||||
const isPortrait = typeof thumbnailData === 'object' ? thumbnailData.isPortrait : false;
|
||||
|
||||
// Find all thumbnail elements for this video
|
||||
const thumbnailElements = document.querySelectorAll(`[data-video-path="${videoPath}"]`);
|
||||
|
||||
thumbnailElements.forEach(element => {
|
||||
if (element.classList.contains('video-thumbnail') || element.classList.contains('list-thumbnail')) {
|
||||
// Replace placeholder with actual thumbnail
|
||||
const placeholderDiv = element.querySelector('.thumbnail-placeholder');
|
||||
if (placeholderDiv) {
|
||||
const img = document.createElement('img');
|
||||
img.src = thumbnailSrc;
|
||||
img.alt = 'Video thumbnail';
|
||||
img.style.opacity = '0';
|
||||
img.style.transition = 'opacity 0.3s';
|
||||
|
||||
// Apply aspect ratio specific styling
|
||||
if (isPortrait) {
|
||||
img.style.width = 'auto';
|
||||
img.style.height = '100%';
|
||||
img.style.objectFit = 'contain';
|
||||
img.style.maxWidth = '100%';
|
||||
} else {
|
||||
img.style.width = '100%';
|
||||
img.style.height = '100%';
|
||||
img.style.objectFit = 'cover';
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
img.style.opacity = '1';
|
||||
placeholderDiv.remove();
|
||||
};
|
||||
|
||||
if (element.classList.contains('list-thumbnail')) {
|
||||
img.className = 'list-thumbnail';
|
||||
}
|
||||
|
||||
element.appendChild(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadMissingDurations() {
|
||||
// Find videos without duration
|
||||
const videosNeedingDuration = this.videos.filter(video => !video.duration || video.duration === 0);
|
||||
|
||||
if (videosNeedingDuration.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`⏱️ Loading durations for ${videosNeedingDuration.length} videos...`);
|
||||
|
||||
// Load durations in batches to avoid overwhelming the system
|
||||
const batchSize = 3;
|
||||
for (let i = 0; i < videosNeedingDuration.length; i += batchSize) {
|
||||
const batch = videosNeedingDuration.slice(i, i + batchSize);
|
||||
|
||||
await Promise.all(batch.map(async (video) => {
|
||||
try {
|
||||
const duration = await this.getVideoDuration(video);
|
||||
video.duration = duration;
|
||||
|
||||
// Update the display for this specific video
|
||||
this.updateVideoDurationDisplay(video);
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load duration for ${video.name}:`, error);
|
||||
}
|
||||
}));
|
||||
|
||||
// Small delay between batches
|
||||
if (i + batchSize < videosNeedingDuration.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoDuration(video) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create a temporary video element to load metadata
|
||||
const tempVideo = document.createElement('video');
|
||||
tempVideo.preload = 'metadata';
|
||||
|
||||
// Convert path to proper format for Electron
|
||||
let videoSrc = video.path;
|
||||
if (window.electronAPI && video.path.match(/^[A-Za-z]:\\/)) {
|
||||
// Absolute Windows path - convert to file:// URL
|
||||
videoSrc = `file:///${video.path.replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
tempVideo.addEventListener('loadedmetadata', () => {
|
||||
const duration = tempVideo.duration;
|
||||
tempVideo.remove(); // Clean up
|
||||
resolve(duration);
|
||||
});
|
||||
|
||||
tempVideo.addEventListener('error', (e) => {
|
||||
tempVideo.remove(); // Clean up
|
||||
reject(new Error(`Failed to load video metadata: ${e.message}`));
|
||||
});
|
||||
|
||||
// Set timeout to avoid hanging
|
||||
setTimeout(() => {
|
||||
tempVideo.remove();
|
||||
reject(new Error('Timeout loading video metadata'));
|
||||
}, 5000);
|
||||
|
||||
tempVideo.src = videoSrc;
|
||||
});
|
||||
}
|
||||
|
||||
updateVideoDurationDisplay(video) {
|
||||
// Escape special characters in the path for CSS selector
|
||||
const escapedPath = video.path.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||
|
||||
// Find video cards/items for this video and update the duration display
|
||||
const videoElements = document.querySelectorAll(`[data-video-path="${escapedPath}"]`);
|
||||
|
||||
// If CSS selector fails, try a different approach
|
||||
if (videoElements.length === 0) {
|
||||
const allVideoElements = document.querySelectorAll('[data-video-path]');
|
||||
const matchingElements = Array.from(allVideoElements).filter(el =>
|
||||
el.getAttribute('data-video-path') === video.path
|
||||
);
|
||||
|
||||
matchingElements.forEach((element) => {
|
||||
this.updateSingleElementDuration(element, video);
|
||||
});
|
||||
} else {
|
||||
videoElements.forEach((element) => {
|
||||
this.updateSingleElementDuration(element, video);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateSingleElementDuration(element, video) {
|
||||
// Check if this is a grid view card or list view item
|
||||
if (element.classList.contains('video-card')) {
|
||||
// Grid view - duration is in .video-duration
|
||||
const durationElement = element.querySelector('.video-duration');
|
||||
if (durationElement) {
|
||||
const formattedDuration = this.formatDuration(video.duration);
|
||||
durationElement.textContent = formattedDuration;
|
||||
}
|
||||
} else if (element.classList.contains('video-list-item')) {
|
||||
// List view - duration is in the second .list-meta element
|
||||
const metaElements = element.querySelectorAll('.list-meta');
|
||||
if (metaElements.length >= 2) {
|
||||
const formattedDuration = this.formatDuration(video.duration);
|
||||
metaElements[1].textContent = formattedDuration; // Second meta is duration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -562,6 +562,11 @@
|
|||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Portrait video thumbnails - use contain to show full image */
|
||||
.video-thumbnail img[style*="object-fit: contain"] {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
|
|
|
|||
Loading…
Reference in New Issue