/**
* js/utils.js
* Utility functions for video playback, UI toggles, loader management, and video player enhancements.
* @module utils
*/
const PROGRESS_PREFIX = 'streamit:progress:';
const WATCHED_KEY = 'watchedContent';
let currentVideoSrc = '';
let currentVideoContext = null;
// Helpers for watched content storage (films & séries)
export function getWatchedContent() {
try {
return JSON.parse(localStorage.getItem(WATCHED_KEY) || '{"films":{},"series":{}}');
} catch (e) {
console.warn('Reset watchedContent (parse error)', e);
return { films: {}, series: {} };
}
}
function setWatchedContent(data) {
localStorage.setItem(WATCHED_KEY, JSON.stringify(data));
}
function sanitizeProgressData(raw) {
const safeFilms = raw && typeof raw.films === 'object' && !Array.isArray(raw.films) ? raw.films : {};
const safeSeries = raw && typeof raw.series === 'object' && !Array.isArray(raw.series) ? raw.series : {};
return { films: safeFilms, series: safeSeries };
}
function normalizeSeason(season) {
if (season === undefined || season === null || season === '') return '1';
const cleaned = String(season).trim();
return cleaned === '' ? '1' : cleaned;
}
function normalizeEpisodeIndex(epIndex) {
return Number.isInteger(epIndex) && epIndex >= 0 ? epIndex : 0;
}
function markFilmWatched(title, watched = true, time = 0) {
if (!title) return;
const data = getWatchedContent();
if (!data.films[title]) data.films[title] = {};
data.films[title].watched = watched;
data.films[title].time = Math.max(0, Math.floor(time));
setWatchedContent(data);
}
function markEpisodeWatched(seriesTitle, season, epIndex, watched = true, time = 0) {
if (!seriesTitle) return;
const safeSeason = normalizeSeason(season);
const safeIdx = normalizeEpisodeIndex(epIndex);
const data = getWatchedContent();
if (!data.series[seriesTitle]) data.series[seriesTitle] = {};
if (!data.series[seriesTitle][safeSeason]) data.series[seriesTitle][safeSeason] = {};
data.series[seriesTitle][safeSeason][safeIdx] = { watched, time: Math.max(0, Math.floor(time)) };
setWatchedContent(data);
}
export function getFilmWatchData(title) {
if (!title) return { watched: false, time: 0 };
const data = getWatchedContent();
return data.films[title] || { watched: false, time: 0 };
}
export function getEpisodeWatchData(seriesTitle, season, epIndex) {
if (!seriesTitle) return { watched: false, time: 0 };
const safeSeason = normalizeSeason(season);
const safeIdx = normalizeEpisodeIndex(epIndex);
const data = getWatchedContent();
return (data.series[seriesTitle] && data.series[seriesTitle][safeSeason] && data.series[seriesTitle][safeSeason][safeIdx])
|| { watched: false, time: 0 };
}
export function isSeriesFullyWatched(series) {
if (!series?.seasons) return false;
const seasons = series.seasons;
for (const sKey of Object.keys(seasons)) {
const episodes = seasons[sKey] || [];
for (let i = 0; i < episodes.length; i++) {
const watch = getEpisodeWatchData(series.title, sKey, i);
if (!watch.watched) return false;
}
}
return true;
}
/**
* Generates a unique key for storing video progress in localStorage.
* @param {string} src - The video source URL.
* @returns {string} The generated storage key.
*/
function progressKey(src) {
return `${PROGRESS_PREFIX}${src}`;
}
/**
* Applies a saved start time (resume) when metadata is available.
* @param {HTMLVideoElement} player - The video player element.
* @param {number} startTime - Time in seconds to seek to.
*/
function applyStartTime(player, startTime) {
if (!Number.isFinite(startTime) || startTime <= 0) return;
const applyTime = () => {
const nearEnd = player.duration && startTime >= player.duration - 1;
if (!nearEnd) player.currentTime = startTime;
player.removeEventListener('loadedmetadata', applyTime);
};
if (player.readyState >= 1) applyTime();
else player.addEventListener('loadedmetadata', applyTime);
}
function getResumeTime(src, context) {
if (context?.type === 'film' && context.title) {
return getFilmWatchData(context.title).time || 0;
}
if (context?.type === 'series' && context.title) {
return getEpisodeWatchData(context.title, context.season, context.episodeIndex).time || 0;
}
const saved = parseFloat(localStorage.getItem(progressKey(src)) || '');
return Number.isNaN(saved) ? 0 : saved;
}
/**
* Persists video playback progress to localStorage (fallback when no context is provided).
* @param {HTMLVideoElement} player - The video player element.
*/
function persistGenericProgress(player) {
if (!currentVideoSrc) return;
const key = progressKey(currentVideoSrc);
const t = player.currentTime || 0;
const nearEnd = player.duration && t >= player.duration - 1;
if (nearEnd) localStorage.removeItem(key);
else localStorage.setItem(key, String(t));
}
function saveWatchProgress(player, isEnded = false) {
if (!player) return;
if (!currentVideoContext) {
persistGenericProgress(player);
return;
}
const { type, title } = currentVideoContext;
const season = currentVideoContext.season;
const epIndex = currentVideoContext.episodeIndex;
const duration = player.duration || 0;
const current = player.currentTime || 0;
const hasDuration = Number.isFinite(duration) && duration > 1;
const nearEnd = hasDuration && current >= 0 && (duration - current) <= Math.max(180, duration * 0.05);
const watched = isEnded || nearEnd;
const timeToSave = watched ? 0 : current;
if (type === 'film' && title) {
markFilmWatched(title, watched, timeToSave);
} else if (type === 'series' && title) {
markEpisodeWatched(title, season, epIndex, watched, timeToSave);
}
if (watched) {
currentVideoSrc = '';
currentVideoContext = null;
}
}
/**
* Plays a video in the overlay player with optional watch context.
* @param {string} src - The video source URL.
* @param {Object|null} context - Optional playback context {type, title, season, episodeIndex}.
*/
export function playVideo(src, context = null) {
const overlay = document.getElementById('videoOverlay');
const player = document.getElementById('mainPlayer');
if (!src) {
alert("Vidéo non disponible pour le moment.");
return;
}
currentVideoSrc = src;
currentVideoContext = context ? { ...context, season: normalizeSeason(context.season) } : null;
player.src = src;
const startTime = getResumeTime(src, currentVideoContext);
applyStartTime(player, startTime);
// Update video title overlay
updateVideoTitle(context);
overlay.classList.remove('hidden');
setTimeout(() => {
player.play().catch(e => console.log("Autoplay bloqué par le navigateur", e));
}, 50);
// Initialize custom controls
initCustomVideoControls();
}
/**
* Closes the video overlay and stops playback.
*/
export function closeVideo() {
const overlay = document.getElementById('videoOverlay');
const player = document.getElementById('mainPlayer');
saveWatchProgress(player);
player.pause();
player.src = "";
currentVideoSrc = '';
currentVideoContext = null;
overlay.classList.add('hidden');
// Clean up custom controls
cleanupCustomVideoControls();
}
/**
* Toggles the notifications dropdown visibility.
*/
export function toggleNotifs() {
const dropdown = document.getElementById('notifDropdown');
const settings = document.getElementById('settingsDropdown');
if (settings) settings.classList.remove('active');
dropdown.classList.toggle('active');
}
export function toggleSettings() {
const dropdown = document.getElementById('settingsDropdown');
const notifs = document.getElementById('notifDropdown');
if (notifs) notifs.classList.remove('active');
dropdown.classList.toggle('active');
}
export function downloadProgressBackup() {
const data = sanitizeProgressData(getWatchedContent());
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const now = new Date();
const stamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `streamit-progression-${stamp}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export function openProgressImport() {
const input = document.getElementById('progressImportInput');
if (input) {
input.value = '';
input.click();
}
}
export async function importProgressFromFile(file) {
if (!file) throw new Error('Aucun fichier sélectionné');
const text = await file.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch (err) {
throw new Error('Fichier JSON invalide.');
}
const sanitized = sanitizeProgressData(parsed);
setWatchedContent(sanitized);
const filmsCount = Object.keys(sanitized.films || {}).length;
const seriesCount = Object.keys(sanitized.series || {}).length;
return { filmsCount, seriesCount };
}
/**
* Toggles the mobile menu visibility.
*/
export function toggleMobileMenu() {
const menu = document.getElementById('mobileMenuPanel');
const search = document.getElementById('mobileSearchPanel');
if (search.classList.contains('active')) {
search.classList.remove('active');
}
menu.classList.toggle('active');
}
/**
* Toggles the mobile search panel visibility.
*/
export function toggleMobileSearch() {
const search = document.getElementById('mobileSearchPanel');
const menu = document.getElementById('mobileMenuPanel');
if (menu.classList.contains('active')) {
menu.classList.remove('active');
}
search.classList.toggle('active');
if (search.classList.contains('active')) {
document.getElementById('mobileSearchInput').focus();
}
}
/**
* Shows the loading spinner.
*/
export function showLoader() {
document.getElementById('loader').classList.remove('hidden');
document.getElementById('loader').style.opacity = '1';
}
/**
* Hides the loading spinner with a fade-out effect.
*/
export function hideLoader() {
const loader = document.getElementById('loader');
loader.style.opacity = '0';
setTimeout(() => loader.classList.add('hidden'), 700);
}
/**
* Enhances video player controls to prevent downloading and remote playback.
*/
export function hardenPlayerControls() {
const player = document.getElementById('mainPlayer');
if (!player) return;
player.setAttribute('controlsList', 'nodownload noremoteplayback');
player.setAttribute('disablepictureinpicture', '');
player.setAttribute('playsinline', '');
player.addEventListener('contextmenu', (e) => e.preventDefault());
}
/**
* Initializes video player progress persistence.
*/
export function initPlayerPersistence() {
const player = document.getElementById('mainPlayer');
if (!player) return;
const save = () => saveWatchProgress(player, false);
player.addEventListener('timeupdate', save);
player.addEventListener('pause', save);
player.addEventListener('ended', () => {
saveWatchProgress(player, true);
currentVideoSrc = '';
currentVideoContext = null;
});
}
let controlsTimeout = null;
let isControlsVisible = true;
let isSeeking = false;
/**
* Updates the video title overlay based on context
* @param {Object|null} context - Video context {type, title, season, episodeIndex, episodeTitle}
*/
function updateVideoTitle(context) {
const seriesInfo = document.getElementById('videoSeriesInfo');
const mainTitle = document.getElementById('videoMainTitle');
if (!context) {
seriesInfo.textContent = '';
mainTitle.textContent = '';
return;
}
if (context.type === 'series') {
// Format: S1:E1
const seasonNum = context.season || '1';
const episodeNum = (context.episodeIndex !== undefined ? context.episodeIndex + 1 : 1);
seriesInfo.textContent = `${context.title} • S${seasonNum}:E${episodeNum}`;
mainTitle.textContent = context.episodeTitle || `Épisode ${episodeNum}`;
} else {
// Film
seriesInfo.textContent = '';
mainTitle.textContent = context.title || '';
}
}
/**
* Formats time in seconds to MM:SS or HH:MM:SS
* @param {number} seconds - Time in seconds
* @returns {string} Formatted time string
*/
function formatTime(seconds) {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${String(secs).padStart(2, '0')}`;
}
/**
* Shows video controls
*/
function showControls() {
const overlay = document.getElementById('videoOverlay');
const playerContainer = document.getElementById('customVideoPlayer');
overlay.classList.remove('controls-hidden');
playerContainer.classList.remove('hide-cursor');
isControlsVisible = true;
resetControlsTimeout();
}
/**
* Hides video controls
*/
function hideControls() {
const player = document.getElementById('mainPlayer');
const overlay = document.getElementById('videoOverlay');
const playerContainer = document.getElementById('customVideoPlayer');
// Don't hide if video is paused
if (player.paused) return;
overlay.classList.add('controls-hidden');
playerContainer.classList.add('hide-cursor');
isControlsVisible = false;
}
/**
* Resets the auto-hide timeout for controls
*/
function resetControlsTimeout() {
if (controlsTimeout) {
clearTimeout(controlsTimeout);
}
const player = document.getElementById('mainPlayer');
// Only set timeout if video is playing
if (!player.paused) {
controlsTimeout = setTimeout(() => {
hideControls();
}, 3000); // Hide after 3 seconds of inactivity
}
}
/**
* Initializes all custom video player controls
*/
function initCustomVideoControls() {
const player = document.getElementById('mainPlayer');
const overlay = document.getElementById('videoOverlay');
const playerContainer = document.getElementById('customVideoPlayer');
// Play/Pause button
const playPauseBtn = document.getElementById('playPauseBtn');
const centerPlayBtn = document.getElementById('centerPlayBtn');
playPauseBtn.onclick = togglePlayPause;
// Click on video or center button to play/pause
player.onclick = togglePlayPause;
centerPlayBtn.onclick = togglePlayPause;
// Rewind/Forward buttons
document.getElementById('rewindBtn').onclick = () => {
player.currentTime = Math.max(0, player.currentTime - 10);
};
document.getElementById('forwardBtn').onclick = () => {
player.currentTime = Math.min(player.duration, player.currentTime + 10);
};
// Volume controls
const volumeBtn = document.getElementById('volumeBtn');
const volumeSlider = document.getElementById('volumeSlider');
volumeBtn.onclick = toggleMute;
volumeSlider.oninput = (e) => {
const volume = e.target.value / 100;
player.volume = volume;
updateVolumeIcon(volume);
updateVolumeSliderBackground(e.target.value);
};
// Initialize volume
volumeSlider.value = player.volume * 100;
updateVolumeSliderBackground(volumeSlider.value);
// Playback speed
const speedBtn = document.getElementById('speedBtn');
const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
let currentSpeedIndex = 3; // 1x
speedBtn.onclick = () => {
currentSpeedIndex = (currentSpeedIndex + 1) % speeds.length;
const speed = speeds[currentSpeedIndex];
player.playbackRate = speed;
speedBtn.textContent = speed === 1 ? '1x' : `${speed}x`;
};
// Fullscreen button
const fullscreenBtn = document.getElementById('fullscreenBtn');
fullscreenBtn.onclick = toggleFullscreen;
// Progress bar
const progressContainer = document.getElementById('progressContainer');
const playbackProgress = document.getElementById('playbackProgress');
const progressHandle = document.getElementById('progressHandle');
const timeTooltip = document.getElementById('timeTooltip');
progressContainer.onclick = seekToPosition;
progressContainer.onmousemove = showTimeTooltip;
progressContainer.onmouseleave = () => {
timeTooltip.style.opacity = '0';
};
// Keyboard controls
document.addEventListener('keydown', handleKeyboardControls);
// Player events
player.onplay = () => {
updatePlayPauseIcon(false);
centerPlayBtn.classList.remove('show');
resetControlsTimeout();
};
player.onpause = () => {
updatePlayPauseIcon(true);
centerPlayBtn.classList.add('show');
showControls();
if (controlsTimeout) clearTimeout(controlsTimeout);
};
player.ontimeupdate = updateProgress;
player.onloadedmetadata = () => {
document.getElementById('duration').textContent = formatTime(player.duration);
};
player.onwaiting = () => {
document.getElementById('videoLoader').classList.add('show');
};
player.oncanplay = () => {
document.getElementById('videoLoader').classList.remove('show');
};
player.onprogress = updateBufferedProgress;
// Mouse movement detection
playerContainer.onmousemove = () => {
showControls();
};
// Touch support for mobile
playerContainer.ontouchstart = () => {
if (isControlsVisible) {
hideControls();
} else {
showControls();
}
};
// Show controls initially
showControls();
}
/**
* Cleans up custom video controls event listeners
*/
function cleanupCustomVideoControls() {
if (controlsTimeout) {
clearTimeout(controlsTimeout);
controlsTimeout = null;
}
document.removeEventListener('keydown', handleKeyboardControls);
const overlay = document.getElementById('videoOverlay');
overlay.classList.remove('controls-hidden');
}
/**
* Toggles play/pause state
*/
function togglePlayPause() {
const player = document.getElementById('mainPlayer');
if (player.paused) {
player.play().catch(e => console.log('Play error:', e));
} else {
player.pause();
}
}
/**
* Updates play/pause button icon
* @param {boolean} isPaused - Whether video is paused
*/
function updatePlayPauseIcon(isPaused) {
const icon = document.querySelector('#playPauseBtn i');
const centerIcon = document.querySelector('#centerPlayBtn i');
if (isPaused) {
icon.className = 'fas fa-play text-3xl';
if (centerIcon) centerIcon.className = 'fas fa-play text-white text-3xl ml-1';
} else {
icon.className = 'fas fa-pause text-3xl';
if (centerIcon) centerIcon.className = 'fas fa-pause text-white text-3xl';
}
}
/**
* Toggles mute state
*/
function toggleMute() {
const player = document.getElementById('mainPlayer');
const volumeSlider = document.getElementById('volumeSlider');
player.muted = !player.muted;
if (player.muted) {
updateVolumeIcon(0);
volumeSlider.value = 0;
} else {
updateVolumeIcon(player.volume);
volumeSlider.value = player.volume * 100;
}
updateVolumeSliderBackground(volumeSlider.value);
}
/**
* Updates volume icon based on volume level
* @param {number} volume - Volume level (0-1)
*/
function updateVolumeIcon(volume) {
const icon = document.querySelector('#volumeBtn i');
if (volume === 0) {
icon.className = 'fas fa-volume-mute text-xl';
} else if (volume < 0.5) {
icon.className = 'fas fa-volume-down text-xl';
} else {
icon.className = 'fas fa-volume-up text-xl';
}
}
/**
* Updates volume slider background gradient
* @param {number} value - Slider value (0-100)
*/
function updateVolumeSliderBackground(value) {
const slider = document.getElementById('volumeSlider');
slider.style.setProperty('--volume-percentage', `${value}%`);
}
/**
* Toggles fullscreen mode
*/
function toggleFullscreen() {
const overlay = document.getElementById('videoOverlay');
const icon = document.querySelector('#fullscreenBtn i');
if (!document.fullscreenElement && !document.webkitFullscreenElement && !document.mozFullScreenElement) {
// Enter fullscreen
if (overlay.requestFullscreen) {
overlay.requestFullscreen();
} else if (overlay.webkitRequestFullscreen) {
overlay.webkitRequestFullscreen();
} else if (overlay.mozRequestFullScreen) {
overlay.mozRequestFullScreen();
}
icon.className = 'fas fa-compress text-xl';
} else {
// Exit fullscreen
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
}
icon.className = 'fas fa-expand text-xl';
}
}
/**
* Updates progress bar and time display
*/
function updateProgress() {
const player = document.getElementById('mainPlayer');
const playbackProgress = document.getElementById('playbackProgress');
const progressHandle = document.getElementById('progressHandle');
const currentTimeEl = document.getElementById('currentTime');
if (!player.duration) return;
const percentage = (player.currentTime / player.duration) * 100;
playbackProgress.style.width = `${percentage}%`;
progressHandle.style.left = `${percentage}%`;
currentTimeEl.textContent = formatTime(player.currentTime);
}
/**
* Updates buffered progress bar
*/
function updateBufferedProgress() {
const player = document.getElementById('mainPlayer');
const bufferedProgress = document.getElementById('bufferedProgress');
if (player.buffered.length > 0 && player.duration) {
const bufferedEnd = player.buffered.end(player.buffered.length - 1);
const percentage = (bufferedEnd / player.duration) * 100;
bufferedProgress.style.width = `${percentage}%`;
}
}
/**
* Seeks to clicked position on progress bar
* @param {MouseEvent} e - Click event
*/
function seekToPosition(e) {
const player = document.getElementById('mainPlayer');
const progressContainer = document.getElementById('progressContainer');
const rect = progressContainer.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = clickX / rect.width;
const newTime = percentage * player.duration;
player.currentTime = newTime;
}
/**
* Shows time tooltip on progress bar hover
* @param {MouseEvent} e - Mouse event
*/
function showTimeTooltip(e) {
const player = document.getElementById('mainPlayer');
const progressContainer = document.getElementById('progressContainer');
const timeTooltip = document.getElementById('timeTooltip');
if (!player.duration) return;
const rect = progressContainer.getBoundingClientRect();
const hoverX = e.clientX - rect.left;
const percentage = hoverX / rect.width;
const hoverTime = percentage * player.duration;
timeTooltip.textContent = formatTime(hoverTime);
timeTooltip.style.left = `${hoverX}px`;
timeTooltip.style.opacity = '1';
}
/**
* Handles keyboard controls
* @param {KeyboardEvent} e - Keyboard event
*/
function handleKeyboardControls(e) {
const overlay = document.getElementById('videoOverlay');
// Only handle if video overlay is visible
if (overlay.classList.contains('hidden')) return;
const player = document.getElementById('mainPlayer');
switch(e.key) {
case ' ':
case 'k':
e.preventDefault();
togglePlayPause();
break;
case 'ArrowLeft':
e.preventDefault();
player.currentTime = Math.max(0, player.currentTime - 5);
showControls();
break;
case 'ArrowRight':
e.preventDefault();
player.currentTime = Math.min(player.duration, player.currentTime + 5);
showControls();
break;
case 'ArrowUp':
e.preventDefault();
player.volume = Math.min(1, player.volume + 0.1);
document.getElementById('volumeSlider').value = player.volume * 100;
updateVolumeIcon(player.volume);
updateVolumeSliderBackground(player.volume * 100);
showControls();
break;
case 'ArrowDown':
e.preventDefault();
player.volume = Math.max(0, player.volume - 0.1);
document.getElementById('volumeSlider').value = player.volume * 100;
updateVolumeIcon(player.volume);
updateVolumeSliderBackground(player.volume * 100);
showControls();
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'm':
e.preventDefault();
toggleMute();
showControls();
break;
case 'Escape':
if (document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement) {
e.preventDefault();
toggleFullscreen();
}
break;
}
}