mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-10 14:22:22 +00:00
Fix: Improve casting and segment skipping
Fixes several issues and improves the casting player experience. - Adds the ability to disable segment skipping options based on plugin settings. - Improves Chromecast integration by: - Adding PlaySessionId for better tracking. - Improves audio track selection - Uses mediaInfo builder for loading media. - Adds support for loading next/previous episodes - Translation support - Updates progress reporting to Jellyfin to be more accurate and reliable. - Fixes an error message in the direct player.
This commit is contained in:
@@ -21,7 +21,8 @@ export const formatTime = (ms: number): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate ending time based on current progress and duration
|
||||
* Calculate ending time based on current progress and duration.
|
||||
* Uses locale-aware formatting when available.
|
||||
*/
|
||||
export const calculateEndingTime = (
|
||||
currentMs: number,
|
||||
@@ -29,12 +30,20 @@ export const calculateEndingTime = (
|
||||
): string => {
|
||||
const remainingMs = durationMs - currentMs;
|
||||
const endTime = new Date(Date.now() + remainingMs);
|
||||
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}`;
|
||||
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}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,6 +85,7 @@ export const getPosterUrl = (
|
||||
* 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)}...`;
|
||||
};
|
||||
@@ -110,7 +120,10 @@ export const getProtocolName = (protocol: CastProtocol): string => {
|
||||
switch (protocol) {
|
||||
case "chromecast":
|
||||
return "Chromecast";
|
||||
// Future: Add cases for other protocols
|
||||
default: {
|
||||
const _exhaustive: never = protocol;
|
||||
return String(_exhaustive);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,16 +136,23 @@ export const getProtocolIcon = (
|
||||
switch (protocol) {
|
||||
case "chromecast":
|
||||
return "tv";
|
||||
// Future: Add icons for other protocols
|
||||
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 &&
|
||||
@@ -143,7 +163,7 @@ export const formatEpisodeInfo = (
|
||||
return `S${seasonNumber} E${episodeNumber}`;
|
||||
}
|
||||
if (episodeNumber !== undefined && episodeNumber !== null) {
|
||||
return `Episode ${episodeNumber}`;
|
||||
return `${episodeLabel} ${episodeNumber}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
85
utils/casting/mediaInfo.ts
Normal file
85
utils/casting/mediaInfo.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Shared helper to build Chromecast media metadata.
|
||||
* Eliminates duplication between PlayButton, casting-player reloadWithSettings, and loadEpisode.
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { MediaStreamType } from "react-native-google-cast";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
/**
|
||||
* Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`.
|
||||
*/
|
||||
export const buildCastMediaInfo = ({
|
||||
item,
|
||||
streamUrl,
|
||||
api,
|
||||
}: {
|
||||
item: BaseItemDto;
|
||||
streamUrl: string;
|
||||
api: Api;
|
||||
}) => {
|
||||
const streamDuration = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
const buildImages = (urls: (string | null | undefined)[]) =>
|
||||
urls.filter(Boolean).map((url) => ({ url: url as string }));
|
||||
|
||||
const metadata =
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow" as const,
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: buildImages([
|
||||
getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
}),
|
||||
]),
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
}),
|
||||
]),
|
||||
}
|
||||
: {
|
||||
type: "generic" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
}),
|
||||
]),
|
||||
};
|
||||
|
||||
return {
|
||||
contentId: item.Id,
|
||||
contentUrl: streamUrl,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration,
|
||||
customData: item,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,8 @@
|
||||
* Architecture allows for future protocols (AirPlay, DLNA, etc.)
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
export type CastProtocol = "chromecast";
|
||||
|
||||
export interface CastDevice {
|
||||
@@ -16,7 +18,7 @@ export interface CastDevice {
|
||||
export interface CastPlayerState {
|
||||
isConnected: boolean;
|
||||
isPlaying: boolean;
|
||||
currentItem: any | null;
|
||||
currentItem: BaseItemDto | null;
|
||||
currentDevice: CastDevice | null;
|
||||
protocol: CastProtocol | null;
|
||||
progress: number;
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Chromecast utility helper functions
|
||||
*/
|
||||
|
||||
import { CONNECTION_QUALITY, type ConnectionQuality } from "./options";
|
||||
|
||||
/**
|
||||
* Formats 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;
|
||||
|
||||
const pad = (num: number) => num.toString().padStart(2, "0");
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
return `${minutes}:${pad(seconds)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates ending time based on current time and remaining duration
|
||||
*/
|
||||
export const calculateEndingTime = (
|
||||
remainingMs: number,
|
||||
use24Hour = true,
|
||||
): string => {
|
||||
const endTime = new Date(Date.now() + remainingMs);
|
||||
const hours = endTime.getHours();
|
||||
const minutes = endTime.getMinutes();
|
||||
|
||||
if (use24Hour) {
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const period = hours >= 12 ? "PM" : "AM";
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${period}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines connection quality based on bitrate and latency
|
||||
*/
|
||||
export const getConnectionQuality = (
|
||||
bitrateMbps: number,
|
||||
latencyMs?: number,
|
||||
): ConnectionQuality => {
|
||||
// Prioritize bitrate, but factor in latency if available
|
||||
let effectiveBitrate = bitrateMbps;
|
||||
|
||||
if (latencyMs !== undefined && latencyMs > 200) {
|
||||
effectiveBitrate *= 0.7; // Reduce effective quality for high latency
|
||||
}
|
||||
|
||||
if (effectiveBitrate >= CONNECTION_QUALITY.EXCELLENT.min) {
|
||||
return "EXCELLENT";
|
||||
}
|
||||
if (effectiveBitrate >= CONNECTION_QUALITY.GOOD.min) {
|
||||
return "GOOD";
|
||||
}
|
||||
if (effectiveBitrate >= CONNECTION_QUALITY.FAIR.min) {
|
||||
return "FAIR";
|
||||
}
|
||||
return "POOR";
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if we should show next episode countdown
|
||||
*/
|
||||
export const shouldShowNextEpisodeCountdown = (
|
||||
remainingMs: number,
|
||||
hasNextEpisode: boolean,
|
||||
countdownStartSeconds: number,
|
||||
): boolean => {
|
||||
return hasNextEpisode && remainingMs <= countdownStartSeconds * 1000;
|
||||
};
|
||||
|
||||
/**
|
||||
* Truncates long titles with ellipsis
|
||||
*/
|
||||
export const truncateTitle = (title: string, maxLength: number): string => {
|
||||
if (title.length <= maxLength) return title;
|
||||
return `${title.substring(0, maxLength - 3)}...`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats episode info (e.g., "S1 E1" or "Episode 1")
|
||||
*/
|
||||
export const formatEpisodeInfo = (
|
||||
seasonNumber?: number | null,
|
||||
episodeNumber?: number | null,
|
||||
): string => {
|
||||
if (
|
||||
seasonNumber !== undefined &&
|
||||
seasonNumber !== null &&
|
||||
episodeNumber !== undefined &&
|
||||
episodeNumber !== null
|
||||
) {
|
||||
return `S${seasonNumber} E${episodeNumber}`;
|
||||
}
|
||||
if (episodeNumber !== undefined && episodeNumber !== null) {
|
||||
return `Episode ${episodeNumber}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the appropriate poster URL (season for series, primary for movies)
|
||||
*/
|
||||
export const getPosterUrl = (
|
||||
item: {
|
||||
Type?: string | null;
|
||||
ParentBackdropImageTags?: string[] | null;
|
||||
SeriesId?: string | null;
|
||||
Id?: string | null;
|
||||
},
|
||||
api: { basePath?: string },
|
||||
): string | null => {
|
||||
if (!api.basePath) return null;
|
||||
|
||||
if (item.Type === "Episode" && item.SeriesId) {
|
||||
// Use season poster for episodes
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Primary`;
|
||||
}
|
||||
|
||||
// Use primary image for movies and other types
|
||||
if (item.Id) {
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Primary`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if currently within a segment (intro, credits, etc.)
|
||||
*/
|
||||
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;
|
||||
};
|
||||
@@ -23,10 +23,10 @@ export const CHROMECAST_CONSTANTS = {
|
||||
} as const;
|
||||
|
||||
export const CONNECTION_QUALITY = {
|
||||
EXCELLENT: { min: 50, label: "Excellent", icon: "signal" },
|
||||
GOOD: { min: 30, label: "Good", icon: "signal" },
|
||||
FAIR: { min: 15, label: "Fair", icon: "signal" },
|
||||
POOR: { min: 0, label: "Poor", icon: "signal" },
|
||||
EXCELLENT: { min: 50, label: "Excellent", icon: "wifi" }, // min Mbps
|
||||
GOOD: { min: 30, label: "Good", icon: "signal" }, // min Mbps
|
||||
FAIR: { min: 15, label: "Fair", icon: "cellular" }, // min Mbps
|
||||
POOR: { min: 0, label: "Poor", icon: "warning" }, // min Mbps
|
||||
} as const;
|
||||
|
||||
export type ConnectionQuality = keyof typeof CONNECTION_QUALITY;
|
||||
@@ -66,5 +66,5 @@ export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = {
|
||||
volume: 1,
|
||||
isMuted: false,
|
||||
currentItemId: null,
|
||||
connectionQuality: "EXCELLENT",
|
||||
connectionQuality: "GOOD",
|
||||
};
|
||||
|
||||
@@ -92,9 +92,5 @@ export const chromecast: DeviceProfile = {
|
||||
Format: "vtt",
|
||||
Method: "Encode",
|
||||
},
|
||||
{
|
||||
Format: "vtt",
|
||||
Method: "Encode",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -91,9 +91,5 @@ export const chromecasth265: DeviceProfile = {
|
||||
Format: "vtt",
|
||||
Method: "Encode",
|
||||
},
|
||||
{
|
||||
Format: "vtt",
|
||||
Method: "Encode",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user