utils/mediaCardUtils.ts
Rama main · réplica de lectura del repositorio original.
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<Stream, "videoDecision" | "audioDecision" | "transcodeHwRequested">;
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<Stream, "userId" | "userAvatar">[] {
const seen = new Map<string, Pick<Stream, "userId" | "userAvatar">>();
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}`;
}