From b595e845bbcaeaa82ab96f6d2f9d198fcbb6cc87 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:59:41 -0800 Subject: [PATCH] Extracted utils --- .../utils/components/FloatingVideoUtils.js | 124 +++++++++++++ .../src/utils/components/SeriesModalUtils.js | 166 ++++++++++++++++++ .../src/utils/components/VODModalUtils.js | 124 +++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 frontend/src/utils/components/FloatingVideoUtils.js create mode 100644 frontend/src/utils/components/SeriesModalUtils.js create mode 100644 frontend/src/utils/components/VODModalUtils.js diff --git a/frontend/src/utils/components/FloatingVideoUtils.js b/frontend/src/utils/components/FloatingVideoUtils.js new file mode 100644 index 00000000..a14f511e --- /dev/null +++ b/frontend/src/utils/components/FloatingVideoUtils.js @@ -0,0 +1,124 @@ +export const getLivePlayerErrorMessage = (errorType, errorDetail) => { + if (errorType !== 'MediaError') { + return errorDetail + ? `Error: ${errorType} - ${errorDetail}` + : `Error: ${errorType}`; + } + + const errorString = errorDetail?.toLowerCase() || ''; + + if ( + errorString.includes('audio') || + errorString.includes('ac3') || + errorString.includes('ac-3') + ) { + return 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'; + } + + if ( + errorString.includes('video') || + errorString.includes('h264') || + errorString.includes('h.264') + ) { + return 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'; + } + + if (errorString.includes('mse')) { + return "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."; + } + + return 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'; +}; + +export const getVODPlayerErrorMessage = (error) => { + if (!error) return 'Video playback error'; + + switch (error.code) { + case error.MEDIA_ERR_ABORTED: + return 'Video playback was aborted'; + case error.MEDIA_ERR_NETWORK: + return 'Network error while loading video'; + case error.MEDIA_ERR_DECODE: + return 'Video codec not supported by your browser'; + case error.MEDIA_ERR_SRC_NOT_SUPPORTED: + return 'Video format not supported by your browser'; + default: + return error.message || 'Unknown video error'; + } +}; + +export const getClientCoordinates = (event) => ({ + clientX: event.touches?.[0]?.clientX ?? event.clientX, + clientY: event.touches?.[0]?.clientY ?? event.clientY, +}); + +export const calculateNewDimensions = ( + deltaX, + deltaY, + startWidth, + startHeight, + handle, + ratio +) => { + const widthDelta = deltaX * handle.xDir; + const heightDelta = deltaY * handle.yDir; + + let width = startWidth + widthDelta; + let height = width / ratio; + + // Use vertical-driven resize if user drags mostly vertically + if (Math.abs(deltaY) > Math.abs(deltaX)) { + height = startHeight + heightDelta; + width = height * ratio; + } + + return { width, height }; +}; + +export const applyConstraints = ( + width, + height, + ratio, + startPos, + handle, + minWidth, + minHeight, + visibleMargin +) => { + // Apply minimum constraints + if (width < minWidth) { + width = minWidth; + height = width / ratio; + } + if (height < minHeight) { + height = minHeight; + width = height * ratio; + } + + // Apply viewport constraints + const posX = startPos?.x ?? 0; + const posY = startPos?.y ?? 0; + const maxWidth = !handle.isLeft + ? Math.max(minWidth, window.innerWidth - posX - visibleMargin) + : null; + const maxHeight = !handle.isTop + ? Math.max(minHeight, window.innerHeight - posY - visibleMargin) + : null; + + if (maxWidth && width > maxWidth) { + width = maxWidth; + height = width / ratio; + } + if (maxHeight && height > maxHeight) { + height = maxHeight; + width = height * ratio; + } + + // Final adjustment to maintain aspect ratio + if (maxWidth && width > maxWidth) { + width = maxWidth; + height = width / ratio; + } + + return { width, height }; +}; diff --git a/frontend/src/utils/components/SeriesModalUtils.js b/frontend/src/utils/components/SeriesModalUtils.js new file mode 100644 index 00000000..11ab2697 --- /dev/null +++ b/frontend/src/utils/components/SeriesModalUtils.js @@ -0,0 +1,166 @@ +export const imdbUrl = (imdb_id) => + imdb_id ? `https://www.imdb.com/title/${imdb_id}` : ''; + +export const tmdbUrl = (tmdb_id, type = 'movie') => + tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : ''; + +export const formatDuration = (seconds) => { + if (!seconds) return ''; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`; +}; + +export const formatStreamLabel = (relation) => { + const provider = relation.m3u_account.name; + const streamId = relation.stream_id; + const quality = extractQuality(relation); + + return `${provider}${quality ?? ''}${streamId ? ` (Stream ${streamId})` : ''}`; +}; + +const extractQuality = (relation) => { + // 1. Primary: Backend quality_info field + const fromQualityInfo = getQualityFromBackend(relation.quality_info); + if (fromQualityInfo) return fromQualityInfo; + + // 2. Secondary: Custom properties detailed_info + if (relation.custom_properties?.detailed_info) { + const fromDetailedInfo = getQualityFromDetailedInfo(relation.custom_properties.detailed_info); + if (fromDetailedInfo) return fromDetailedInfo; + } + + // 3. Fallback: Stream name + if (relation.stream_name) { + return getQualityFromStreamName(relation.stream_name); + } + + return ''; +}; + +const getQualityFromBackend = (qualityInfo) => { + if (!qualityInfo) return ''; + + if (qualityInfo.quality) { + return ` - ${qualityInfo.quality}`; + } else if (qualityInfo.resolution) { + return ` - ${qualityInfo.resolution}`; + } else if (qualityInfo.bitrate) { + return ` - ${qualityInfo.bitrate}`; + } + return ''; +}; + +const getQualityFromDetailedInfo = (detailedInfo) => { + // Check video dimensions first + if (detailedInfo.video?.width && detailedInfo.video?.height) { + return getQualityInfoFromDimensions(detailedInfo.video.width, detailedInfo.video.height); + } + + // Check name field + if (detailedInfo.name) { + return parseQualityFromText(detailedInfo.name); + } + + return ''; +}; + +const getQualityFromStreamName = (streamName) => { + return parseQualityFromText(streamName); +}; + +const parseQualityFromText = (text) => { + if (text.includes('4K') || text.includes('2160p')) return ' - 4K'; + if (text.includes('1080p') || text.includes('FHD')) return ' - 1080p'; + if (text.includes('720p') || text.includes('HD')) return ' - 720p'; + if (text.includes('480p')) return ' - 480p'; + return ''; +}; + +const getQualityInfoFromDimensions = (width, height) => { + // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios) + if (width >= 3840) { + return ' - 4K'; + } else if (width >= 1920) { + return ' - 1080p'; + } else if (width >= 1280) { + return ' - 720p'; + } else if (width >= 854) { + return ' - 480p'; + } else { + return ` - ${width}x${height}`; + } +} + +export const sortEpisodesList = (episodesList) => { + return episodesList.sort((a, b) => { + if (a.season_number !== b.season_number) { + return (a.season_number || 0) - (b.season_number || 0); + } + return (a.episode_number || 0) - (b.episode_number || 0); + }); +}; + +export const groupEpisodesBySeason = (seriesEpisodes) => { + const grouped = {}; + seriesEpisodes.forEach((episode) => { + const season = episode.season_number || 1; + if (!grouped[season]) { + grouped[season] = []; + } + grouped[season].push(episode); + }); + return grouped; +}; + +export const sortBySeasonNumber = (episodesBySeason) => { + return Object.keys(episodesBySeason) + .map(Number) + .sort((a, b) => a - b); +}; + +export const getEpisodeStreamUrl = (episode, selectedProvider, env_mode) => { + let streamUrl = `/proxy/vod/episode/${episode.uuid}`; + + // Add selected provider as query parameter if available + if (selectedProvider) { + // Use stream_id for most specific selection, fallback to account_id + if (selectedProvider.stream_id) { + streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; + } else { + streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; + } + } + + if (env_mode === 'dev') { + streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; + } else { + streamUrl = `${window.location.origin}${streamUrl}`; + } + return streamUrl; +}; + +// Helper to get embeddable YouTube URL +export const getYouTubeEmbedUrl = (url) => { + if (!url) return ''; + // Accepts full YouTube URLs or just IDs + const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/); + const videoId = match ? match[1] : url; + return `https://www.youtube.com/embed/${videoId}`; +}; + +export const getEpisodeAirdate = (episode) => { + return episode.air_date + ? new Date(episode.air_date).toLocaleDateString() + : 'N/A'; +}; + +export const getTmdbUrlLink = (displaySeries, episode) => { + return ( + tmdbUrl(displaySeries.tmdb_id, 'tv') + + (episode.season_number && episode.episode_number + ? `/season/${episode.season_number}/episode/${episode.episode_number}` + : '') + ); +}; diff --git a/frontend/src/utils/components/VODModalUtils.js b/frontend/src/utils/components/VODModalUtils.js new file mode 100644 index 00000000..111fd477 --- /dev/null +++ b/frontend/src/utils/components/VODModalUtils.js @@ -0,0 +1,124 @@ +const hasValidTechnicalDetails = (obj) => { + return obj?.bitrate || obj?.video || obj?.audio; +}; + +const extractFromDetailedInfo = (customProperties) => { + const detailedInfo = customProperties?.detailed_info; + if (!detailedInfo) return null; + + return { + bitrate: detailedInfo.bitrate || null, + video: detailedInfo.video || null, + audio: detailedInfo.audio || null, + }; +}; + +export const getTechnicalDetails = (selectedProvider, defaultVOD) => { + if (!selectedProvider) { + return { + bitrate: defaultVOD?.bitrate, + video: defaultVOD?.video, + audio: defaultVOD?.audio, + }; + } + + // Try movie/episode content first + const content = selectedProvider.movie || selectedProvider.episode; + if (content && hasValidTechnicalDetails(content)) { + return { + bitrate: content.bitrate, + video: content.video, + audio: content.audio, + }; + } + + // Try provider object directly + if (hasValidTechnicalDetails(selectedProvider)) { + return { + bitrate: selectedProvider.bitrate, + video: selectedProvider.video, + audio: selectedProvider.audio, + }; + } + + // Try custom_properties.detailed_info + const detailedInfo = extractFromDetailedInfo( + selectedProvider.custom_properties + ); + if (detailedInfo && hasValidTechnicalDetails(detailedInfo)) { + return detailedInfo; + } + + // Fallback to defaultVOD + return { + bitrate: defaultVOD?.bitrate, + video: defaultVOD?.video, + audio: defaultVOD?.audio, + }; +}; + +export const getMovieStreamUrl = (vod, selectedProvider, env_mode) => { + let streamUrl = `/proxy/vod/movie/${vod.uuid}`; + + // Add selected provider as query parameter if available + if (selectedProvider) { + // Use stream_id for most specific selection, fallback to account_id + if (selectedProvider.stream_id) { + streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; + } else { + streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; + } + } + + if (env_mode === 'dev') { + streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; + } else { + streamUrl = `${window.location.origin}${streamUrl}`; + } + return streamUrl; +}; + +export const formatVideoDetails = (video) => { + const parts = []; + + const codec = + video.codec_long_name && video.codec_long_name !== 'unknown' + ? video.codec_long_name + : video.codec_name; + parts.push(codec); + + if (video.profile) parts.push(`(${video.profile})`); + if (video.width && video.height) parts.push(`${video.width}x${video.height}`); + if (video.display_aspect_ratio) + parts.push(`Aspect Ratio: ${video.display_aspect_ratio}`); + if (video.bit_rate) + parts.push(`Bitrate: ${Math.round(Number(video.bit_rate) / 1000)} kbps`); + if (video.r_frame_rate) parts.push(`Frame Rate: ${video.r_frame_rate} fps`); + if (video.tags?.encoder) parts.push(`Encoder: ${video.tags.encoder}`); + + return parts.join(', '); +}; + +export const formatAudioDetails = (audio) => { + const parts = []; + + const codec = + audio.codec_long_name && audio.codec_long_name !== 'unknown' + ? audio.codec_long_name + : audio.codec_name; + parts.push(codec); + + if (audio.profile) parts.push(`(${audio.profile})`); + + const channels = + audio.channel_layout || (audio.channels ? `${audio.channels}` : null); + if (channels) parts.push(`Channels: ${channels}`); + + if (audio.sample_rate) parts.push(`Sample Rate: ${audio.sample_rate} Hz`); + if (audio.bit_rate) + parts.push(`Bitrate: ${Math.round(Number(audio.bit_rate) / 1000)} kbps`); + if (audio.tags?.handler_name) + parts.push(`Handler: ${audio.tags.handler_name}`); + + return parts.join(', '); +};