mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-10 06:12:23 +00:00
Fixes several issues and improves the overall Chromecast casting experience: - Implements an AbortController for fetching item data to prevent race conditions. - Syncs live progress in the mini player more accurately using elapsed real time. - Prevents event propagation in the mini player's play/pause button. - Ensures the disconnect callback in the connection menu is always called. - Retries scrolling in the episode list on failure. - Handles unmute failures gracefully in volume controls. - Clamps seek positions to prevent exceeding duration. - Fixes reporting playback start multiple times - Improves segment calculation in `useChromecastSegments` - Prevents race condition with `isPlaying` state in `Controls` component Also includes minor UI and timing adjustments for a smoother user experience.
181 lines
4.5 KiB
TypeScript
181 lines
4.5 KiB
TypeScript
/**
|
|
* Unified Casting Helper Functions
|
|
* Common utilities for casting protocols
|
|
*/
|
|
|
|
import type { CastProtocol, ConnectionQuality } from "./types";
|
|
|
|
/**
|
|
* Format milliseconds to HH:MM:SS or MM:SS
|
|
*/
|
|
export const formatTime = (ms: number): string => {
|
|
const totalSeconds = Math.floor(ms / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
}
|
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
/**
|
|
* Calculate ending time based on current progress and duration.
|
|
* Uses locale-aware formatting when available.
|
|
*/
|
|
export const calculateEndingTime = (
|
|
currentMs: number,
|
|
durationMs: number,
|
|
): string => {
|
|
const remainingMs = durationMs - currentMs;
|
|
const endTime = new Date(Date.now() + remainingMs);
|
|
|
|
try {
|
|
return endTime.toLocaleTimeString(undefined, {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
// Fallback for environments without Intl support
|
|
const hours = endTime.getHours();
|
|
const minutes = endTime.getMinutes();
|
|
const ampm = hours >= 12 ? "PM" : "AM";
|
|
const displayHours = hours % 12 || 12;
|
|
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Determine connection quality based on bitrate
|
|
*/
|
|
export const getConnectionQuality = (bitrate?: number): ConnectionQuality => {
|
|
if (bitrate == null) return "good";
|
|
const mbps = bitrate / 1000000;
|
|
|
|
if (mbps >= 15) return "excellent";
|
|
if (mbps >= 8) return "good";
|
|
if (mbps >= 4) return "fair";
|
|
return "poor";
|
|
};
|
|
|
|
/**
|
|
* Get poster URL for item with specified dimensions
|
|
*/
|
|
export const getPosterUrl = (
|
|
baseUrl: string | undefined,
|
|
itemId: string | undefined,
|
|
tag: string | undefined,
|
|
width: number,
|
|
height: number,
|
|
): string | null => {
|
|
if (!baseUrl || !itemId) return null;
|
|
|
|
const params = new URLSearchParams({
|
|
maxWidth: width.toString(),
|
|
maxHeight: height.toString(),
|
|
quality: "90",
|
|
...(tag && { tag }),
|
|
});
|
|
|
|
return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
|
|
};
|
|
|
|
/**
|
|
* Truncate title to max length with ellipsis
|
|
*/
|
|
export const truncateTitle = (title: string, maxLength: number): string => {
|
|
if (maxLength < 4) return title.substring(0, maxLength);
|
|
if (title.length <= maxLength) return title;
|
|
return `${title.substring(0, maxLength - 3)}...`;
|
|
};
|
|
|
|
/**
|
|
* Check if current time is within a segment
|
|
*/
|
|
export const isWithinSegment = (
|
|
currentMs: number,
|
|
segment: { start: number; end: number } | null,
|
|
): boolean => {
|
|
if (!segment) return false;
|
|
const currentSeconds = currentMs / 1000;
|
|
return currentSeconds >= segment.start && currentSeconds <= segment.end;
|
|
};
|
|
|
|
/**
|
|
* Format bitrate to human-readable string
|
|
*/
|
|
export const formatBitrate = (bitrate: number): string => {
|
|
const mbps = bitrate / 1000000;
|
|
if (mbps >= 1) {
|
|
return `${mbps.toFixed(1)} Mbps`;
|
|
}
|
|
return `${(bitrate / 1000).toFixed(0)} Kbps`;
|
|
};
|
|
|
|
/**
|
|
* Get protocol display name
|
|
*/
|
|
export const getProtocolName = (protocol: CastProtocol): string => {
|
|
switch (protocol) {
|
|
case "chromecast":
|
|
return "Chromecast";
|
|
default: {
|
|
const _exhaustive: never = protocol;
|
|
return String(_exhaustive);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get protocol icon name
|
|
*/
|
|
export const getProtocolIcon = (
|
|
protocol: CastProtocol,
|
|
): "tv" | "logo-apple" => {
|
|
switch (protocol) {
|
|
case "chromecast":
|
|
return "tv";
|
|
default: {
|
|
const _exhaustive: never = protocol;
|
|
return "tv";
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Format episode info (e.g., "S1 E1" or "Episode 1")
|
|
* @param seasonNumber - Season number
|
|
* @param episodeNumber - Episode number
|
|
* @param episodeLabel - Optional label for standalone episode (e.g. translated "Episode")
|
|
*/
|
|
export const formatEpisodeInfo = (
|
|
seasonNumber?: number | null,
|
|
episodeNumber?: number | null,
|
|
episodeLabel = "Episode",
|
|
): string => {
|
|
if (
|
|
seasonNumber !== undefined &&
|
|
seasonNumber !== null &&
|
|
episodeNumber !== undefined &&
|
|
episodeNumber !== null
|
|
) {
|
|
return `S${seasonNumber} E${episodeNumber}`;
|
|
}
|
|
if (episodeNumber !== undefined && episodeNumber !== null) {
|
|
return `${episodeLabel} ${episodeNumber}`;
|
|
}
|
|
return "";
|
|
};
|
|
|
|
/**
|
|
* Check if we should show next episode countdown
|
|
*/
|
|
export const shouldShowNextEpisodeCountdown = (
|
|
remainingMs: number,
|
|
hasNextEpisode: boolean,
|
|
countdownStartSeconds: number,
|
|
): boolean => {
|
|
return hasNextEpisode && remainingMs <= countdownStartSeconds * 1000;
|
|
};
|