import type { BaseMedia, Stream } from "../types/media"; // "1h 30m" / "45m"; with `seconds`, durations under an hour show "3m 42s" export const formatDuration = ( duration: number, { seconds = false }: { seconds?: boolean } = {} ): string => { const totalSeconds = Math.round(duration / 1000); if (seconds && totalSeconds < 3600) { const m = Math.floor(totalSeconds / 60); const s = totalSeconds % 60; if (m === 0) return `${s}s`; return s === 0 ? `${m}m` : `${m}m ${s}s`; } const minutes = Math.round(duration / 60000); if (minutes >= 60) { return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; } return `${minutes}m`; }; // Maps raw resolution strings (Plex) or pixel heights as strings (Jellyfin) to // standard labels. Numeric thresholds handle odd values like "400" → "480p". export function normalizeVideoResolution(value: string | undefined): string { if (!value) return ""; const str = value.toLowerCase().trim(); if (str === "4k" || str === "uhd") return "4K"; if (str === "sd") return "SD"; const num = parseInt(str, 10); if (!isNaN(num)) { if (num >= 2160) return "4K"; if (num >= 1440) return "1440p"; if (num >= 1080) return "1080p"; if (num >= 720) return "720p"; if (num >= 480) return "480p"; if (num >= 360) return "360p"; if (num >= 240) return "240p"; return "SD"; } return value.toUpperCase(); } export const formatQuality = (videoResolution?: string): string => videoResolution ?? ""; export const calculateProgress = ( viewOffset?: number, duration?: number ): number => { return viewOffset && duration ? (viewOffset / duration) * 100 : 0; }; export const getRatingSource = (rating: { image?: string; type: string; }): string => { if (!rating.image) return rating.type; const imageUrl = rating.image.toLowerCase(); if (imageUrl.includes("imdb")) return "IMDB"; if (imageUrl.includes("rottentomatoes")) { if (imageUrl.includes("spilled")) return "Rotten Tomatoes (Audience)"; if (imageUrl.includes("ripe")) return "Rotten Tomatoes (Critics)"; return "Rotten Tomatoes"; } if (imageUrl.includes("themoviedb")) return "TMDB"; return rating.type; }; export interface DisplayRating { label: string; value: string; image?: string; type: string; } export const getBestDisplayRating = ( ratings: { image?: string; type: string; value: string }[] | undefined, fallbackRating: number | undefined ): DisplayRating | null => { if (ratings && ratings.length > 0) { const find = (pred: (img: string) => boolean) => ratings.find((r) => pred(r.image?.toLowerCase() ?? "")); const wrap = (r: { image?: string; type: string; value: string }): DisplayRating => ({ label: getRatingSource(r), value: r.value, image: r.image, type: r.type, }); const rtCritics = find((img) => img.includes("rottentomatoes") && img.includes("ripe")); if (rtCritics) return wrap(rtCritics); const rtAudience = find((img) => img.includes("rottentomatoes") && img.includes("spilled")); if (rtAudience) return wrap(rtAudience); const imdb = find((img) => img.includes("imdb")); if (imdb) return wrap(imdb); const tmdb = find((img) => img.includes("themoviedb") || img.includes("thetvdb")); if (tmdb) return wrap(tmdb); return wrap(ratings[0]); } if (fallbackRating != null) { return { label: "rating", value: `${fallbackRating}/10`, type: "fallback" }; } return null; }; export const formatAudioChannels = ( channels: number | undefined, channelLayout?: string ): string => { if (!channels) return ""; // If we have channel layout info, use that for more accurate display if (channelLayout) { const layoutMatch = channelLayout.match(/(\d+\.\d+)/); if (layoutMatch) { return layoutMatch[1]; } } switch (channels) { case 1: return "1.0"; // Mono case 2: return "2.0"; // Stereo case 6: return "5.1"; // 5.1 Surround case 8: return "7.1"; // 7.1 Surround default: return `${channels}.0`; // Default format } }; type PlaybackDecisions = Pick; export function getPlaybackType({ videoDecision, audioDecision, transcodeHwRequested }: PlaybackDecisions): string { const label = videoDecision === "transcode" && audioDecision === "transcode" ? "Full Transcode" : videoDecision === "transcode" ? "Video Transcode" : audioDecision === "transcode" ? "Audio Transcode" : "Direct Play"; return transcodeHwRequested ? `${label} (HW)` : label; } export function toStream(item: BaseMedia): Stream { return { sessionId: item.sessionId, userId: item.userId, userAvatar: item.userAvatar, player: item.player, state: item.state, viewOffset: item.viewOffset, syncedAt: item.syncedAt, videoDecision: item.videoDecision, audioDecision: item.audioDecision, transcodeHwRequested: item.transcodeHwRequested, }; } // A merged card's streams, or the card's own session as a single stream export function getStreams(item: BaseMedia): Stream[] { return item.streams ?? [toStream(item)]; } // Distinct users across a merged card's streams, in stream order export function uniqueViewers(streams: Stream[]): Pick[] { const seen = new Map>(); for (const { userId, userAvatar } of streams) { if (!seen.has(userId)) seen.set(userId, { userId, userAvatar }); } return [...seen.values()]; } // "Caleb" / "Caleb & Alex" / "Caleb, Alex +2" export function formatNames(names: string[]): string { if (names.length <= 1) return names[0] ?? ""; if (names.length === 2) return `${names[0]} & ${names[1]}`; return `${names[0]}, ${names[1]} +${names.length - 2}`; }