Ctrl K
ComunidadGuías…

utils/mediaCardUtils.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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}`;
}