mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: music info badges in music modal
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -16,14 +19,29 @@ import {
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
type RepeatMode,
|
||||
useMusicPlayer,
|
||||
} from "@/providers/MusicPlayerProvider";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import { formatDuration } from "@/utils/time";
|
||||
|
||||
const formatFileSize = (bytes?: number | null) => {
|
||||
if (!bytes) return null;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
if (bytes === 0) return "0 B";
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatSampleRate = (sampleRate?: number | null) => {
|
||||
if (!sampleRate) return null;
|
||||
return `${(sampleRate / 1000).toFixed(1)} kHz`;
|
||||
};
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||
|
||||
@@ -45,6 +63,8 @@ export default function NowPlayingScreen() {
|
||||
duration,
|
||||
repeatMode,
|
||||
shuffleEnabled,
|
||||
mediaSource,
|
||||
isTranscoding,
|
||||
togglePlayPause,
|
||||
next,
|
||||
previous,
|
||||
@@ -221,6 +241,8 @@ export default function NowPlayingScreen() {
|
||||
getRepeatIcon={getRepeatIcon}
|
||||
queue={queue}
|
||||
queueIndex={queueIndex}
|
||||
mediaSource={mediaSource}
|
||||
isTranscoding={isTranscoding}
|
||||
/>
|
||||
) : (
|
||||
<QueueView
|
||||
@@ -259,6 +281,8 @@ interface PlayerViewProps {
|
||||
getRepeatIcon: () => string;
|
||||
queue: BaseItemDto[];
|
||||
queueIndex: number;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
}
|
||||
|
||||
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
@@ -284,7 +308,21 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
getRepeatIcon,
|
||||
queue,
|
||||
queueIndex,
|
||||
mediaSource,
|
||||
isTranscoding,
|
||||
}) => {
|
||||
const audioStream = useMemo(() => {
|
||||
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
||||
}, [mediaSource]);
|
||||
|
||||
const fileSize = formatFileSize(mediaSource?.Size);
|
||||
const codec = audioStream?.Codec?.toUpperCase();
|
||||
const bitrate = formatBitrate(audioStream?.BitRate);
|
||||
const sampleRate = formatSampleRate(audioStream?.SampleRate);
|
||||
const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
|
||||
|
||||
const hasAudioStats =
|
||||
mediaSource && (fileSize || codec || bitrate || sampleRate);
|
||||
return (
|
||||
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
|
||||
{/* Album artwork */}
|
||||
@@ -330,6 +368,29 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
{currentTrack.Album}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Audio Stats */}
|
||||
{hasAudioStats && (
|
||||
<View className='flex-row flex-wrap gap-1.5 mt-3'>
|
||||
{fileSize && <Badge variant='gray' text={fileSize} />}
|
||||
{codec && <Badge variant='gray' text={codec} />}
|
||||
<Badge
|
||||
variant='gray'
|
||||
text={playbackMethod}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name={isTranscoding ? "swap-horizontal" : "play"}
|
||||
size={12}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{bitrate && bitrate !== "N/A" && (
|
||||
<Badge variant='gray' text={bitrate} />
|
||||
)}
|
||||
{sampleRate && <Badge variant='gray' text={sampleRate} />}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Progress slider */}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi, getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
@@ -33,6 +36,11 @@ const STORAGE_KEYS = {
|
||||
|
||||
export type RepeatMode = "off" | "all" | "one";
|
||||
|
||||
interface TrackMediaInfo {
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
}
|
||||
|
||||
interface MusicPlayerState {
|
||||
currentTrack: BaseItemDto | null;
|
||||
queue: BaseItemDto[];
|
||||
@@ -47,6 +55,9 @@ interface MusicPlayerState {
|
||||
playSessionId: string | null;
|
||||
repeatMode: RepeatMode;
|
||||
shuffleEnabled: boolean;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
trackMediaInfoMap: Record<string, TrackMediaInfo>;
|
||||
}
|
||||
|
||||
interface MusicPlayerContextType extends MusicPlayerState {
|
||||
@@ -195,7 +206,12 @@ const getAudioStreamUrl = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
itemId: string,
|
||||
): Promise<{ url: string; sessionId: string | null } | null> => {
|
||||
): Promise<{
|
||||
url: string;
|
||||
sessionId: string | null;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
} | null> => {
|
||||
try {
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{ itemId },
|
||||
@@ -212,12 +228,14 @@ const getAudioStreamUrl = async (
|
||||
);
|
||||
|
||||
const sessionId = res.data.PlaySessionId || null;
|
||||
const mediaSource = res.data.MediaSources?.[0];
|
||||
const mediaSource = res.data.MediaSources?.[0] || null;
|
||||
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
return {
|
||||
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
|
||||
sessionId,
|
||||
mediaSource,
|
||||
isTranscoding: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,6 +252,8 @@ const getAudioStreamUrl = async (
|
||||
return {
|
||||
url: `${api.basePath}/Audio/${itemId}/stream?${streamParams.toString()}`,
|
||||
sessionId,
|
||||
mediaSource,
|
||||
isTranscoding: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -281,6 +301,9 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
playSessionId: null,
|
||||
repeatMode: loadRepeatMode(),
|
||||
shuffleEnabled: loadShuffleEnabled(),
|
||||
mediaSource: null,
|
||||
isTranscoding: false,
|
||||
trackMediaInfoMap: {},
|
||||
});
|
||||
|
||||
const lastReportRef = useRef<number>(0);
|
||||
@@ -471,11 +494,21 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
try {
|
||||
// Get stream URLs for all tracks
|
||||
const tracks: Track[] = [];
|
||||
for (const item of queue) {
|
||||
const mediaInfoMap: Record<string, TrackMediaInfo> = {};
|
||||
let startTrackMediaSource: MediaSourceInfo | null = null;
|
||||
let startTrackIsTranscoding = false;
|
||||
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const item = queue[i];
|
||||
if (!item.Id) continue;
|
||||
const result = await getAudioStreamUrl(api, user.Id, item.Id);
|
||||
if (result) {
|
||||
tracks.push(itemToTrack(item, result.url, api));
|
||||
// Store media info for all tracks
|
||||
mediaInfoMap[item.Id] = {
|
||||
mediaSource: result.mediaSource,
|
||||
isTranscoding: result.isTranscoding,
|
||||
};
|
||||
// Store first track's session ID
|
||||
if (tracks.length === 1) {
|
||||
setState((prev) => ({
|
||||
@@ -483,6 +516,11 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
playSessionId: result.sessionId,
|
||||
}));
|
||||
}
|
||||
// Store media source info for the starting track
|
||||
if (i === startIndex) {
|
||||
startTrackMediaSource = result.mediaSource;
|
||||
startTrackIsTranscoding = result.isTranscoding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +553,9 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
duration: currentTrack?.RunTimeTicks
|
||||
? Math.floor(currentTrack.RunTimeTicks / 10000000)
|
||||
: 0,
|
||||
mediaSource: startTrackMediaSource,
|
||||
isTranscoding: startTrackIsTranscoding,
|
||||
trackMediaInfoMap: mediaInfoMap,
|
||||
}));
|
||||
|
||||
reportPlaybackStart(currentTrack, state.playSessionId);
|
||||
@@ -686,11 +727,19 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
}
|
||||
await TrackPlayer.skipToNext();
|
||||
const newIndex = currentIndex + 1;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
queueIndex: newIndex,
|
||||
currentTrack: prev.queue[newIndex],
|
||||
}));
|
||||
setState((prev) => {
|
||||
const nextTrack = prev.queue[newIndex];
|
||||
const mediaInfo = nextTrack?.Id
|
||||
? prev.trackMediaInfoMap[nextTrack.Id]
|
||||
: null;
|
||||
return {
|
||||
...prev,
|
||||
queueIndex: newIndex,
|
||||
currentTrack: nextTrack,
|
||||
mediaSource: mediaInfo?.mediaSource ?? null,
|
||||
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
||||
};
|
||||
});
|
||||
} else if (state.repeatMode === "all" && state.queue.length > 0) {
|
||||
if (state.currentTrack && state.playSessionId) {
|
||||
reportPlaybackStopped(
|
||||
@@ -700,11 +749,19 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
);
|
||||
}
|
||||
await TrackPlayer.skip(0);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
queueIndex: 0,
|
||||
currentTrack: prev.queue[0],
|
||||
}));
|
||||
setState((prev) => {
|
||||
const firstTrack = prev.queue[0];
|
||||
const mediaInfo = firstTrack?.Id
|
||||
? prev.trackMediaInfoMap[firstTrack.Id]
|
||||
: null;
|
||||
return {
|
||||
...prev,
|
||||
queueIndex: 0,
|
||||
currentTrack: firstTrack,
|
||||
mediaSource: mediaInfo?.mediaSource ?? null,
|
||||
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [
|
||||
state.queue,
|
||||
@@ -738,11 +795,19 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
}
|
||||
await TrackPlayer.skipToPrevious();
|
||||
const newIndex = currentIndex - 1;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
queueIndex: newIndex,
|
||||
currentTrack: prev.queue[newIndex],
|
||||
}));
|
||||
setState((prev) => {
|
||||
const prevTrack = prev.queue[newIndex];
|
||||
const mediaInfo = prevTrack?.Id
|
||||
? prev.trackMediaInfoMap[prevTrack.Id]
|
||||
: null;
|
||||
return {
|
||||
...prev,
|
||||
queueIndex: newIndex,
|
||||
currentTrack: prevTrack,
|
||||
mediaSource: mediaInfo?.mediaSource ?? null,
|
||||
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
||||
};
|
||||
});
|
||||
} else if (state.repeatMode === "all" && state.queue.length > 0) {
|
||||
const lastIndex = state.queue.length - 1;
|
||||
if (state.currentTrack && state.playSessionId) {
|
||||
@@ -753,11 +818,19 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
);
|
||||
}
|
||||
await TrackPlayer.skip(lastIndex);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
queueIndex: lastIndex,
|
||||
currentTrack: prev.queue[lastIndex],
|
||||
}));
|
||||
setState((prev) => {
|
||||
const lastTrack = prev.queue[lastIndex];
|
||||
const mediaInfo = lastTrack?.Id
|
||||
? prev.trackMediaInfoMap[lastTrack.Id]
|
||||
: null;
|
||||
return {
|
||||
...prev,
|
||||
queueIndex: lastIndex,
|
||||
currentTrack: lastTrack,
|
||||
mediaSource: mediaInfo?.mediaSource ?? null,
|
||||
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [
|
||||
state.queue,
|
||||
@@ -807,6 +880,9 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
playSessionId: null,
|
||||
repeatMode: state.repeatMode,
|
||||
shuffleEnabled: state.shuffleEnabled,
|
||||
mediaSource: null,
|
||||
isTranscoding: false,
|
||||
trackMediaInfoMap: {},
|
||||
});
|
||||
}, [
|
||||
state.currentTrack,
|
||||
@@ -999,11 +1075,19 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
await TrackPlayer.skip(index);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
queueIndex: index,
|
||||
currentTrack: prev.queue[index],
|
||||
}));
|
||||
setState((prev) => {
|
||||
const targetTrack = prev.queue[index];
|
||||
const mediaInfo = targetTrack?.Id
|
||||
? prev.trackMediaInfoMap[targetTrack.Id]
|
||||
: null;
|
||||
return {
|
||||
...prev,
|
||||
queueIndex: index,
|
||||
currentTrack: targetTrack,
|
||||
mediaSource: mediaInfo?.mediaSource ?? null,
|
||||
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
||||
};
|
||||
});
|
||||
},
|
||||
[
|
||||
state.queue,
|
||||
|
||||
Reference in New Issue
Block a user