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

@@ -6,6 +6,7 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -24,6 +25,7 @@ export const ChromecastConnectionMenu: React.FC<
ChromecastConnectionMenuProps
> = ({ visible, onClose, onDisconnect }) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const castDevice = useCastDevice();
const castSession = useCastSession();
@@ -191,10 +193,10 @@ export const ChromecastConnectionMenu: React.FC<
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{castDevice?.friendlyName || "Chromecast"}
{castDevice?.friendlyName || t("casting_player.chromecast")}
</Text>
<Text style={{ color: protocolColor, fontSize: 12 }}>
Connected
{t("casting_player.connected")}
</Text>
</View>
</View>
@@ -213,9 +215,11 @@ export const ChromecastConnectionMenu: React.FC<
marginBottom: 12,
}}
>
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? "Muted" : `${displayVolume}%`}
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
@@ -255,7 +259,15 @@ export const ChromecastConnectionMenu: React.FC<
handleVolumeChange(value);
if (isMuted) {
setIsMuted(false);
castSession?.setMute(false);
try {
castSession?.setMute(false);
} catch (error: unknown) {
console.error(
"[ChromecastConnectionMenu] Failed to unmute:",
error,
);
setIsMuted(true); // Rollback on failure
}
}
}}
onSlidingComplete={handleVolumeComplete}
@@ -288,7 +300,7 @@ export const ChromecastConnectionMenu: React.FC<
<Text
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
>
Disconnect
{t("casting_player.disconnect")}
</Text>
</Pressable>
</View>

View File

@@ -5,10 +5,11 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { type Device, useCastSession } from "react-native-google-cast";
import { useCastSession } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -16,7 +17,7 @@ import { Text } from "@/components/common/Text";
interface ChromecastDeviceSheetProps {
visible: boolean;
onClose: () => void;
device: Device | null;
device: { friendlyName?: string } | null;
onDisconnect: () => Promise<void>;
volume?: number;
onVolumeChange?: (volume: number) => Promise<void>;
@@ -31,6 +32,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onVolumeChange,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
const volumeValue = useSharedValue(volume * 100);
@@ -76,16 +78,14 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
// Check mute state
const muteState = await castSession.isMute();
if (muteState !== isMuted) {
setIsMuted(muteState);
}
setIsMuted(muteState);
} catch {
// Ignore errors - device might be disconnected
}
}, 1000);
return () => clearInterval(interval);
}, [visible, castSession, displayVolume, volumeValue, isMuted]);
}, [visible, castSession, volumeValue]);
const handleDisconnect = async () => {
setIsDisconnecting(true);
@@ -107,7 +107,6 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
// This works even when no media is playing, unlike setStreamVolume
if (castSession) {
await castSession.setVolume(newVolume);
console.log("[Volume] Set device volume via CastSession:", newVolume);
} else if (onVolumeChange) {
// Fallback to prop method if session not available
await onVolumeChange(newVolume);
@@ -153,6 +152,15 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
}
}, [castSession, isMuted]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
};
}, []);
return (
<Modal
visible={visible}
@@ -196,7 +204,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Text
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
>
Chromecast
{t("casting_player.chromecast")}
</Text>
</View>
<Pressable onPress={onClose} style={{ padding: 8 }}>
@@ -208,12 +216,12 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<View style={{ padding: 16 }}>
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
Device Name
{t("casting_player.device_name")}
</Text>
<Text
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
>
{device?.friendlyName || "Unknown Device"}
{device?.friendlyName || t("casting_player.unknown_device")}
</Text>
</View>
{/* Volume control */}
@@ -226,9 +234,11 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
marginBottom: 12,
}}
>
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? "Muted" : `${displayVolume}%`}
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
@@ -317,7 +327,9 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
{isDisconnecting
? t("casting_player.disconnecting")
: t("casting_player.stop_casting")}
</Text>
</Pressable>
</View>

View File

@@ -8,6 +8,7 @@ import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -32,6 +33,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
api,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const flatListRef = useRef<FlatList>(null);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
@@ -76,13 +78,14 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
);
if (currentIndex !== -1 && flatListRef.current) {
// Delay to ensure FlatList is rendered
setTimeout(() => {
const timeoutId = setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: currentIndex,
animated: true,
viewPosition: 0.5, // Center the item
});
}, 300);
return () => clearTimeout(timeoutId);
}
}
}, [visible, currentItem, filteredEpisodes]);
@@ -147,7 +150,8 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}}
numberOfLines={1}
>
{item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)}
{item.IndexNumber}.{" "}
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
</Text>
{item.Overview && (
<Text
@@ -178,7 +182,8 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
)}
{item.RunTimeTicks && (
<Text style={{ color: "#666", fontSize: 11 }}>
{Math.round(item.RunTimeTicks / 600000000)} min
{Math.round(item.RunTimeTicks / 600000000)}{" "}
{t("casting_player.minutes_short")}
</Text>
)}
</View>
@@ -237,7 +242,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Episodes
{t("casting_player.episodes")}
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
@@ -270,7 +275,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
fontWeight: selectedSeason === season ? "600" : "400",
}}
>
Season {season}
{t("casting_player.season", { number: season })}
</Text>
</Pressable>
))}
@@ -283,7 +288,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
ref={flatListRef}
data={filteredEpisodes}
renderItem={renderEpisode}
keyExtractor={(item) => item.Id || ""}
keyExtractor={(item, index) => item.Id || `episode-${index}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,

View File

@@ -6,6 +6,7 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -51,6 +52,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
onPlaybackSpeedChange,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [expandedSection, setExpandedSection] = useState<string | null>(null);
const toggleSection = (section: string) => {
@@ -124,7 +126,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Playback Settings
{t("casting_player.playback_settings")}
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
@@ -132,9 +134,14 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
</View>
<ScrollView>
{/* Quality/Media Source */}
{renderSectionHeader("Quality", "film-outline", "quality")}
{expandedSection === "quality" && (
{/* Quality/Media Source - only show when sources available */}
{mediaSources.length > 0 &&
renderSectionHeader(
t("casting_player.quality"),
"film-outline",
"quality",
)}
{mediaSources.length > 0 && expandedSection === "quality" && (
<View style={{ paddingVertical: 8 }}>
{mediaSources.map((source) => (
<Pressable
@@ -176,7 +183,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
{/* Audio Tracks - only show if more than one track */}
{audioTracks.length > 1 &&
renderSectionHeader("Audio", "musical-notes", "audio")}
renderSectionHeader(
t("casting_player.audio"),
"musical-notes",
"audio",
)}
{audioTracks.length > 1 && expandedSection === "audio" && (
<View style={{ paddingVertical: 8 }}>
{audioTracks.map((track) => (
@@ -199,7 +210,9 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle || track.language || "Unknown"}
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{track.codec && (
<Text
@@ -219,7 +232,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
{/* Subtitle Tracks - only show if subtitles available */}
{subtitleTracks.length > 0 &&
renderSectionHeader("Subtitles", "text", "subtitles")}
renderSectionHeader(
t("casting_player.subtitles"),
"text",
"subtitles",
)}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}>
<Pressable
@@ -238,7 +255,9 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>None</Text>
<Text style={{ color: "white", fontSize: 15 }}>
{t("casting_player.none")}
</Text>
{selectedSubtitleTrack === null && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
@@ -263,14 +282,16 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle || track.language || "Unknown"}
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{track.codec && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{track.codec.toUpperCase()}
{track.isForced && " • Forced"}
{track.isForced && `${t("casting_player.forced")}`}
</Text>
)}
</View>
@@ -283,7 +304,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)}
{/* Playback Speed */}
{renderSectionHeader("Playback Speed", "speedometer", "speed")}
{renderSectionHeader(
t("casting_player.playback_speed"),
"speedometer",
"speed",
)}
{expandedSection === "speed" && (
<View style={{ paddingVertical: 8 }}>
{PLAYBACK_SPEEDS.map((speed) => (
@@ -299,13 +324,15 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
alignItems: "center",
padding: 16,
backgroundColor:
playbackSpeed === speed ? "#2a2a2a" : "transparent",
Math.abs(playbackSpeed - speed) < 0.01
? "#2a2a2a"
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>
{speed === 1 ? "Normal" : `${speed}x`}
{speed === 1 ? t("casting_player.normal") : `${speed}x`}
</Text>
{playbackSpeed === speed && (
{Math.abs(playbackSpeed - speed) < 0.01 && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>