mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-14 01:40:23 +01:00
fix: Refactors Chromecast casting player
Refactors the Chromecast casting player for better compatibility, UI improvements, and stability. - Adds auto-selection of stereo audio tracks for improved Chromecast compatibility - Refactors episode list to filter out virtual episodes and allow season selection - Improves UI layout and styling - Removes connection quality indicator - Fixes progress reporting to Jellyfin - Updates volume control to use CastSession for device volume
This commit is contained in:
@@ -13,7 +13,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
|
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import {
|
import GoogleCast, {
|
||||||
CastState,
|
CastState,
|
||||||
MediaPlayerState,
|
MediaPlayerState,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
@@ -39,7 +39,6 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import {
|
import {
|
||||||
calculateEndingTime,
|
calculateEndingTime,
|
||||||
formatTime,
|
formatTime,
|
||||||
getConnectionQuality,
|
|
||||||
getPosterUrl,
|
getPosterUrl,
|
||||||
shouldShowNextEpisodeCountdown,
|
shouldShowNextEpisodeCountdown,
|
||||||
truncateTitle,
|
truncateTitle,
|
||||||
@@ -188,7 +187,6 @@ export default function CastingPlayerScreen() {
|
|||||||
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
||||||
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
|
|
||||||
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
|
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
|
||||||
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
|
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
|
||||||
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
|
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
|
||||||
@@ -316,6 +314,32 @@ export default function CastingPlayerScreen() {
|
|||||||
return variants;
|
return variants;
|
||||||
}, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]);
|
}, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]);
|
||||||
|
|
||||||
|
// Auto-select stereo audio track for better Chromecast compatibility
|
||||||
|
useEffect(() => {
|
||||||
|
if (!remoteMediaClient || !mediaStatus?.mediaInfo) return;
|
||||||
|
|
||||||
|
const currentTrack = availableAudioTracks.find(
|
||||||
|
(t) => t.index === selectedAudioTrackIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If current track is 5.1+ audio, try to switch to stereo
|
||||||
|
if (currentTrack && (currentTrack.channels || 0) > 2) {
|
||||||
|
const stereoTrack = availableAudioTracks.find((t) => t.channels === 2);
|
||||||
|
if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) {
|
||||||
|
console.log(
|
||||||
|
"[Audio] Switching from 5.1 to stereo for better compatibility:",
|
||||||
|
currentTrack.displayTitle,
|
||||||
|
"->",
|
||||||
|
stereoTrack.displayTitle,
|
||||||
|
);
|
||||||
|
setSelectedAudioTrackIndex(stereoTrack.index);
|
||||||
|
remoteMediaClient
|
||||||
|
.setActiveTrackIds([stereoTrack.index])
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mediaStatus?.mediaInfo, availableAudioTracks, remoteMediaClient]);
|
||||||
|
|
||||||
// Fetch episodes for TV shows
|
// Fetch episodes for TV shows
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api)
|
if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api)
|
||||||
@@ -324,9 +348,10 @@ export default function CastingPlayerScreen() {
|
|||||||
const fetchEpisodes = async () => {
|
const fetchEpisodes = async () => {
|
||||||
try {
|
try {
|
||||||
const tvShowsApi = getTvShowsApi(api);
|
const tvShowsApi = getTvShowsApi(api);
|
||||||
|
// Fetch ALL episodes from ALL seasons by removing seasonId filter
|
||||||
const response = await tvShowsApi.getEpisodes({
|
const response = await tvShowsApi.getEpisodes({
|
||||||
seriesId: currentItem.SeriesId!,
|
seriesId: currentItem.SeriesId!,
|
||||||
seasonId: currentItem.SeasonId || undefined,
|
// Don't filter by seasonId - get all seasons
|
||||||
userId: api.accessToken ? undefined : "",
|
userId: api.accessToken ? undefined : "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -475,8 +500,8 @@ export default function CastingPlayerScreen() {
|
|||||||
{ imageItemId, seasonImageTag },
|
{ imageItemId, seasonImageTag },
|
||||||
);
|
);
|
||||||
return seasonImageTag
|
return seasonImageTag
|
||||||
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=390&fillWidth=260&quality=96&tag=${seasonImageTag}`
|
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
||||||
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=390&fillWidth=260&quality=96`;
|
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to item poster for non-episodes or if season data not loaded
|
// Fallback to item poster for non-episodes or if season data not loaded
|
||||||
@@ -510,27 +535,6 @@ export default function CastingPlayerScreen() {
|
|||||||
|
|
||||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||||
|
|
||||||
const connectionQuality = useMemo(() => {
|
|
||||||
const bitrate = availableMediaSources[0]?.bitrate;
|
|
||||||
return getConnectionQuality(bitrate);
|
|
||||||
}, [availableMediaSources]);
|
|
||||||
|
|
||||||
// Get quality indicator color
|
|
||||||
const qualityColor = useMemo(() => {
|
|
||||||
switch (connectionQuality) {
|
|
||||||
case "excellent":
|
|
||||||
return "#22c55e"; // green
|
|
||||||
case "good":
|
|
||||||
return "#eab308"; // yellow
|
|
||||||
case "fair":
|
|
||||||
return "#f59e0b"; // orange
|
|
||||||
case "poor":
|
|
||||||
return "#ef4444"; // red
|
|
||||||
default:
|
|
||||||
return protocolColor;
|
|
||||||
}
|
|
||||||
}, [connectionQuality, protocolColor]);
|
|
||||||
|
|
||||||
const _showNextEpisode = useMemo(() => {
|
const _showNextEpisode = useMemo(() => {
|
||||||
if (currentItem?.Type !== "Episode" || !nextEpisode) return false;
|
if (currentItem?.Type !== "Episode" || !nextEpisode) return false;
|
||||||
const remaining = duration - progress;
|
const remaining = duration - progress;
|
||||||
@@ -659,14 +663,12 @@ export default function CastingPlayerScreen() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: protocolColor,
|
color: protocolColor,
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentDevice || "Unknown Device"}
|
{currentDevice || "Unknown Device"}
|
||||||
</Text>
|
</Text>
|
||||||
{/* Connection quality indicator with color */}
|
|
||||||
<Ionicons name='cellular' size={14} color={qualityColor} />
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -681,7 +683,7 @@ export default function CastingPlayerScreen() {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: insets.top + 40,
|
top: insets.top + 50,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 95,
|
zIndex: 95,
|
||||||
@@ -695,7 +697,7 @@ export default function CastingPlayerScreen() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "white",
|
color: "white",
|
||||||
fontSize: 22,
|
fontSize: 25,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
@@ -711,7 +713,7 @@ export default function CastingPlayerScreen() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#999",
|
color: "#999",
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -727,12 +729,12 @@ export default function CastingPlayerScreen() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingTop: insets.top + 140,
|
paddingTop: insets.top + 160,
|
||||||
paddingBottom: insets.bottom + 500,
|
paddingBottom: insets.bottom + 500,
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Poster with buffering overlay - reduced size */}
|
{/* Poster with buffering overlay */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -741,8 +743,8 @@ export default function CastingPlayerScreen() {
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 260,
|
width: 280,
|
||||||
height: 390,
|
height: 420,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -859,125 +861,133 @@ export default function CastingPlayerScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
{/* Spacer to push buttons down */}
|
{/* Fixed 4-button control row for episodes - positioned independently */}
|
||||||
<View style={{ height: 40 }} />
|
{currentItem.Type === "Episode" && (
|
||||||
|
<View
|
||||||
{/* 4-button control row for episodes */}
|
style={{
|
||||||
{currentItem.Type === "Episode" && (
|
position: "absolute",
|
||||||
<View
|
bottom: insets.bottom + 200,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Episodes button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowEpisodeList(true)}
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 16,
|
|
||||||
marginBottom: 40,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Episodes button */}
|
<Ionicons name='list' size={22} color='white' />
|
||||||
<Pressable
|
</Pressable>
|
||||||
onPress={() => setShowEpisodeList(true)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='list' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Previous episode button */}
|
{/* Previous episode button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const currentIndex = episodes.findIndex(
|
const currentIndex = episodes.findIndex(
|
||||||
(ep) => ep.Id === currentItem.Id,
|
(ep) => ep.Id === currentItem.Id,
|
||||||
);
|
);
|
||||||
if (currentIndex > 0 && remoteMediaClient) {
|
if (currentIndex > 0 && remoteMediaClient) {
|
||||||
const previousEp = episodes[currentIndex - 1];
|
const previousEp = episodes[currentIndex - 1];
|
||||||
console.log("Previous episode:", previousEp.Name);
|
console.log("Previous episode:", previousEp.Name);
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
|
|
||||||
}
|
}
|
||||||
style={{
|
}}
|
||||||
flex: 1,
|
disabled={
|
||||||
backgroundColor: "#1a1a1a",
|
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
|
||||||
padding: 12,
|
}
|
||||||
borderRadius: 12,
|
style={{
|
||||||
flexDirection: "row",
|
flex: 1,
|
||||||
justifyContent: "center",
|
backgroundColor: "#1a1a1a",
|
||||||
alignItems: "center",
|
padding: 12,
|
||||||
opacity:
|
borderRadius: 12,
|
||||||
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
|
flexDirection: "row",
|
||||||
? 0.4
|
justifyContent: "center",
|
||||||
: 1,
|
alignItems: "center",
|
||||||
}}
|
opacity:
|
||||||
>
|
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
|
||||||
<Ionicons name='play-skip-back' size={22} color='white' />
|
? 0.4
|
||||||
</Pressable>
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-skip-back' size={22} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
{/* Next episode button */}
|
{/* Next episode button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
if (nextEpisode && remoteMediaClient) {
|
if (nextEpisode && remoteMediaClient) {
|
||||||
console.log("Next episode:", nextEpisode.Name);
|
console.log("Next episode:", nextEpisode.Name);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!nextEpisode}
|
disabled={!nextEpisode}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: "#1a1a1a",
|
||||||
padding: 12,
|
padding: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
opacity: nextEpisode ? 1 : 0.4,
|
opacity: nextEpisode ? 1 : 0.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='play-skip-forward' size={22} color='white' />
|
<Ionicons name='play-skip-forward' size={22} color='white' />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* Stop casting button */}
|
{/* Stop casting button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
try {
|
try {
|
||||||
await stop();
|
// End the casting session and stop the receiver
|
||||||
if (router.canGoBack()) {
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
router.back();
|
await sessionManager.endCurrentSession(true);
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
// Navigate back
|
||||||
}
|
if (router.canGoBack()) {
|
||||||
} catch (error) {
|
router.back();
|
||||||
console.error("[Casting Player] Error stopping:", error);
|
} else {
|
||||||
if (router.canGoBack()) {
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
} catch (error) {
|
||||||
style={{
|
console.error(
|
||||||
flex: 1,
|
"[Casting Player] Error disconnecting:",
|
||||||
backgroundColor: "#1a1a1a",
|
error,
|
||||||
padding: 12,
|
);
|
||||||
borderRadius: 12,
|
// Try to navigate anyway
|
||||||
flexDirection: "row",
|
if (router.canGoBack()) {
|
||||||
justifyContent: "center",
|
router.back();
|
||||||
alignItems: "center",
|
} else {
|
||||||
}}
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
>
|
}
|
||||||
<Ionicons name='stop-circle' size={22} color='white' />
|
}
|
||||||
</Pressable>
|
}}
|
||||||
</View>
|
style={{
|
||||||
)}
|
flex: 1,
|
||||||
</ScrollView>
|
backgroundColor: "#1a1a1a",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='stop-circle' size={22} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fixed bottom controls area */}
|
{/* Fixed bottom controls area */}
|
||||||
<View
|
<View
|
||||||
@@ -1150,8 +1160,6 @@ export default function CastingPlayerScreen() {
|
|||||||
} as any)
|
} as any)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
connectionQuality={connectionQuality}
|
|
||||||
bitrate={availableMediaSources[0]?.bitrate}
|
|
||||||
onDisconnect={async () => {
|
onDisconnect={async () => {
|
||||||
try {
|
try {
|
||||||
await stop();
|
await stop();
|
||||||
@@ -1172,7 +1180,6 @@ export default function CastingPlayerScreen() {
|
|||||||
onVolumeChange={async (vol) => {
|
onVolumeChange={async (vol) => {
|
||||||
setVolume(vol);
|
setVolume(vol);
|
||||||
}}
|
}}
|
||||||
showTechnicalInfo={showTechnicalInfo}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChromecastEpisodeList
|
<ChromecastEpisodeList
|
||||||
@@ -1241,10 +1248,6 @@ export default function CastingPlayerScreen() {
|
|||||||
setCurrentPlaybackSpeed(speed);
|
setCurrentPlaybackSpeed(speed);
|
||||||
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
||||||
}}
|
}}
|
||||||
showTechnicalInfo={showTechnicalInfo}
|
|
||||||
onToggleTechnicalInfo={() => {
|
|
||||||
setShowTechnicalInfo(!showTechnicalInfo);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|||||||
@@ -4,58 +4,111 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
|
import {
|
||||||
|
MediaPlayerState,
|
||||||
|
useCastDevice,
|
||||||
|
useMediaStatus,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
|
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useCasting } from "@/hooks/useCasting";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
|
||||||
formatTime,
|
|
||||||
getPosterUrl,
|
|
||||||
getProtocolIcon,
|
|
||||||
getProtocolName,
|
|
||||||
} from "@/utils/casting/helpers";
|
|
||||||
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
||||||
|
|
||||||
export const CastingMiniPlayer: React.FC = () => {
|
export const CastingMiniPlayer: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const {
|
const castDevice = useCastDevice();
|
||||||
isConnected,
|
const mediaStatus = useMediaStatus();
|
||||||
protocol,
|
const remoteMediaClient = useRemoteMediaClient();
|
||||||
currentItem,
|
|
||||||
currentDevice,
|
|
||||||
progress,
|
|
||||||
duration,
|
|
||||||
isPlaying,
|
|
||||||
togglePlayPause,
|
|
||||||
} = useCasting(null);
|
|
||||||
|
|
||||||
if (!isConnected || !currentItem || !protocol) {
|
const currentItem = useMemo(() => {
|
||||||
|
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
|
||||||
|
}, [mediaStatus?.mediaInfo?.customData]);
|
||||||
|
|
||||||
|
// Live progress state that updates every second when playing
|
||||||
|
const [liveProgress, setLiveProgress] = useState(
|
||||||
|
mediaStatus?.streamPosition || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync live progress with mediaStatus and poll every second when playing
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaStatus?.streamPosition) {
|
||||||
|
setLiveProgress(mediaStatus.streamPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update every second when playing
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (
|
||||||
|
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
||||||
|
mediaStatus?.streamPosition !== undefined
|
||||||
|
) {
|
||||||
|
setLiveProgress((prev) => prev + 1);
|
||||||
|
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||||
|
// Sync with actual position when paused/buffering
|
||||||
|
setLiveProgress(mediaStatus.streamPosition);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
||||||
|
|
||||||
|
const progress = liveProgress * 1000; // Convert to ms
|
||||||
|
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
|
||||||
|
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||||
|
|
||||||
|
// For episodes, use season poster; for other content, use item poster
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (!api?.basePath || !currentItem) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentItem.Type === "Episode" &&
|
||||||
|
currentItem.SeriesId &&
|
||||||
|
currentItem.ParentIndexNumber
|
||||||
|
) {
|
||||||
|
// Build season poster URL using SeriesId and season number
|
||||||
|
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-episodes, use item's own poster
|
||||||
|
return getPosterUrl(
|
||||||
|
api.basePath,
|
||||||
|
currentItem.Id,
|
||||||
|
currentItem.ImageTags?.Primary,
|
||||||
|
80,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
}, [api?.basePath, currentItem]);
|
||||||
|
|
||||||
|
if (!castDevice || !currentItem || !mediaStatus) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const posterUrl = getPosterUrl(
|
|
||||||
api?.basePath,
|
|
||||||
currentItem.Id,
|
|
||||||
currentItem.ImageTags?.Primary,
|
|
||||||
80,
|
|
||||||
120,
|
|
||||||
);
|
|
||||||
|
|
||||||
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
||||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||||
const TAB_BAR_HEIGHT = 49; // Standard tab bar height
|
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
router.push("/casting-player");
|
router.push("/casting-player");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTogglePlayPause = (e: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isPlaying) {
|
||||||
|
remoteMediaClient?.pause();
|
||||||
|
} else {
|
||||||
|
remoteMediaClient?.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||||
@@ -141,11 +194,7 @@ export const CastingMiniPlayer: React.FC = () => {
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name='tv' size={12} color={protocolColor} />
|
||||||
name={getProtocolIcon(protocol)}
|
|
||||||
size={12}
|
|
||||||
color={protocolColor}
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: protocolColor,
|
color: protocolColor,
|
||||||
@@ -153,7 +202,7 @@ export const CastingMiniPlayer: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{currentDevice?.name || getProtocolName(protocol)}
|
{castDevice.friendlyName || "Chromecast"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -167,17 +216,7 @@ export const CastingMiniPlayer: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Play/Pause button */}
|
{/* Play/Pause button */}
|
||||||
<Pressable
|
<Pressable onPress={handleTogglePlayPause} style={{ padding: 8 }}>
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isConnected && protocol) {
|
|
||||||
togglePlayPause();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isPlaying ? "pause" : "play"}
|
name={isPlaying ? "pause" : "play"}
|
||||||
size={28}
|
size={28}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { Modal, Pressable, View } from "react-native";
|
import { Modal, Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import type { Device } from "react-native-google-cast";
|
import type { Device } from "react-native-google-cast";
|
||||||
|
import { useCastSession, useRemoteMediaClient } from "react-native-google-cast";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -19,9 +20,6 @@ interface ChromecastDeviceSheetProps {
|
|||||||
onDisconnect: () => Promise<void>;
|
onDisconnect: () => Promise<void>;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
onVolumeChange?: (volume: number) => Promise<void>;
|
onVolumeChange?: (volume: number) => Promise<void>;
|
||||||
showTechnicalInfo?: boolean;
|
|
||||||
connectionQuality?: "excellent" | "good" | "fair" | "poor";
|
|
||||||
bitrate?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||||
@@ -31,19 +29,34 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|||||||
onDisconnect,
|
onDisconnect,
|
||||||
volume = 0.5,
|
volume = 0.5,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
showTechnicalInfo = false,
|
|
||||||
connectionQuality = "good",
|
|
||||||
bitrate,
|
|
||||||
}) => {
|
}) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||||
const volumeValue = useSharedValue(volume * 100);
|
const volumeValue = useSharedValue(volume * 100);
|
||||||
|
const minimumValue = useSharedValue(0);
|
||||||
|
const maximumValue = useSharedValue(100);
|
||||||
|
const castSession = useCastSession();
|
||||||
|
const remoteMediaClient = useRemoteMediaClient();
|
||||||
|
|
||||||
// Sync volume slider with prop changes
|
// Sync volume slider with prop changes (updates from physical buttons)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
volumeValue.value = volume * 100;
|
volumeValue.value = volume * 100;
|
||||||
}, [volume, volumeValue]);
|
}, [volume, volumeValue]);
|
||||||
|
|
||||||
|
// Poll for volume updates when sheet is visible to catch physical button changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !remoteMediaClient) return;
|
||||||
|
|
||||||
|
// Request status update to get latest volume from device
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
remoteMediaClient.requestStatus().catch(() => {
|
||||||
|
// Ignore errors - device might be disconnected
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [visible, remoteMediaClient]);
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
const handleDisconnect = async () => {
|
||||||
setIsDisconnecting(true);
|
setIsDisconnecting(true);
|
||||||
try {
|
try {
|
||||||
@@ -57,8 +70,19 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleVolumeComplete = async (value: number) => {
|
const handleVolumeComplete = async (value: number) => {
|
||||||
if (onVolumeChange) {
|
const newVolume = value / 100;
|
||||||
await onVolumeChange(value / 100);
|
try {
|
||||||
|
// Use CastSession.setVolume for DEVICE volume control
|
||||||
|
// This works even when no media is playing, unlike setStreamVolume
|
||||||
|
if (castSession) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Volume] Error setting volume:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,61 +144,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|||||||
{device?.friendlyName || device?.deviceId || "Unknown Device"}
|
{device?.friendlyName || device?.deviceId || "Unknown Device"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{device?.deviceId && (
|
||||||
{/* Connection Quality */}
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 8 }}>
|
|
||||||
Connection Quality
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor:
|
|
||||||
connectionQuality === "excellent"
|
|
||||||
? "#10b981"
|
|
||||||
: connectionQuality === "good"
|
|
||||||
? "#fbbf24"
|
|
||||||
: connectionQuality === "fair"
|
|
||||||
? "#f97316"
|
|
||||||
: "#ef4444",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
connectionQuality === "excellent"
|
|
||||||
? "#10b981"
|
|
||||||
: connectionQuality === "good"
|
|
||||||
? "#fbbf24"
|
|
||||||
: connectionQuality === "fair"
|
|
||||||
? "#f97316"
|
|
||||||
: "#ef4444",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
textTransform: "capitalize",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{connectionQuality}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{bitrate && (
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginTop: 4 }}>
|
|
||||||
Bitrate: {(bitrate / 1000000).toFixed(1)} Mbps
|
|
||||||
{connectionQuality === "poor" &&
|
|
||||||
" (Low bitrate may cause buffering)"}
|
|
||||||
{connectionQuality === "fair" && " (Moderate quality)"}
|
|
||||||
{connectionQuality === "good" && " (Good quality)"}
|
|
||||||
{connectionQuality === "excellent" && " (Maximum quality)"}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{device?.deviceId && showTechnicalInfo && (
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
<View style={{ marginBottom: 20 }}>
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||||
Device ID
|
Device ID
|
||||||
@@ -183,11 +153,10 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|||||||
style={{ color: "white", fontSize: 14 }}
|
style={{ color: "white", fontSize: 14 }}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{device.deviceId}
|
{device?.deviceId}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Volume control */}
|
{/* Volume control */}
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<View
|
<View
|
||||||
@@ -211,8 +180,8 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|||||||
<Slider
|
<Slider
|
||||||
style={{ width: "100%", height: 40 }}
|
style={{ width: "100%", height: 40 }}
|
||||||
progress={volumeValue}
|
progress={volumeValue}
|
||||||
minimumValue={useSharedValue(0)}
|
minimumValue={minimumValue}
|
||||||
maximumValue={useSharedValue(100)}
|
maximumValue={maximumValue}
|
||||||
theme={{
|
theme={{
|
||||||
disableMinTrackTintColor: "#333",
|
disableMinTrackTintColor: "#333",
|
||||||
maximumTrackTintColor: "#333",
|
maximumTrackTintColor: "#333",
|
||||||
@@ -231,13 +200,11 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|||||||
}}
|
}}
|
||||||
onSlidingComplete={handleVolumeComplete}
|
onSlidingComplete={handleVolumeComplete}
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||||
disable={false}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name='volume-high' size={20} color='#999' />
|
<Ionicons name='volume-high' size={20} color='#999' />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Disconnect button */}
|
{/* Disconnect button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleDisconnect}
|
onPress={handleDisconnect}
|
||||||
@@ -253,7 +220,12 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|||||||
opacity: isDisconnecting ? 0.5 : 1,
|
opacity: isDisconnecting ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='power' size={20} color='white' />
|
<Ionicons
|
||||||
|
name='power'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
|
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
|
||||||
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
|
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { FlatList, Modal, Pressable, View } from "react-native";
|
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { truncateTitle } from "@/utils/casting/helpers";
|
import { truncateTitle } from "@/utils/casting/helpers";
|
||||||
@@ -33,10 +33,47 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Get unique seasons from episodes
|
||||||
|
const seasons = useMemo(() => {
|
||||||
|
const seasonSet = new Set<number>();
|
||||||
|
for (const ep of episodes) {
|
||||||
|
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
|
||||||
|
seasonSet.add(ep.ParentIndexNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seasonSet).sort((a, b) => a - b);
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
|
// Filter episodes by selected season and exclude virtual episodes
|
||||||
|
const filteredEpisodes = useMemo(() => {
|
||||||
|
let eps = episodes;
|
||||||
|
|
||||||
|
// Filter by season if selected
|
||||||
|
if (selectedSeason !== null) {
|
||||||
|
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out virtual episodes (episodes without actual video files)
|
||||||
|
// LocationType === "Virtual" means the episode doesn't have a media file
|
||||||
|
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
|
||||||
|
|
||||||
|
return eps;
|
||||||
|
}, [episodes, selectedSeason]);
|
||||||
|
|
||||||
|
// Set initial season to current episode's season
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentItem?.ParentIndexNumber !== undefined) {
|
||||||
|
setSelectedSeason(currentItem.ParentIndexNumber);
|
||||||
|
}
|
||||||
|
}, [currentItem]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && currentItem && episodes.length > 0) {
|
if (visible && currentItem && filteredEpisodes.length > 0) {
|
||||||
const currentIndex = episodes.findIndex((ep) => ep.Id === currentItem.Id);
|
const currentIndex = filteredEpisodes.findIndex(
|
||||||
|
(ep) => ep.Id === currentItem.Id,
|
||||||
|
);
|
||||||
if (currentIndex !== -1 && flatListRef.current) {
|
if (currentIndex !== -1 && flatListRef.current) {
|
||||||
// Delay to ensure FlatList is rendered
|
// Delay to ensure FlatList is rendered
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -48,7 +85,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
|||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [visible, currentItem, episodes]);
|
}, [visible, currentItem, filteredEpisodes]);
|
||||||
|
|
||||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
||||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
const isCurrentEpisode = item.Id === currentItem?.Id;
|
||||||
@@ -125,6 +162,15 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
||||||
|
{item.ParentIndexNumber !== undefined &&
|
||||||
|
item.IndexNumber !== undefined && (
|
||||||
|
<Text
|
||||||
|
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
|
||||||
|
>
|
||||||
|
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
|
||||||
|
{String(item.IndexNumber).padStart(2, "0")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{item.ProductionYear && (
|
{item.ProductionYear && (
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
@@ -176,27 +222,66 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: "#333",
|
borderBottomColor: "#333",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
<View
|
||||||
Episodes
|
style={{
|
||||||
</Text>
|
flexDirection: "row",
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
justifyContent: "space-between",
|
||||||
<Ionicons name='close' size={24} color='white' />
|
alignItems: "center",
|
||||||
</Pressable>
|
marginBottom: seasons.length > 1 ? 12 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||||
|
Episodes
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Season selector */}
|
||||||
|
{seasons.length > 1 && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ gap: 8 }}
|
||||||
|
>
|
||||||
|
{seasons.map((season) => (
|
||||||
|
<Pressable
|
||||||
|
key={season}
|
||||||
|
onPress={() => setSelectedSeason(season)}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor:
|
||||||
|
selectedSeason === season ? "#a855f7" : "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: selectedSeason === season ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Season {season}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Episode list */}
|
{/* Episode list */}
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={episodes}
|
data={filteredEpisodes}
|
||||||
renderItem={renderEpisode}
|
renderItem={renderEpisode}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ interface ChromecastSettingsMenuProps {
|
|||||||
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
|
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
onPlaybackSpeedChange: (speed: number) => void;
|
onPlaybackSpeedChange: (speed: number) => void;
|
||||||
showTechnicalInfo: boolean;
|
|
||||||
onToggleTechnicalInfo: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||||
@@ -51,8 +49,6 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
|||||||
onSubtitleTrackChange,
|
onSubtitleTrackChange,
|
||||||
playbackSpeed,
|
playbackSpeed,
|
||||||
onPlaybackSpeedChange,
|
onPlaybackSpeedChange,
|
||||||
showTechnicalInfo,
|
|
||||||
onToggleTechnicalInfo,
|
|
||||||
}) => {
|
}) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||||
@@ -316,50 +312,6 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Technical Info Toggle */}
|
|
||||||
<Pressable
|
|
||||||
onPress={onToggleTechnicalInfo}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='information-circle' size={20} color='white' />
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
|
||||||
>
|
|
||||||
Show Technical Info
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 30,
|
|
||||||
borderRadius: 15,
|
|
||||||
backgroundColor: showTechnicalInfo ? "#a855f7" : "#333",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: showTechnicalInfo ? "flex-end" : "flex-start",
|
|
||||||
padding: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 13,
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
|
useCastSession,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
@@ -30,6 +31,7 @@ export const useCasting = (item: BaseItemDto | null) => {
|
|||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
|
const castSession = useCastSession();
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
||||||
@@ -37,6 +39,7 @@ export const useCasting = (item: BaseItemDto | null) => {
|
|||||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastReportedProgressRef = useRef(0);
|
const lastReportedProgressRef = useRef(0);
|
||||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
|
||||||
|
|
||||||
// Detect which protocol is active
|
// Detect which protocol is active
|
||||||
const chromecastConnected = castDevice !== null;
|
const chromecastConnected = castDevice !== null;
|
||||||
@@ -85,44 +88,118 @@ export const useCasting = (item: BaseItemDto | null) => {
|
|||||||
}
|
}
|
||||||
}, [mediaStatus, activeProtocol]);
|
}, [mediaStatus, activeProtocol]);
|
||||||
|
|
||||||
// Chromecast: Sync volume from device
|
// Chromecast: Sync volume from device (both mediaStatus and CastSession)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeProtocol === "chromecast" && mediaStatus?.volume !== undefined) {
|
if (activeProtocol !== "chromecast") return;
|
||||||
|
|
||||||
|
// Sync from mediaStatus when available
|
||||||
|
if (mediaStatus?.volume !== undefined) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
volume: mediaStatus.volume,
|
volume: mediaStatus.volume,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [mediaStatus?.volume, activeProtocol]);
|
|
||||||
|
|
||||||
// Progress reporting to Jellyfin (optimized to skip redundant reports)
|
// Also poll CastSession for device volume to catch physical button changes
|
||||||
|
if (castSession) {
|
||||||
|
const volumeInterval = setInterval(() => {
|
||||||
|
castSession
|
||||||
|
.getVolume()
|
||||||
|
.then((deviceVolume) => {
|
||||||
|
if (deviceVolume !== undefined) {
|
||||||
|
setState((prev) => {
|
||||||
|
// Only update if significantly different to avoid jitter
|
||||||
|
if (Math.abs(prev.volume - deviceVolume) > 0.01) {
|
||||||
|
return { ...prev, volume: deviceVolume };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore errors - device might be disconnected
|
||||||
|
});
|
||||||
|
}, 500); // Check every 500ms
|
||||||
|
|
||||||
|
return () => clearInterval(volumeInterval);
|
||||||
|
}
|
||||||
|
}, [mediaStatus?.volume, castSession, activeProtocol]);
|
||||||
|
|
||||||
|
// Progress reporting to Jellyfin (matches native player behavior)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return;
|
if (!isConnected || !item?.Id || !user?.Id || !api) return;
|
||||||
|
|
||||||
|
const playStateApi = getPlaystateApi(api);
|
||||||
|
|
||||||
|
// Report playback start when media begins (only once per item)
|
||||||
|
if (hasReportedStartRef.current !== item.Id && state.progress > 0) {
|
||||||
|
playStateApi
|
||||||
|
.reportPlaybackStart({
|
||||||
|
playbackStartInfo: {
|
||||||
|
ItemId: item.Id,
|
||||||
|
PositionTicks: Math.floor(state.progress * 10000),
|
||||||
|
PlayMethod:
|
||||||
|
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
||||||
|
VolumeLevel: Math.floor(state.volume * 100),
|
||||||
|
IsMuted: state.volume === 0,
|
||||||
|
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
hasReportedStartRef.current = item.Id || null;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[useCasting] Failed to report playback start:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const reportProgress = () => {
|
const reportProgress = () => {
|
||||||
const progressSeconds = Math.floor(state.progress / 1000);
|
// Don't report if no meaningful progress or if buffering
|
||||||
|
if (state.progress <= 0 || state.isBuffering) return;
|
||||||
|
|
||||||
// Skip if progress hasn't changed significantly (less than 5 seconds)
|
const progressMs = Math.floor(state.progress);
|
||||||
if (Math.abs(progressSeconds - lastReportedProgressRef.current) < 5) {
|
const progressTicks = progressMs * 10000; // Convert ms to ticks
|
||||||
|
const progressSeconds = Math.floor(progressMs / 1000);
|
||||||
|
|
||||||
|
// When paused, always report to keep server in sync
|
||||||
|
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
|
||||||
|
if (
|
||||||
|
state.isPlaying &&
|
||||||
|
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastReportedProgressRef.current = progressSeconds;
|
lastReportedProgressRef.current = progressSeconds;
|
||||||
const playStateApi = api ? getPlaystateApi(api) : null;
|
|
||||||
playStateApi
|
playStateApi
|
||||||
?.reportPlaybackProgress({
|
.reportPlaybackProgress({
|
||||||
playbackProgressInfo: {
|
playbackProgressInfo: {
|
||||||
ItemId: item.Id,
|
ItemId: item.Id,
|
||||||
PositionTicks: progressSeconds * 10000000,
|
PositionTicks: progressTicks,
|
||||||
IsPaused: !state.isPlaying,
|
IsPaused: !state.isPlaying,
|
||||||
PlayMethod:
|
PlayMethod:
|
||||||
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
||||||
|
// Add volume level for server tracking
|
||||||
|
VolumeLevel: Math.floor(state.volume * 100),
|
||||||
|
IsMuted: state.volume === 0,
|
||||||
|
// Include play session ID if available
|
||||||
|
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch((error) => {
|
||||||
|
console.error("[useCasting] Failed to report progress:", error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const interval = setInterval(reportProgress, 10000);
|
// Report immediately on play/pause state change
|
||||||
|
reportProgress();
|
||||||
|
|
||||||
|
// Report every 5 seconds when paused, every 10 seconds when playing
|
||||||
|
const interval = setInterval(
|
||||||
|
reportProgress,
|
||||||
|
state.isPlaying ? 10000 : 5000,
|
||||||
|
);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
@@ -130,17 +207,32 @@ export const useCasting = (item: BaseItemDto | null) => {
|
|||||||
user?.Id,
|
user?.Id,
|
||||||
state.progress,
|
state.progress,
|
||||||
state.isPlaying,
|
state.isPlaying,
|
||||||
|
state.isBuffering, // Add buffering state to dependencies
|
||||||
|
state.volume,
|
||||||
isConnected,
|
isConnected,
|
||||||
activeProtocol,
|
activeProtocol,
|
||||||
|
mediaStatus?.mediaInfo?.contentId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Play/Pause controls
|
// Play/Pause controls
|
||||||
const play = useCallback(async () => {
|
const play = useCallback(async () => {
|
||||||
if (activeProtocol === "chromecast") {
|
if (activeProtocol === "chromecast") {
|
||||||
await client?.play();
|
// Check if there's an active media session
|
||||||
|
if (!client || !mediaStatus?.mediaInfo) {
|
||||||
|
console.warn(
|
||||||
|
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[useCasting] Error playing:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Future: Add play control for other protocols
|
// Future: Add play control for other protocols
|
||||||
}, [client, activeProtocol]);
|
}, [client, mediaStatus, activeProtocol]);
|
||||||
|
|
||||||
const pause = useCallback(async () => {
|
const pause = useCallback(async () => {
|
||||||
if (activeProtocol === "chromecast") {
|
if (activeProtocol === "chromecast") {
|
||||||
@@ -160,12 +252,31 @@ export const useCasting = (item: BaseItemDto | null) => {
|
|||||||
// Seek controls
|
// Seek controls
|
||||||
const seek = useCallback(
|
const seek = useCallback(
|
||||||
async (positionMs: number) => {
|
async (positionMs: number) => {
|
||||||
|
// Validate position
|
||||||
|
if (positionMs < 0 || !Number.isFinite(positionMs)) {
|
||||||
|
console.error("[useCasting] Invalid seek position (ms):", positionMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionSeconds = positionMs / 1000;
|
||||||
|
|
||||||
|
// Additional validation for Chromecast
|
||||||
if (activeProtocol === "chromecast") {
|
if (activeProtocol === "chromecast") {
|
||||||
await client?.seek({ position: positionMs / 1000 });
|
if (positionSeconds > state.duration) {
|
||||||
|
console.warn(
|
||||||
|
"[useCasting] Seek position exceeds duration, clamping:",
|
||||||
|
positionSeconds,
|
||||||
|
"->",
|
||||||
|
state.duration,
|
||||||
|
);
|
||||||
|
await client?.seek({ position: state.duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client?.seek({ position: positionSeconds });
|
||||||
}
|
}
|
||||||
// Future: Add seek control for other protocols
|
// Future: Add seek control for other protocols
|
||||||
},
|
},
|
||||||
[client, activeProtocol],
|
[client, activeProtocol, state.duration],
|
||||||
);
|
);
|
||||||
|
|
||||||
const skipForward = useCallback(
|
const skipForward = useCallback(
|
||||||
@@ -185,25 +296,33 @@ export const useCasting = (item: BaseItemDto | null) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Stop and disconnect
|
// Stop and disconnect
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(
|
||||||
if (activeProtocol === "chromecast") {
|
async (onStopComplete?: () => void) => {
|
||||||
await client?.stop();
|
if (activeProtocol === "chromecast") {
|
||||||
}
|
await client?.stop();
|
||||||
// Future: Add stop control for other protocols
|
}
|
||||||
|
// Future: Add stop control for other protocols
|
||||||
|
|
||||||
// Report stop to Jellyfin
|
// Report stop to Jellyfin
|
||||||
if (api && item?.Id && user?.Id) {
|
if (api && item?.Id && user?.Id) {
|
||||||
const playStateApi = getPlaystateApi(api);
|
const playStateApi = getPlaystateApi(api);
|
||||||
await playStateApi.reportPlaybackStopped({
|
await playStateApi.reportPlaybackStopped({
|
||||||
playbackStopInfo: {
|
playbackStopInfo: {
|
||||||
ItemId: item.Id,
|
ItemId: item.Id,
|
||||||
PositionTicks: state.progress * 10000,
|
PositionTicks: state.progress * 10000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(DEFAULT_CAST_STATE);
|
setState(DEFAULT_CAST_STATE);
|
||||||
}, [client, api, item?.Id, user?.Id, state.progress, activeProtocol]);
|
|
||||||
|
// Call callback after stop completes (e.g., to navigate away)
|
||||||
|
if (onStopComplete) {
|
||||||
|
onStopComplete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, api, item?.Id, user?.Id, state.progress, activeProtocol],
|
||||||
|
);
|
||||||
|
|
||||||
// Volume control (debounced to reduce API calls)
|
// Volume control (debounced to reduce API calls)
|
||||||
const setVolume = useCallback(
|
const setVolume = useCallback(
|
||||||
@@ -220,6 +339,8 @@ export const useCasting = (item: BaseItemDto | null) => {
|
|||||||
|
|
||||||
volumeDebounceRef.current = setTimeout(async () => {
|
volumeDebounceRef.current = setTimeout(async () => {
|
||||||
if (activeProtocol === "chromecast" && client && isConnected) {
|
if (activeProtocol === "chromecast" && client && isConnected) {
|
||||||
|
// Use setStreamVolume for media stream volume (0.0 - 1.0)
|
||||||
|
// Physical volume buttons are handled automatically by the framework
|
||||||
await client.setStreamVolume(clampedVolume).catch((error) => {
|
await client.setStreamVolume(clampedVolume).catch((error) => {
|
||||||
console.log(
|
console.log(
|
||||||
"[useCasting] Volume set failed (no session):",
|
"[useCasting] Volume set failed (no session):",
|
||||||
|
|||||||
@@ -51,13 +51,28 @@
|
|||||||
},
|
},
|
||||||
"casting_player": {
|
"casting_player": {
|
||||||
"buffering": "Buffering...",
|
"buffering": "Buffering...",
|
||||||
|
"changing_audio": "Changing audio...",
|
||||||
|
"changing_subtitles": "Changing subtitles...",
|
||||||
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
||||||
"connection_quality": {
|
"connection_quality": {
|
||||||
"excellent": "Excellent",
|
"excellent": "Excellent",
|
||||||
"good": "Good",
|
"good": "Good",
|
||||||
"fair": "Fair",
|
"fair": "Fair",
|
||||||
"poor": "Poor"
|
"poor": "Poor",
|
||||||
}
|
"disconnected": "Disconnected"
|
||||||
|
},
|
||||||
|
"error_title": "Chromecast Error",
|
||||||
|
"error_description": "Something went wrong with the cast session",
|
||||||
|
"retry": "Try Again",
|
||||||
|
"critical_error_title": "Multiple Errors Detected",
|
||||||
|
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
|
||||||
|
"track_changed": "Track changed successfully",
|
||||||
|
"audio_track_changed": "Audio track changed",
|
||||||
|
"subtitle_track_changed": "Subtitle track changed",
|
||||||
|
"seeking": "Seeking...",
|
||||||
|
"seeking_error": "Failed to seek",
|
||||||
|
"load_failed": "Failed to load media",
|
||||||
|
"load_retry": "Retrying media load..."
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export const chromecast: DeviceProfile = {
|
|||||||
{
|
{
|
||||||
Type: "Audio",
|
Type: "Audio",
|
||||||
Codec: "aac,mp3,flac,opus,vorbis",
|
Codec: "aac,mp3,flac,opus,vorbis",
|
||||||
|
// Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: "LessThanEqual",
|
||||||
|
Property: "AudioChannels",
|
||||||
|
Value: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
ContainerProfiles: [],
|
ContainerProfiles: [],
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ export const chromecasth265: DeviceProfile = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: "Audio",
|
Type: "Audio",
|
||||||
Codec: "aac,mp3,flac,opus,vorbis",
|
Codec: "aac,mp3,flac,opus,vorbis", // Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: "LessThanEqual",
|
||||||
|
Property: "AudioChannels",
|
||||||
|
Value: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
ContainerProfiles: [],
|
ContainerProfiles: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user