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:
Uruk
2026-02-08 15:01:02 +01:00
parent 761b464fb6
commit c243fbc0ba
24 changed files with 463 additions and 724 deletions

View File

@@ -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 "";
};

View 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,
};
};

View File

@@ -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;