mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-10 06:12:23 +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:
@@ -96,6 +96,7 @@ export default function SegmentSkipPage() {
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipIntroOptions}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
@@ -119,6 +120,7 @@ export default function SegmentSkipPage() {
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipOutroOptions}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
@@ -142,6 +144,7 @@ export default function SegmentSkipPage() {
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipRecapOptions}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
@@ -165,6 +168,7 @@ export default function SegmentSkipPage() {
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipCommercialOptions}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
@@ -190,6 +194,7 @@ export default function SegmentSkipPage() {
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipPreviewOptions}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
|
||||
@@ -23,7 +23,6 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastState,
|
||||
MediaPlayerState,
|
||||
MediaStreamType,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
@@ -49,12 +48,10 @@ import {
|
||||
calculateEndingTime,
|
||||
formatTime,
|
||||
getPosterUrl,
|
||||
shouldShowNextEpisodeCountdown,
|
||||
truncateTitle,
|
||||
} from "@/utils/casting/helpers";
|
||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
||||
import type { CastProtocol } from "@/utils/casting/types";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
@@ -92,35 +89,25 @@ export default function CastingPlayerScreen() {
|
||||
|
||||
// Live progress tracking - update every second
|
||||
const [liveProgress, setLiveProgress] = useState(0);
|
||||
const lastSyncPositionRef = useRef(0);
|
||||
const lastSyncTimestampRef = useRef(Date.now());
|
||||
|
||||
// Fetch full item data from Jellyfin by ID
|
||||
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
|
||||
const [_isFetchingItem, setIsFetchingItem] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItemData = async () => {
|
||||
const itemId = mediaStatus?.mediaInfo?.contentId;
|
||||
if (!itemId || !api || !user?.Id) return;
|
||||
|
||||
setIsFetchingItem(true);
|
||||
try {
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId,
|
||||
userId: user.Id,
|
||||
});
|
||||
console.log("[Casting Player] Fetched full item from API:", {
|
||||
Type: res.data.Type,
|
||||
Name: res.data.Name,
|
||||
SeriesName: res.data.SeriesName,
|
||||
SeasonId: res.data.SeasonId,
|
||||
ParentIndexNumber: res.data.ParentIndexNumber,
|
||||
IndexNumber: res.data.IndexNumber,
|
||||
});
|
||||
setFetchedItem(res.data);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to fetch item:", error);
|
||||
} finally {
|
||||
setIsFetchingItem(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,18 +115,21 @@ export default function CastingPlayerScreen() {
|
||||
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize with actual position
|
||||
if (mediaStatus?.streamPosition) {
|
||||
// Sync refs whenever mediaStatus provides a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
lastSyncPositionRef.current = mediaStatus.streamPosition;
|
||||
lastSyncTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update every second when playing
|
||||
// Update every second when playing, deriving from last sync point
|
||||
const interval = setInterval(() => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
||||
mediaStatus?.streamPosition !== undefined
|
||||
) {
|
||||
setLiveProgress((prev) => prev + 1);
|
||||
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
|
||||
setLiveProgress(lastSyncPositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
@@ -153,7 +143,6 @@ export default function CastingPlayerScreen() {
|
||||
const currentItem = useMemo(() => {
|
||||
// Priority 1: Use fetched item from API (most reliable)
|
||||
if (fetchedItem) {
|
||||
console.log("[Casting Player] Using fetched item from API");
|
||||
return fetchedItem;
|
||||
}
|
||||
|
||||
@@ -161,19 +150,12 @@ export default function CastingPlayerScreen() {
|
||||
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
|
||||
if (customData?.Type && customData.Type !== "Movie") {
|
||||
// Only use customData if it has a real Type (not default fallback)
|
||||
console.log("[Casting Player] Using customData item:", {
|
||||
Type: customData.Type,
|
||||
Name: customData.Name,
|
||||
});
|
||||
return customData;
|
||||
}
|
||||
|
||||
// Priority 3: Create minimal fallback while loading
|
||||
if (mediaStatus?.mediaInfo) {
|
||||
const { contentId, metadata } = mediaStatus.mediaInfo;
|
||||
console.log(
|
||||
"[Casting Player] Using minimal fallback item (still loading)",
|
||||
);
|
||||
return {
|
||||
Id: contentId,
|
||||
Name: metadata?.title || "Unknown",
|
||||
@@ -227,7 +209,7 @@ export default function CastingPlayerScreen() {
|
||||
togglePlayPause: async () => {},
|
||||
skipForward: async () => {},
|
||||
skipBackward: async () => {},
|
||||
setVolume: async () => {},
|
||||
setVolume: () => {},
|
||||
volume: 1,
|
||||
remoteMediaClient: null,
|
||||
};
|
||||
@@ -264,10 +246,6 @@ export default function CastingPlayerScreen() {
|
||||
try {
|
||||
// Save current playback position
|
||||
const currentPosition = mediaStatus?.streamPosition ?? 0;
|
||||
console.log(
|
||||
"[Casting Player] Reloading stream at position:",
|
||||
currentPosition,
|
||||
);
|
||||
|
||||
// Get new stream URL with updated settings
|
||||
const enableH265 = settings.enableH265ForChromecast;
|
||||
@@ -288,74 +266,15 @@ export default function CastingPlayerScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Casting Player] Reloading with new URL:", data.url);
|
||||
|
||||
// Reload media with new URL
|
||||
await remoteMediaClient.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: currentItem.Id,
|
||||
contentUrl: data.url,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: currentItem.RunTimeTicks
|
||||
? currentItem.RunTimeTicks / 10000000
|
||||
: undefined,
|
||||
customData: currentItem,
|
||||
metadata:
|
||||
currentItem.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: currentItem.Name || "",
|
||||
episodeNumber: currentItem.IndexNumber || 0,
|
||||
seasonNumber: currentItem.ParentIndexNumber || 0,
|
||||
seriesTitle: currentItem.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item: currentItem,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: currentItem.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: currentItem.Name || "",
|
||||
subtitle: currentItem.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item: currentItem,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: currentItem.Name || "",
|
||||
subtitle: currentItem.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item: currentItem,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
mediaInfo: buildCastMediaInfo({
|
||||
item: currentItem,
|
||||
streamUrl: data.url,
|
||||
api,
|
||||
}),
|
||||
startTime: currentPosition, // Resume at same position
|
||||
});
|
||||
|
||||
console.log("[Casting Player] Stream reloaded successfully");
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to reload stream:", error);
|
||||
}
|
||||
@@ -371,6 +290,47 @@ export default function CastingPlayerScreen() {
|
||||
],
|
||||
);
|
||||
|
||||
// Load a different episode on the Chromecast
|
||||
const loadEpisode = useCallback(
|
||||
async (episode: BaseItemDto) => {
|
||||
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
|
||||
|
||||
try {
|
||||
const enableH265 = settings.enableH265ForChromecast;
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item: episode,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: episode.UserData?.PlaybackPositionTicks ?? 0,
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to get stream URL for episode",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await remoteMediaClient.loadMedia({
|
||||
mediaInfo: buildCastMediaInfo({
|
||||
item: episode,
|
||||
streamUrl: data.url,
|
||||
api,
|
||||
}),
|
||||
startTime: (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000000,
|
||||
});
|
||||
|
||||
// Reset track selections for new episode
|
||||
setSelectedAudioTrackIndex(null);
|
||||
setSelectedSubtitleTrackIndex(null);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to load episode:", error);
|
||||
}
|
||||
},
|
||||
[api, user?.Id, remoteMediaClient, settings.enableH265ForChromecast],
|
||||
);
|
||||
|
||||
// Fetch season data for season poster
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -383,20 +343,11 @@ export default function CastingPlayerScreen() {
|
||||
|
||||
const fetchSeasonData = async () => {
|
||||
try {
|
||||
console.log(
|
||||
`[Casting Player] Fetching season data for SeasonId: ${currentItem.SeasonId}`,
|
||||
);
|
||||
const userLibraryApi = getUserLibraryApi(api);
|
||||
const response = await userLibraryApi.getItem({
|
||||
itemId: currentItem.SeasonId!,
|
||||
userId: user.Id!,
|
||||
});
|
||||
console.log("[Casting Player] Season data fetched:", {
|
||||
Id: response.data.Id,
|
||||
Name: response.data.Name,
|
||||
ImageTags: response.data.ImageTags,
|
||||
ParentPrimaryImageItemId: response.data.ParentPrimaryImageItemId,
|
||||
});
|
||||
setSeasonData(response.data);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to fetch season data:", error);
|
||||
@@ -485,12 +436,16 @@ export default function CastingPlayerScreen() {
|
||||
return variants;
|
||||
}, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]);
|
||||
|
||||
// Track whether user has manually selected an audio track
|
||||
const [userSelectedAudio, setUserSelectedAudio] = useState(false);
|
||||
|
||||
// Auto-select stereo audio track for better Chromecast compatibility
|
||||
// Note: This only updates the UI state. The actual audio track change requires
|
||||
// regenerating the stream URL, which would be disruptive on initial load.
|
||||
// The user can manually switch audio tracks if needed.
|
||||
useEffect(() => {
|
||||
if (!remoteMediaClient || !mediaStatus?.mediaInfo) return;
|
||||
if (!remoteMediaClient || !mediaStatus?.mediaInfo || userSelectedAudio)
|
||||
return;
|
||||
|
||||
const currentTrack = availableAudioTracks.find(
|
||||
(t) => t.index === selectedAudioTrackIndex,
|
||||
@@ -500,12 +455,6 @@ export default function CastingPlayerScreen() {
|
||||
if (currentTrack && (currentTrack.channels || 0) > 2) {
|
||||
const stereoTrack = availableAudioTracks.find((t) => t.channels === 2);
|
||||
if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) {
|
||||
console.log(
|
||||
"[Audio] Note: 5.1 audio detected. Stereo available:",
|
||||
currentTrack.displayTitle,
|
||||
"->",
|
||||
stereoTrack.displayTitle,
|
||||
);
|
||||
// Auto-select stereo in UI (user can manually trigger reload)
|
||||
setSelectedAudioTrackIndex(stereoTrack.index);
|
||||
}
|
||||
@@ -515,6 +464,7 @@ export default function CastingPlayerScreen() {
|
||||
availableAudioTracks,
|
||||
remoteMediaClient,
|
||||
selectedAudioTrackIndex,
|
||||
userSelectedAudio,
|
||||
]);
|
||||
|
||||
// Fetch episodes for TV shows
|
||||
@@ -562,8 +512,7 @@ export default function CastingPlayerScreen() {
|
||||
useEffect(() => {
|
||||
if (mediaStatus?.currentItemId && !currentItem) {
|
||||
// New media started casting while we're not on the player
|
||||
console.log("[Casting Player] Auto-navigating to player for new cast");
|
||||
router.replace("/casting-player" as any);
|
||||
router.replace("/casting-player" as "/casting-player");
|
||||
}
|
||||
}, [mediaStatus?.currentItemId, currentItem, router]);
|
||||
|
||||
@@ -627,23 +576,12 @@ export default function CastingPlayerScreen() {
|
||||
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
|
||||
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
|
||||
const seasonImageTag = seasonData.ImageTags?.Primary;
|
||||
console.log(
|
||||
`[Casting Player] Using season poster for ${seasonData.Name}`,
|
||||
{ imageItemId, seasonImageTag },
|
||||
);
|
||||
return seasonImageTag
|
||||
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
||||
: `${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
|
||||
console.log(
|
||||
`[Casting Player] Using fallback poster for ${currentItem.Name}`,
|
||||
{
|
||||
Type: currentItem.Type,
|
||||
hasSeasonData: !!seasonData?.Id,
|
||||
},
|
||||
);
|
||||
return getPosterUrl(
|
||||
api.basePath,
|
||||
currentItem.Id,
|
||||
@@ -662,12 +600,6 @@ export default function CastingPlayerScreen() {
|
||||
|
||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||
|
||||
const _showNextEpisode = useMemo(() => {
|
||||
if (currentItem?.Type !== "Episode" || !nextEpisode) return false;
|
||||
const remaining = duration - progress;
|
||||
return shouldShowNextEpisodeCountdown(remaining, true, 30);
|
||||
}, [currentItem?.Type, nextEpisode, duration, progress]);
|
||||
|
||||
// Redirect if not connected - check CastState like old implementation
|
||||
useEffect(() => {
|
||||
// Redirect immediately when disconnected or no devices
|
||||
@@ -686,7 +618,7 @@ export default function CastingPlayerScreen() {
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [castState]);
|
||||
}, [castState, router]);
|
||||
|
||||
// Also redirect if mediaStatus disappears (media ended or stopped)
|
||||
useEffect(() => {
|
||||
@@ -701,7 +633,7 @@ export default function CastingPlayerScreen() {
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [castState, mediaStatus]);
|
||||
}, [castState, mediaStatus, router]);
|
||||
|
||||
// Show loading while connecting
|
||||
if (castState === CastState.CONNECTING) {
|
||||
@@ -716,7 +648,7 @@ export default function CastingPlayerScreen() {
|
||||
>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={{ color: "#fff", marginTop: 16 }}>
|
||||
Connecting to Chromecast...
|
||||
{t("casting_player.connecting")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -795,7 +727,7 @@ export default function CastingPlayerScreen() {
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{currentDevice || "Unknown Device"}
|
||||
{currentDevice || t("casting_player.unknown_device")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -831,7 +763,10 @@ export default function CastingPlayerScreen() {
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{truncateTitle(currentItem.Name || "Unknown", 50)}
|
||||
{truncateTitle(
|
||||
currentItem.Name || t("casting_player.unknown"),
|
||||
50,
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Grey episode/season info */}
|
||||
@@ -1028,13 +963,12 @@ export default function CastingPlayerScreen() {
|
||||
const currentIndex = episodes.findIndex(
|
||||
(ep) => ep.Id === currentItem.Id,
|
||||
);
|
||||
if (currentIndex > 0 && remoteMediaClient) {
|
||||
const previousEp = episodes[currentIndex - 1];
|
||||
console.log("Previous episode:", previousEp.Name);
|
||||
if (currentIndex > 0) {
|
||||
await loadEpisode(episodes[currentIndex - 1]);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
|
||||
episodes.findIndex((ep) => ep.Id === currentItem.Id) <= 0
|
||||
}
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -1045,7 +979,7 @@ export default function CastingPlayerScreen() {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity:
|
||||
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
|
||||
episodes.findIndex((ep) => ep.Id === currentItem.Id) <= 0
|
||||
? 0.4
|
||||
: 1,
|
||||
}}
|
||||
@@ -1056,8 +990,8 @@ export default function CastingPlayerScreen() {
|
||||
{/* Next episode button */}
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
if (nextEpisode && remoteMediaClient) {
|
||||
console.log("Next episode:", nextEpisode.Name);
|
||||
if (nextEpisode) {
|
||||
await loadEpisode(nextEpisode);
|
||||
}
|
||||
}}
|
||||
disabled={!nextEpisode}
|
||||
@@ -1331,8 +1265,9 @@ export default function CastingPlayerScreen() {
|
||||
{formatTime(progress * 1000)}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
Ending at{" "}
|
||||
{calculateEndingTime(progress * 1000, duration * 1000)}
|
||||
{t("casting_player.ending_at", {
|
||||
time: calculateEndingTime(progress * 1000, duration * 1000),
|
||||
})}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(duration * 1000)}
|
||||
@@ -1432,10 +1367,7 @@ export default function CastingPlayerScreen() {
|
||||
onClose={() => setShowDeviceSheet(false)}
|
||||
device={
|
||||
currentDevice && protocol === "chromecast" && castDevice
|
||||
? ({
|
||||
deviceId: castDevice.deviceId,
|
||||
friendlyName: currentDevice,
|
||||
} as any)
|
||||
? { friendlyName: currentDevice }
|
||||
: null
|
||||
}
|
||||
onDisconnect={async () => {
|
||||
@@ -1461,7 +1393,11 @@ export default function CastingPlayerScreen() {
|
||||
}}
|
||||
volume={volume}
|
||||
onVolumeChange={async (vol) => {
|
||||
setVolume(vol);
|
||||
try {
|
||||
await setVolume(vol);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to set volume:", error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1471,10 +1407,9 @@ export default function CastingPlayerScreen() {
|
||||
currentItem={currentItem}
|
||||
episodes={episodes}
|
||||
api={api}
|
||||
onSelectEpisode={(episode) => {
|
||||
// TODO: Load new episode - requires casting new media
|
||||
console.log("Selected episode:", episode.Name);
|
||||
onSelectEpisode={async (episode) => {
|
||||
setShowEpisodeList(false);
|
||||
await loadEpisode(episode);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1489,8 +1424,6 @@ export default function CastingPlayerScreen() {
|
||||
})}
|
||||
selectedMediaSource={availableMediaSources[0] || null}
|
||||
onMediaSourceChange={(source) => {
|
||||
// Reload stream with new bitrate
|
||||
console.log("Changed media source:", source);
|
||||
reloadWithSettings({ bitrateValue: source.bitrate });
|
||||
}}
|
||||
audioTracks={availableAudioTracks}
|
||||
@@ -1502,6 +1435,7 @@ export default function CastingPlayerScreen() {
|
||||
: availableAudioTracks[0] || null
|
||||
}
|
||||
onAudioTrackChange={(track) => {
|
||||
setUserSelectedAudio(true);
|
||||
setSelectedAudioTrackIndex(track.index);
|
||||
// Reload stream with new audio track
|
||||
reloadWithSettings({ audioIndex: track.index });
|
||||
|
||||
@@ -939,7 +939,7 @@ export default function page() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
|
||||
@@ -41,6 +41,8 @@ export function Chromecast({
|
||||
const isConnected = castState === CastState.CONNECTED;
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const playSessionIdRef = useRef<string | null>(null);
|
||||
const lastContentIdRef = useRef<string | null>(null);
|
||||
const discoveryAttempts = useRef(0);
|
||||
const maxDiscoveryAttempts = 3;
|
||||
const hasLoggedDevices = useRef(false);
|
||||
@@ -121,6 +123,13 @@ export function Chromecast({
|
||||
}
|
||||
|
||||
const contentId = mediaStatus.mediaInfo.contentId;
|
||||
|
||||
// Generate a new PlaySessionId when the content changes
|
||||
if (contentId !== lastContentIdRef.current) {
|
||||
playSessionIdRef.current = `${contentId}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
lastContentIdRef.current = contentId;
|
||||
}
|
||||
|
||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||
const isPaused = mediaStatus.playerState === "paused";
|
||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||
@@ -131,7 +140,7 @@ export function Chromecast({
|
||||
PositionTicks: positionTicks,
|
||||
IsPaused: isPaused,
|
||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||
PlaySessionId: contentId,
|
||||
PlaySessionId: playSessionIdRef.current || contentId,
|
||||
};
|
||||
|
||||
getPlaystateApi(api)
|
||||
|
||||
@@ -47,6 +47,7 @@ interface PlatformDropdownProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
disabled?: boolean;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
@@ -197,6 +198,7 @@ const PlatformDropdownComponent = ({
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
disabled,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
@@ -231,6 +233,13 @@ const PlatformDropdownComponent = ({
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
if (disabled) {
|
||||
return (
|
||||
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
<ContextMenu>
|
||||
@@ -353,8 +362,14 @@ const PlatformDropdownComponent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={disabled ? { opacity: 0.5 } : undefined}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
MediaPlayerState,
|
||||
MediaStreamType,
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
@@ -33,8 +32,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
@@ -112,7 +110,11 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const options = [
|
||||
t("casting_player.chromecast"),
|
||||
t("casting_player.device"),
|
||||
t("casting_player.cancel"),
|
||||
];
|
||||
const cancelButtonIndex = 2;
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
@@ -181,17 +183,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||
});
|
||||
|
||||
console.log("URL: ", data?.url, enableH265);
|
||||
console.log("[PlayButton] Item before casting:", {
|
||||
Type: item.Type,
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
ParentIndexNumber: item.ParentIndexNumber,
|
||||
IndexNumber: item.IndexNumber,
|
||||
SeasonId: item.SeasonId,
|
||||
SeriesId: item.SeriesId,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
@@ -201,80 +192,16 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate start time in seconds from playback position
|
||||
const startTimeSeconds =
|
||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||
|
||||
// Calculate stream duration in seconds from runtime
|
||||
const streamDurationSeconds = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
console.log("[PlayButton] Loading media with customData:", {
|
||||
hasCustomData: !!item,
|
||||
customDataType: item.Type,
|
||||
});
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: item.Id,
|
||||
contentUrl: data?.url,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: streamDurationSeconds,
|
||||
customData: item,
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
mediaInfo: buildCastMediaInfo({
|
||||
item,
|
||||
streamUrl: data.url,
|
||||
api,
|
||||
}),
|
||||
startTime: startTimeSeconds,
|
||||
})
|
||||
.then(() => {
|
||||
@@ -285,7 +212,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
router.push("/casting-player");
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.error("[PlayButton] Cast error:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,9 +110,10 @@ export const CastingMiniPlayer: React.FC = () => {
|
||||
if (
|
||||
currentItem.Type === "Episode" &&
|
||||
currentItem.SeriesId &&
|
||||
currentItem.ParentIndexNumber
|
||||
currentItem.ParentIndexNumber !== undefined &&
|
||||
currentItem.SeasonId
|
||||
) {
|
||||
// Build season poster URL using SeriesId and season number
|
||||
// Build season poster URL using SeriesId and SeasonId as tag
|
||||
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`;
|
||||
}
|
||||
|
||||
@@ -146,12 +147,15 @@ export const CastingMiniPlayer: React.FC = () => {
|
||||
router.push("/casting-player");
|
||||
};
|
||||
|
||||
const handleTogglePlayPause = (e: any) => {
|
||||
e.stopPropagation();
|
||||
const handleTogglePlayPause = () => {
|
||||
if (isPlaying) {
|
||||
remoteMediaClient?.pause();
|
||||
remoteMediaClient?.pause()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Pause error:", error);
|
||||
});
|
||||
} else {
|
||||
remoteMediaClient?.play();
|
||||
remoteMediaClient?.play()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Play error:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -346,12 +346,15 @@ export const Controls: FC<Props> = ({
|
||||
seek(timeInSeconds * 1000);
|
||||
// Brief delay ensures the seek operation completes before resuming playback
|
||||
// Without this, playback may resume from the old position
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
play();
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
// Only resume if currently playing to avoid overriding user pause
|
||||
if (isPlaying) {
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
play();
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
}
|
||||
},
|
||||
[seek, play],
|
||||
[seek, play, isPlaying],
|
||||
);
|
||||
|
||||
// Use unified segment skipper for all segment types
|
||||
@@ -427,7 +430,7 @@ export const Controls: FC<Props> = ({
|
||||
);
|
||||
const skipIntro = activeSegment?.skipSegment || noop;
|
||||
const showSkipCreditButton = activeSegment?.type === "Outro";
|
||||
const skipCredit = outroSkipper.skipSegment;
|
||||
const skipCredit = outroSkipper.skipSegment || noop;
|
||||
const hasContentAfterCredits =
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
|
||||
@@ -9,8 +9,9 @@ import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CastState,
|
||||
useCastDevice,
|
||||
useCastSession,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
@@ -30,8 +31,8 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
// Chromecast hooks
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const castState = useCastState();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const castSession = useCastSession();
|
||||
|
||||
// Local state
|
||||
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
||||
@@ -40,9 +41,22 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
|
||||
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
|
||||
|
||||
// Detect which protocol is active
|
||||
const chromecastConnected = castDevice !== null;
|
||||
// Helper to update both state and ref
|
||||
const updateState = useCallback(
|
||||
(updater: (prev: CastPlayerState) => CastPlayerState) => {
|
||||
setState((prev) => {
|
||||
const next = updater(prev);
|
||||
stateRef.current = next;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Detect which protocol is active - use CastState for reliable detection
|
||||
const chromecastConnected = castState === CastState.CONNECTED;
|
||||
// Future: Add detection for other protocols here
|
||||
|
||||
const activeProtocol: CastProtocol | null = chromecastConnected
|
||||
@@ -54,7 +68,7 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
// Update current device
|
||||
useEffect(() => {
|
||||
if (chromecastConnected && castDevice) {
|
||||
setState((prev) => ({
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
protocol: "chromecast",
|
||||
@@ -65,7 +79,7 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
protocol: null,
|
||||
@@ -78,7 +92,7 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
// Chromecast: Update playback state
|
||||
useEffect(() => {
|
||||
if (activeProtocol === "chromecast" && mediaStatus) {
|
||||
setState((prev) => ({
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: mediaStatus.playerState === "playing",
|
||||
progress: (mediaStatus.streamPosition || 0) * 1000,
|
||||
@@ -86,62 +100,40 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
isBuffering: mediaStatus.playerState === "buffering",
|
||||
}));
|
||||
}
|
||||
}, [mediaStatus, activeProtocol]);
|
||||
}, [mediaStatus, activeProtocol, updateState]);
|
||||
|
||||
// Chromecast: Sync volume from device (both mediaStatus and CastSession)
|
||||
// Chromecast: Sync volume from mediaStatus
|
||||
useEffect(() => {
|
||||
if (activeProtocol !== "chromecast") return;
|
||||
|
||||
// Sync from mediaStatus when available
|
||||
if (mediaStatus?.volume !== undefined) {
|
||||
setState((prev) => ({
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
volume: mediaStatus.volume,
|
||||
}));
|
||||
}
|
||||
|
||||
// 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]);
|
||||
}, [mediaStatus?.volume, activeProtocol, updateState]);
|
||||
|
||||
// Progress reporting to Jellyfin (matches native player behavior)
|
||||
// Uses stateRef to read current progress/volume without adding them as deps
|
||||
useEffect(() => {
|
||||
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) {
|
||||
const currentState = stateRef.current;
|
||||
if (hasReportedStartRef.current !== item.Id && currentState.progress > 0) {
|
||||
playStateApi
|
||||
.reportPlaybackStart({
|
||||
playbackStartInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: Math.floor(state.progress * 10000),
|
||||
PositionTicks: Math.floor(currentState.progress * 10000),
|
||||
PlayMethod:
|
||||
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
||||
VolumeLevel: Math.floor(state.volume * 100),
|
||||
IsMuted: state.volume === 0,
|
||||
VolumeLevel: Math.floor(currentState.volume * 100),
|
||||
IsMuted: currentState.volume === 0,
|
||||
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
|
||||
},
|
||||
})
|
||||
@@ -154,17 +146,18 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
}
|
||||
|
||||
const reportProgress = () => {
|
||||
const s = stateRef.current;
|
||||
// Don't report if no meaningful progress or if buffering
|
||||
if (state.progress <= 0 || state.isBuffering) return;
|
||||
if (s.progress <= 0 || s.isBuffering) return;
|
||||
|
||||
const progressMs = Math.floor(state.progress);
|
||||
const progressMs = Math.floor(s.progress);
|
||||
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 &&
|
||||
s.isPlaying &&
|
||||
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
|
||||
) {
|
||||
return;
|
||||
@@ -177,13 +170,11 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
playbackProgressInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: progressTicks,
|
||||
IsPaused: !state.isPlaying,
|
||||
IsPaused: !s.isPlaying,
|
||||
PlayMethod:
|
||||
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
|
||||
VolumeLevel: Math.floor(s.volume * 100),
|
||||
IsMuted: s.volume === 0,
|
||||
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
|
||||
},
|
||||
})
|
||||
@@ -192,23 +183,13 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 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,
|
||||
);
|
||||
// Report progress on a fixed interval, reading latest state from ref
|
||||
const interval = setInterval(reportProgress, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
api,
|
||||
item?.Id,
|
||||
user?.Id,
|
||||
state.progress,
|
||||
state.isPlaying,
|
||||
state.isBuffering, // Add buffering state to dependencies
|
||||
state.volume,
|
||||
isConnected,
|
||||
activeProtocol,
|
||||
mediaStatus?.mediaInfo?.contentId,
|
||||
@@ -262,14 +243,16 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
|
||||
// Additional validation for Chromecast
|
||||
if (activeProtocol === "chromecast") {
|
||||
if (positionSeconds > state.duration) {
|
||||
// state.duration is in ms, positionSeconds is in seconds - compare in same unit
|
||||
const durationSeconds = state.duration / 1000;
|
||||
if (positionSeconds > durationSeconds) {
|
||||
console.warn(
|
||||
"[useCasting] Seek position exceeds duration, clamping:",
|
||||
positionSeconds,
|
||||
"->",
|
||||
state.duration,
|
||||
durationSeconds,
|
||||
);
|
||||
await client?.seek({ position: state.duration });
|
||||
await client?.seek({ position: durationSeconds });
|
||||
return;
|
||||
}
|
||||
await client?.seek({ position: positionSeconds });
|
||||
@@ -315,6 +298,7 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
}
|
||||
|
||||
setState(DEFAULT_CAST_STATE);
|
||||
stateRef.current = DEFAULT_CAST_STATE;
|
||||
|
||||
// Call callback after stop completes (e.g., to navigate away)
|
||||
if (onStopComplete) {
|
||||
@@ -330,7 +314,7 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||
|
||||
// Update UI immediately
|
||||
setState((prev) => ({ ...prev, volume: clampedVolume }));
|
||||
updateState((prev) => ({ ...prev, volume: clampedVolume }));
|
||||
|
||||
// Debounce API call
|
||||
if (volumeDebounceRef.current) {
|
||||
@@ -341,35 +325,32 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
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) => {
|
||||
console.log(
|
||||
"[useCasting] Volume set failed (no session):",
|
||||
error.message,
|
||||
);
|
||||
await client.setStreamVolume(clampedVolume).catch(() => {
|
||||
// Ignore errors - session might have ended
|
||||
});
|
||||
}
|
||||
// Future: Add volume control for other protocols
|
||||
}, 300);
|
||||
},
|
||||
[client, activeProtocol],
|
||||
[client, activeProtocol, isConnected],
|
||||
);
|
||||
|
||||
// Controls visibility
|
||||
const showControls = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, showControls: true }));
|
||||
updateState((prev) => ({ ...prev, showControls: true }));
|
||||
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (state.isPlaying) {
|
||||
setState((prev) => ({ ...prev, showControls: false }));
|
||||
updateState((prev) => ({ ...prev, showControls: false }));
|
||||
}
|
||||
}, 5000);
|
||||
}, [state.isPlaying]);
|
||||
}, [state.isPlaying, updateState]);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, showControls: false }));
|
||||
updateState((prev) => ({ ...prev, showControls: false }));
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping credits in a media player.
|
||||
* The player reports time values in milliseconds.
|
||||
*/
|
||||
export const useCreditSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
totalDuration?: number,
|
||||
) => {
|
||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
|
||||
const totalDurationInSeconds =
|
||||
totalDuration != null ? msToSeconds(totalDuration) : undefined;
|
||||
|
||||
// Regular function (not useCallback) to match useIntroSkipper pattern
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
itemId,
|
||||
isOffline,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
const creditTimestamps = segments?.creditSegments?.[0];
|
||||
|
||||
// Determine if there's content after credits (credits don't extend to video end)
|
||||
// Use a 5-second buffer to account for timing discrepancies
|
||||
const hasContentAfterCredits = (() => {
|
||||
if (
|
||||
!creditTimestamps ||
|
||||
totalDurationInSeconds == null ||
|
||||
!Number.isFinite(totalDurationInSeconds)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const creditsEndToVideoEnd =
|
||||
totalDurationInSeconds - creditTimestamps.endTime;
|
||||
// If credits end more than 5 seconds before video ends, there's content after
|
||||
return creditsEndToVideoEnd > 5;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (creditTimestamps) {
|
||||
const shouldShow =
|
||||
currentTimeSeconds > creditTimestamps.startTime &&
|
||||
currentTimeSeconds < creditTimestamps.endTime;
|
||||
|
||||
setShowSkipCreditButton(shouldShow);
|
||||
} else {
|
||||
// Reset button state when no credit timestamps exist
|
||||
if (showSkipCreditButton) {
|
||||
setShowSkipCreditButton(false);
|
||||
}
|
||||
}
|
||||
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
|
||||
|
||||
const skipCredit = useCallback(() => {
|
||||
if (!creditTimestamps) return;
|
||||
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
|
||||
// Calculate the target seek position
|
||||
let seekTarget = creditTimestamps.endTime;
|
||||
|
||||
// If we have total duration, ensure we don't seek past the end of the video.
|
||||
// Some media sources report credit end times that exceed the actual video duration,
|
||||
// which causes the player to pause/stop when seeking past the end.
|
||||
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
|
||||
// (next episode countdown, etc.) instead of an abrupt pause.
|
||||
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
|
||||
seekTarget = Math.max(0, totalDurationInSeconds - 2);
|
||||
}
|
||||
|
||||
wrappedSeek(seekTarget);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
|
||||
}
|
||||
}, [
|
||||
creditTimestamps,
|
||||
lightHapticFeedback,
|
||||
wrappedSeek,
|
||||
play,
|
||||
totalDurationInSeconds,
|
||||
]);
|
||||
|
||||
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping intros in a media player.
|
||||
* MPV player uses milliseconds for time.
|
||||
*
|
||||
* @param {number} currentTime - The current playback time in milliseconds.
|
||||
*/
|
||||
export const useIntroSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
) => {
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
itemId,
|
||||
isOffline,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
const introTimestamps = segments?.introSegments?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (introTimestamps) {
|
||||
const shouldShow =
|
||||
currentTimeSeconds > introTimestamps.startTime &&
|
||||
currentTimeSeconds < introTimestamps.endTime;
|
||||
|
||||
setShowSkipButton(shouldShow);
|
||||
} else {
|
||||
if (showSkipButton) {
|
||||
setShowSkipButton(false);
|
||||
}
|
||||
}
|
||||
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
if (!introTimestamps) return;
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
wrappedSeek(introTimestamps.endTime);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.error("[INTRO_SKIPPER] Error skipping intro", error);
|
||||
}
|
||||
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||
|
||||
return { showSkipButton, skipIntro };
|
||||
};
|
||||
@@ -33,7 +33,7 @@ export const useSegmentSkipper = ({
|
||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||
const { settings } = useSettings();
|
||||
const haptic = useHaptic();
|
||||
const autoSkipTriggeredRef = useRef(false);
|
||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
||||
|
||||
// Get skip mode based on segment type
|
||||
const skipMode = (() => {
|
||||
@@ -63,7 +63,7 @@ export const useSegmentSkipper = ({
|
||||
// Skip function with optional haptic feedback
|
||||
const skipSegment = useCallback(
|
||||
(notifyOrUseHaptics = true) => {
|
||||
if (!currentSegment) return;
|
||||
if (!currentSegment || skipMode === "none") return;
|
||||
|
||||
// For Outro segments, prevent seeking past the end
|
||||
if (segmentType === "Outro" && totalDuration) {
|
||||
@@ -78,22 +78,26 @@ export const useSegmentSkipper = ({
|
||||
haptic();
|
||||
}
|
||||
},
|
||||
[currentSegment, segmentType, totalDuration, seek, haptic],
|
||||
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
|
||||
);
|
||||
// Auto-skip logic when mode is 'auto'
|
||||
useEffect(() => {
|
||||
if (skipMode !== "auto" || isPaused) {
|
||||
autoSkipTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSegment && !autoSkipTriggeredRef.current) {
|
||||
autoSkipTriggeredRef.current = true;
|
||||
// Track segment identity to avoid re-triggering on pause/unpause
|
||||
const segmentId = currentSegment
|
||||
? `${currentSegment.startTime}-${currentSegment.endTime}`
|
||||
: null;
|
||||
|
||||
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
|
||||
autoSkipTriggeredRef.current = segmentId;
|
||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
||||
}
|
||||
|
||||
if (!currentSegment) {
|
||||
autoSkipTriggeredRef.current = false;
|
||||
autoSkipTriggeredRef.current = null;
|
||||
}
|
||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"skip_preview": "Skip Preview",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
@@ -54,6 +54,32 @@
|
||||
"changing_audio": "Changing audio...",
|
||||
"changing_subtitles": "Changing subtitles...",
|
||||
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
||||
"connecting": "Connecting to Chromecast...",
|
||||
"unknown_device": "Unknown Device",
|
||||
"ending_at": "Ending at {{time}}",
|
||||
"unknown": "Unknown",
|
||||
"connected": "Connected",
|
||||
"volume": "Volume",
|
||||
"muted": "Muted",
|
||||
"disconnect": "Disconnect",
|
||||
"stop_casting": "Stop Casting",
|
||||
"disconnecting": "Disconnecting...",
|
||||
"chromecast": "Chromecast",
|
||||
"device_name": "Device Name",
|
||||
"playback_settings": "Playback Settings",
|
||||
"quality": "Quality",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitles",
|
||||
"none": "None",
|
||||
"playback_speed": "Playback Speed",
|
||||
"normal": "Normal",
|
||||
"episodes": "Episodes",
|
||||
"season": "Season {{number}}",
|
||||
"minutes_short": "min",
|
||||
"episode_label": "Episode {{number}}",
|
||||
"forced": "Forced",
|
||||
"device": "Device",
|
||||
"cancel": "Cancel",
|
||||
"connection_quality": {
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
|
||||
@@ -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