mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-21 06:16:43 +01:00
Implements trickplay functionality with preview images to improve the casting player's seeking experience. Adds a progress slider with trickplay preview, allowing users to scrub through media with visual feedback. Integrates device volume control and mute functionality to the Chromecast device sheet. Also fixes minor bugs and improves UI.
436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
/**
|
|
* Unified Casting Mini Player
|
|
* Works with all supported casting protocols
|
|
*/
|
|
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
import { Image } from "expo-image";
|
|
import { router } from "expo-router";
|
|
import { useAtomValue } from "jotai";
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Dimensions, Pressable, View } from "react-native";
|
|
import { Slider } from "react-native-awesome-slider";
|
|
import {
|
|
MediaPlayerState,
|
|
useCastDevice,
|
|
useMediaStatus,
|
|
useRemoteMediaClient,
|
|
} from "react-native-google-cast";
|
|
import Animated, {
|
|
SlideInDown,
|
|
SlideOutDown,
|
|
useSharedValue,
|
|
} from "react-native-reanimated";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Text } from "@/components/common/Text";
|
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
|
|
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
|
|
export const CastingMiniPlayer: React.FC = () => {
|
|
const api = useAtomValue(apiAtom);
|
|
const insets = useSafeAreaInsets();
|
|
const castDevice = useCastDevice();
|
|
const mediaStatus = useMediaStatus();
|
|
const remoteMediaClient = useRemoteMediaClient();
|
|
|
|
const currentItem = useMemo(() => {
|
|
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
|
|
}, [mediaStatus?.mediaInfo?.customData]);
|
|
|
|
// Trickplay support - pass currentItem as BaseItemDto or empty object
|
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
|
currentItem || ({} as BaseItemDto),
|
|
);
|
|
const [trickplayTime, setTrickplayTime] = useState({
|
|
hours: 0,
|
|
minutes: 0,
|
|
seconds: 0,
|
|
});
|
|
const [scrubPercentage, setScrubPercentage] = useState(0);
|
|
const isScrubbing = useRef(false);
|
|
|
|
// Slider shared values
|
|
const sliderProgress = useSharedValue(0);
|
|
const sliderMin = useSharedValue(0);
|
|
const sliderMax = useSharedValue(100);
|
|
|
|
// 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;
|
|
|
|
// Update slider max value when duration changes
|
|
useEffect(() => {
|
|
if (duration > 0) {
|
|
sliderMax.value = duration;
|
|
}
|
|
}, [duration, sliderMax]);
|
|
|
|
// Sync slider progress with live progress (when not scrubbing)
|
|
useEffect(() => {
|
|
if (!isScrubbing.current && progress >= 0) {
|
|
sliderProgress.value = progress;
|
|
}
|
|
}, [progress, sliderProgress]);
|
|
|
|
// 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]);
|
|
|
|
// Hide mini player when:
|
|
// - No cast device connected
|
|
// - No media info (currentItem)
|
|
// - No media status
|
|
// - Media is stopped (IDLE state)
|
|
// - Media is unknown state
|
|
const playerState = mediaStatus?.playerState;
|
|
const isMediaStopped = playerState === MediaPlayerState.IDLE;
|
|
|
|
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
|
|
return null;
|
|
}
|
|
|
|
const protocolColor = "#a855f7"; // Streamyfin purple
|
|
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
|
|
|
|
const handlePress = () => {
|
|
router.push("/casting-player");
|
|
};
|
|
|
|
const handleTogglePlayPause = (e: any) => {
|
|
e.stopPropagation();
|
|
if (isPlaying) {
|
|
remoteMediaClient?.pause();
|
|
} else {
|
|
remoteMediaClient?.play();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Animated.View
|
|
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
|
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
|
style={{
|
|
position: "absolute",
|
|
bottom: TAB_BAR_HEIGHT + insets.bottom,
|
|
left: 0,
|
|
right: 0,
|
|
backgroundColor: "#1a1a1a",
|
|
borderTopWidth: 1,
|
|
borderTopColor: "#333",
|
|
zIndex: 100,
|
|
}}
|
|
>
|
|
{/* Interactive progress slider with trickplay */}
|
|
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
|
|
<Slider
|
|
style={{ width: "100%", height: 20 }}
|
|
progress={sliderProgress}
|
|
minimumValue={sliderMin}
|
|
maximumValue={sliderMax}
|
|
theme={{
|
|
maximumTrackTintColor: "#333",
|
|
minimumTrackTintColor: protocolColor,
|
|
bubbleBackgroundColor: protocolColor,
|
|
bubbleTextColor: "#fff",
|
|
}}
|
|
onSlidingStart={() => {
|
|
isScrubbing.current = true;
|
|
}}
|
|
onValueChange={(value) => {
|
|
// Calculate trickplay preview
|
|
const progressInTicks = msToTicks(value);
|
|
calculateTrickplayUrl(progressInTicks);
|
|
|
|
// Update time display for trickplay bubble
|
|
const progressInSeconds = Math.floor(
|
|
ticksToSeconds(progressInTicks),
|
|
);
|
|
const hours = Math.floor(progressInSeconds / 3600);
|
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
const seconds = progressInSeconds % 60;
|
|
setTrickplayTime({ hours, minutes, seconds });
|
|
|
|
// Track scrub percentage for bubble positioning
|
|
if (duration > 0) {
|
|
setScrubPercentage(value / duration);
|
|
}
|
|
}}
|
|
onSlidingComplete={(value) => {
|
|
isScrubbing.current = false;
|
|
// Seek to the position (value is in milliseconds, convert to seconds)
|
|
const positionSeconds = value / 1000;
|
|
if (remoteMediaClient && duration > 0) {
|
|
remoteMediaClient
|
|
.seek({ position: positionSeconds })
|
|
.catch((error) => {
|
|
console.error("[Mini Player] Seek error:", error);
|
|
});
|
|
}
|
|
}}
|
|
renderBubble={() => {
|
|
// Calculate bubble position with edge clamping
|
|
const screenWidth = Dimensions.get("window").width;
|
|
const sliderPadding = 8;
|
|
const thumbWidth = 10; // matches thumbWidth prop on Slider
|
|
const sliderWidth = screenWidth - sliderPadding * 2;
|
|
// Adjust thumb position to account for thumb width affecting travel range
|
|
const effectiveTrackWidth = sliderWidth - thumbWidth;
|
|
const thumbPosition =
|
|
thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
|
|
|
|
if (!trickPlayUrl || !trickplayInfo) {
|
|
// Show simple time bubble when no trickplay
|
|
const timeBubbleWidth = 70;
|
|
const minLeft = -thumbPosition;
|
|
const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth;
|
|
const centeredLeft = -timeBubbleWidth / 2;
|
|
const clampedLeft = Math.max(
|
|
minLeft,
|
|
Math.min(maxLeft, centeredLeft),
|
|
);
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 12,
|
|
left: clampedLeft,
|
|
backgroundColor: protocolColor,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{ color: "#fff", fontSize: 11, fontWeight: "600" }}
|
|
>
|
|
{`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${
|
|
trickplayTime.minutes < 10
|
|
? `0${trickplayTime.minutes}`
|
|
: trickplayTime.minutes
|
|
}:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const { x, y, url } = trickPlayUrl;
|
|
const tileWidth = 140; // Smaller preview for mini player
|
|
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
|
|
|
// Calculate clamped position for trickplay preview
|
|
const minLeft = -thumbPosition;
|
|
const maxLeft = sliderWidth - thumbPosition - tileWidth;
|
|
const centeredLeft = -tileWidth / 2;
|
|
const clampedLeft = Math.max(
|
|
minLeft,
|
|
Math.min(maxLeft, centeredLeft),
|
|
);
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 12,
|
|
left: clampedLeft,
|
|
width: tileWidth,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
{/* Trickplay image preview */}
|
|
<View
|
|
style={{
|
|
width: tileWidth,
|
|
height: tileHeight,
|
|
borderRadius: 6,
|
|
overflow: "hidden",
|
|
backgroundColor: "#1a1a1a",
|
|
}}
|
|
>
|
|
<Image
|
|
cachePolicy='memory-disk'
|
|
style={{
|
|
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
|
height:
|
|
(tileWidth / (trickplayInfo.aspectRatio ?? 1.78)) *
|
|
(trickplayInfo.data?.TileHeight ?? 1),
|
|
transform: [
|
|
{ translateX: -x * tileWidth },
|
|
{ translateY: -y * tileHeight },
|
|
],
|
|
}}
|
|
source={{ uri: url }}
|
|
contentFit='cover'
|
|
/>
|
|
</View>
|
|
{/* Time overlay */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 2,
|
|
left: 2,
|
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
paddingHorizontal: 4,
|
|
paddingVertical: 1,
|
|
borderRadius: 3,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{ color: "#fff", fontSize: 10, fontWeight: "600" }}
|
|
>
|
|
{`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${
|
|
trickplayTime.minutes < 10
|
|
? `0${trickplayTime.minutes}`
|
|
: trickplayTime.minutes
|
|
}:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}}
|
|
sliderHeight={3}
|
|
thumbWidth={10}
|
|
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
|
|
/>
|
|
</View>
|
|
|
|
<Pressable onPress={handlePress}>
|
|
{/* Content */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
padding: 12,
|
|
paddingTop: 6,
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{/* Poster */}
|
|
{posterUrl && (
|
|
<Image
|
|
source={{ uri: posterUrl }}
|
|
style={{
|
|
width: 40,
|
|
height: 60,
|
|
borderRadius: 4,
|
|
}}
|
|
contentFit='cover'
|
|
/>
|
|
)}
|
|
|
|
{/* Info */}
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
style={{
|
|
color: "white",
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{currentItem.Name}
|
|
</Text>
|
|
{currentItem.SeriesName && (
|
|
<Text
|
|
style={{
|
|
color: "#999",
|
|
fontSize: 12,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{currentItem.SeriesName}
|
|
</Text>
|
|
)}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
marginTop: 2,
|
|
}}
|
|
>
|
|
<Ionicons name='tv' size={12} color={protocolColor} />
|
|
<Text
|
|
style={{
|
|
color: protocolColor,
|
|
fontSize: 11,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{castDevice.friendlyName || "Chromecast"}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
color: "#666",
|
|
fontSize: 11,
|
|
}}
|
|
>
|
|
{formatTime(progress)} / {formatTime(duration)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Play/Pause button */}
|
|
<Pressable onPress={handleTogglePlayPause} style={{ padding: 8 }}>
|
|
<Ionicons
|
|
name={isPlaying ? "pause" : "play"}
|
|
size={28}
|
|
color='white'
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
</Pressable>
|
|
</Animated.View>
|
|
);
|
|
};
|