mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-07 04:52:23 +00:00
Compare commits
20 Commits
fix-networ
...
0.51.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d04c1003 | ||
|
|
7da52441ab | ||
|
|
70268e6120 | ||
|
|
96fbb9fe1f | ||
|
|
3b104b91fc | ||
|
|
e4134d6f9a | ||
|
|
5b2e7b3883 | ||
|
|
1fde3c82a3 | ||
|
|
054fb05651 | ||
|
|
a2058a8009 | ||
|
|
d22827bc9b | ||
|
|
4121502bfe | ||
|
|
b6e59aab01 | ||
|
|
ab3465aec5 | ||
|
|
b1da9f8777 | ||
|
|
36d24176ae | ||
|
|
bfdc2c053b | ||
|
|
245c9597c4 | ||
|
|
966a8e8f24 | ||
|
|
f941c88457 |
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.50.1",
|
||||
"version": "0.51.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 88,
|
||||
"versionCode": 91,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
|
||||
@@ -166,6 +166,24 @@ export default function IndexLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/music/page'
|
||||
options={{
|
||||
title: t("home.settings.music.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/appearance/hide-libraries/page'
|
||||
options={{
|
||||
|
||||
@@ -70,6 +70,11 @@ export default function settings() {
|
||||
showArrow
|
||||
title={t("home.settings.audio_subtitles.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/music/page")}
|
||||
showArrow
|
||||
title={t("home.settings.music.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/appearance/page")}
|
||||
showArrow
|
||||
|
||||
177
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
177
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const CACHE_SIZE_OPTIONS = [
|
||||
{ label: "100 MB", value: 100 },
|
||||
{ label: "250 MB", value: 250 },
|
||||
{ label: "500 MB", value: 500 },
|
||||
{ label: "1 GB", value: 1024 },
|
||||
{ label: "2 GB", value: 2048 },
|
||||
];
|
||||
|
||||
const LOOKAHEAD_COUNT_OPTIONS = [
|
||||
{ label: "1 song", value: 1 },
|
||||
{ label: "2 songs", value: 2 },
|
||||
{ label: "3 songs", value: 3 },
|
||||
{ label: "5 songs", value: 5 },
|
||||
];
|
||||
|
||||
export default function MusicSettingsPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cacheSizeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: CACHE_SIZE_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: String(option.value),
|
||||
selected: option.value === settings?.audioMaxCacheSizeMB,
|
||||
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.audioMaxCacheSizeMB, updateSettings],
|
||||
);
|
||||
|
||||
const currentCacheSizeLabel =
|
||||
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
|
||||
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
|
||||
|
||||
const lookaheadCountOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: String(option.value),
|
||||
selected: option.value === settings?.audioLookaheadCount,
|
||||
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.audioLookaheadCount, updateSettings],
|
||||
);
|
||||
|
||||
const currentLookaheadLabel =
|
||||
LOOKAHEAD_COUNT_OPTIONS.find(
|
||||
(o) => o.value === settings?.audioLookaheadCount,
|
||||
)?.label ?? `${settings?.audioLookaheadCount} songs`;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<ListGroup
|
||||
title={t("home.settings.music.playback_title")}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.music.playback_description")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.music.prefer_downloaded")}
|
||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.preferLocalAudio}
|
||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ preferLocalAudio: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<View className='mt-4'>
|
||||
<ListGroup
|
||||
title={t("home.settings.music.caching_title")}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.music.caching_description")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.music.lookahead_enabled")}
|
||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.audioLookaheadEnabled}
|
||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ audioLookaheadEnabled: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.music.lookahead_count")}
|
||||
disabled={
|
||||
pluginSettings?.audioLookaheadCount?.locked ||
|
||||
!settings.audioLookaheadEnabled
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={lookaheadCountOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{currentLookaheadLabel}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.music.lookahead_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.music.max_cache_size")}
|
||||
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={cacheSizeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{currentCacheSizeLabel}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.music.max_cache_size")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,9 @@ export default function page() {
|
||||
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
|
||||
settings?.streamyStatsPromotedWatchlists ?? false,
|
||||
);
|
||||
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
|
||||
settings?.hideWatchlistsTab ?? false,
|
||||
);
|
||||
|
||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||
const isStreamystatsEnabled = !!url;
|
||||
@@ -56,6 +59,7 @@ export default function page() {
|
||||
streamyStatsMovieRecommendations: movieRecs,
|
||||
streamyStatsSeriesRecommendations: seriesRecs,
|
||||
streamyStatsPromotedWatchlists: promotedWatchlists,
|
||||
hideWatchlistsTab: hideWatchlistsTab,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||
@@ -66,6 +70,7 @@ export default function page() {
|
||||
movieRecs,
|
||||
seriesRecs,
|
||||
promotedWatchlists,
|
||||
hideWatchlistsTab,
|
||||
updateSettings,
|
||||
queryClient,
|
||||
t,
|
||||
@@ -90,12 +95,14 @@ export default function page() {
|
||||
setMovieRecs(false);
|
||||
setSeriesRecs(false);
|
||||
setPromotedWatchlists(false);
|
||||
setHideWatchlistsTab(false);
|
||||
updateSettings({
|
||||
streamyStatsServerUrl: "",
|
||||
searchEngine: "Jellyfin",
|
||||
streamyStatsMovieRecommendations: false,
|
||||
streamyStatsSeriesRecommendations: false,
|
||||
streamyStatsPromotedWatchlists: false,
|
||||
hideWatchlistsTab: false,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
@@ -214,6 +221,16 @@ export default function page() {
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||
>
|
||||
<Switch
|
||||
value={hideWatchlistsTab}
|
||||
onValueChange={setHideWatchlistsTab}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import {
|
||||
downloadTrack,
|
||||
isPermanentlyDownloaded,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
@@ -29,6 +43,25 @@ export default function AlbumDetailScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||
setSelectedTrack(track);
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const { data: album, isLoading: loadingAlbum } = useQuery({
|
||||
queryKey: ["music-album", albumId, user?.Id],
|
||||
queryFn: async () => {
|
||||
@@ -91,9 +124,37 @@ export default function AlbumDetailScreen() {
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
// Check if all tracks are already permanently downloaded
|
||||
const allTracksDownloaded = useMemo(() => {
|
||||
if (!tracks || tracks.length === 0) return false;
|
||||
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
|
||||
}, [tracks]);
|
||||
|
||||
const handleDownloadAlbum = useCallback(async () => {
|
||||
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
for (const track of tracks) {
|
||||
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
|
||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||
if (result?.url && !result.isTranscoding) {
|
||||
await downloadTrack(track.Id, result.url, {
|
||||
permanent: true,
|
||||
container: result.mediaSource?.Container || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
setIsDownloading(false);
|
||||
}, [tracks, api, user?.Id, isDownloading]);
|
||||
|
||||
const isLoading = loadingAlbum || loadingTracks;
|
||||
|
||||
if (isLoading) {
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && !album) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
@@ -162,7 +223,7 @@ export default function AlbumDetailScreen() {
|
||||
</Text>
|
||||
|
||||
{/* Play buttons */}
|
||||
<View className='flex flex-row mt-4'>
|
||||
<View className='flex flex-row mt-4 items-center'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayAll}
|
||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||
@@ -174,27 +235,66 @@ export default function AlbumDetailScreen() {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleShuffle}
|
||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full'
|
||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||
>
|
||||
<Ionicons name='shuffle' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.shuffle")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDownloadAlbum}
|
||||
disabled={allTracksDownloaded || isDownloading}
|
||||
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size={20} color='white' />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={
|
||||
allTracksDownloaded
|
||||
? "checkmark-circle"
|
||||
: "download-outline"
|
||||
}
|
||||
size={20}
|
||||
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<View className='px-4'>
|
||||
<MusicTrackItem
|
||||
track={item}
|
||||
index={index + 1}
|
||||
queue={tracks}
|
||||
showArtwork={false}
|
||||
/>
|
||||
</View>
|
||||
<MusicTrackItem
|
||||
track={item}
|
||||
index={index + 1}
|
||||
queue={tracks}
|
||||
showArtwork={false}
|
||||
onOptionsPress={handleTrackOptionsPress}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
<TrackOptionsSheet
|
||||
open={trackOptionsOpen}
|
||||
setOpen={setTrackOptionsOpen}
|
||||
track={selectedTrack}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
/>
|
||||
<PlaylistPickerSheet
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={selectedTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={selectedTrack?.Id}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
@@ -30,6 +34,24 @@ export default function ArtistDetailScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||
|
||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||
setSelectedTrack(track);
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const { data: artist, isLoading: loadingArtist } = useQuery({
|
||||
queryKey: ["music-artist", artistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
@@ -98,7 +120,8 @@ export default function ArtistDetailScreen() {
|
||||
|
||||
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
||||
|
||||
if (isLoading) {
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && !artist) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
@@ -209,20 +232,42 @@ export default function ArtistDetailScreen() {
|
||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||
/>
|
||||
) : (
|
||||
<View className='px-4'>
|
||||
{section.data.slice(0, 5).map((track, index) => (
|
||||
section.data
|
||||
.slice(0, 5)
|
||||
.map((track, index) => (
|
||||
<MusicTrackItem
|
||||
key={track.Id}
|
||||
track={track}
|
||||
index={index + 1}
|
||||
queue={section.data}
|
||||
onOptionsPress={handleTrackOptionsPress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
<TrackOptionsSheet
|
||||
open={trackOptionsOpen}
|
||||
setOpen={setTrackOptionsOpen}
|
||||
track={selectedTrack}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
/>
|
||||
<PlaylistPickerSheet
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={selectedTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={selectedTrack?.Id}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
|
||||
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
@@ -29,6 +42,37 @@ export default function PlaylistDetailScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const removeFromPlaylist = useRemoveFromPlaylist();
|
||||
|
||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||
setSelectedTrack(track);
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRemoveFromPlaylist = useCallback(() => {
|
||||
if (selectedTrack?.Id && playlistId) {
|
||||
removeFromPlaylist.mutate({
|
||||
playlistId,
|
||||
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
|
||||
});
|
||||
}
|
||||
}, [selectedTrack, playlistId, removeFromPlaylist]);
|
||||
|
||||
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
|
||||
queryKey: ["music-playlist", playlistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
@@ -59,6 +103,14 @@ export default function PlaylistDetailScreen() {
|
||||
headerTransparent: true,
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setPlaylistOptionsOpen(true)}
|
||||
className='p-1.5'
|
||||
>
|
||||
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [playlist?.Name, navigation]);
|
||||
|
||||
@@ -89,9 +141,37 @@ export default function PlaylistDetailScreen() {
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
// Check if all tracks are already downloaded
|
||||
const allTracksDownloaded = useMemo(() => {
|
||||
if (!tracks || tracks.length === 0) return false;
|
||||
return tracks.every((track) => !!getLocalPath(track.Id));
|
||||
}, [tracks]);
|
||||
|
||||
const handleDownloadPlaylist = useCallback(async () => {
|
||||
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
for (const track of tracks) {
|
||||
if (!track.Id || getLocalPath(track.Id)) continue;
|
||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||
if (result?.url && !result.isTranscoding) {
|
||||
await downloadTrack(track.Id, result.url, {
|
||||
permanent: true,
|
||||
container: result.mediaSource?.Container || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
setIsDownloading(false);
|
||||
}, [tracks, api, user?.Id, isDownloading]);
|
||||
|
||||
const isLoading = loadingPlaylist || loadingTracks;
|
||||
|
||||
if (isLoading) {
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && !playlist) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
@@ -158,7 +238,7 @@ export default function PlaylistDetailScreen() {
|
||||
</Text>
|
||||
|
||||
{/* Play buttons */}
|
||||
<View className='flex flex-row mt-4'>
|
||||
<View className='flex flex-row mt-4 items-center'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayAll}
|
||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||
@@ -170,22 +250,72 @@ export default function PlaylistDetailScreen() {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleShuffle}
|
||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full'
|
||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||
>
|
||||
<Ionicons name='shuffle' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.shuffle")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDownloadPlaylist}
|
||||
disabled={allTracksDownloaded || isDownloading}
|
||||
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size={20} color='white' />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={
|
||||
allTracksDownloaded
|
||||
? "checkmark-circle"
|
||||
: "download-outline"
|
||||
}
|
||||
size={20}
|
||||
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<View className='px-4'>
|
||||
<MusicTrackItem track={item} index={index + 1} queue={tracks} />
|
||||
</View>
|
||||
<MusicTrackItem
|
||||
track={item}
|
||||
index={index + 1}
|
||||
queue={tracks}
|
||||
onOptionsPress={handleTrackOptionsPress}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
<TrackOptionsSheet
|
||||
open={trackOptionsOpen}
|
||||
setOpen={setTrackOptionsOpen}
|
||||
track={selectedTrack}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
playlistId={playlistId}
|
||||
onRemoveFromPlaylist={handleRemoveFromPlaylist}
|
||||
/>
|
||||
<PlaylistPickerSheet
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={selectedTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={selectedTrack?.Id}
|
||||
/>
|
||||
<PlaylistOptionsSheet
|
||||
open={playlistOptionsOpen}
|
||||
setOpen={setPlaylistOptionsOpen}
|
||||
playlist={playlist}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,8 @@ export default function ArtistsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && artists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
@@ -110,7 +111,9 @@ export default function ArtistsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
// Only show error if we have no cached data to display
|
||||
// This allows offline access to previously cached artists
|
||||
if (isError && artists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, RefreshControl, View } from "react-native";
|
||||
import {
|
||||
Dimensions,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
@@ -18,6 +25,7 @@ const ITEMS_PER_PAGE = 40;
|
||||
export default function PlaylistsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const navigation = useNavigation();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
@@ -27,8 +35,24 @@ export default function PlaylistsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
const isReady = Boolean(api && user?.Id && libraryId);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setCreateModalOpen(true)}
|
||||
className='mr-4'
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name='add' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -43,7 +67,6 @@ export default function PlaylistsScreen() {
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
@@ -101,7 +124,8 @@ export default function PlaylistsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && playlists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
@@ -109,7 +133,9 @@ export default function PlaylistsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
// Only show error if we have no cached data to display
|
||||
// This allows offline access to previously cached playlists
|
||||
if (isError && playlists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
@@ -123,7 +149,20 @@ export default function PlaylistsScreen() {
|
||||
if (playlists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.no_playlists")}</Text>
|
||||
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setCreateModalOpen(true)}
|
||||
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
|
||||
>
|
||||
<Ionicons name='add' size={20} color='white' />
|
||||
<Text className='text-white font-semibold ml-2'>
|
||||
{t("music.playlists.create_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<CreatePlaylistModal
|
||||
open={createModalOpen}
|
||||
setOpen={setCreateModalOpen}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -167,6 +206,10 @@ export default function PlaylistsScreen() {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createModalOpen}
|
||||
setOpen={setCreateModalOpen}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@ import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshControl, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { writeDebugLog } from "@/utils/log";
|
||||
|
||||
@@ -29,6 +32,24 @@ export default function SuggestionsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||
|
||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||
setSelectedTrack(track);
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const isReady = Boolean(api && user?.Id && libraryId);
|
||||
|
||||
writeDebugLog("Music suggestions params", {
|
||||
@@ -211,7 +232,8 @@ export default function SuggestionsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && sections.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
@@ -219,7 +241,12 @@ export default function SuggestionsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLatestError || isRecentlyPlayedError || isFrequentError) {
|
||||
// Only show error if we have no cached data to display
|
||||
// This allows offline access to previously cached suggestions
|
||||
if (
|
||||
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
|
||||
sections.length === 0
|
||||
) {
|
||||
const msg =
|
||||
(latestError as Error | undefined)?.message ||
|
||||
(recentlyPlayedError as Error | undefined)?.message ||
|
||||
@@ -268,21 +295,39 @@ export default function SuggestionsScreen() {
|
||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||
/>
|
||||
) : (
|
||||
<View className='px-4'>
|
||||
{section.data.slice(0, 5).map((track, index, _tracks) => (
|
||||
section.data
|
||||
.slice(0, 5)
|
||||
.map((track, index, _tracks) => (
|
||||
<MusicTrackItem
|
||||
key={track.Id}
|
||||
track={track}
|
||||
index={index + 1}
|
||||
queue={section.data}
|
||||
onOptionsPress={handleTrackOptionsPress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.title}
|
||||
/>
|
||||
<TrackOptionsSheet
|
||||
open={trackOptionsOpen}
|
||||
setOpen={setTrackOptionsOpen}
|
||||
track={selectedTrack}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
/>
|
||||
<PlaylistPickerSheet
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={selectedTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={selectedTrack?.Id}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ export default function TabLayout() {
|
||||
name='(watchlists)'
|
||||
options={{
|
||||
title: t("watchlists.title"),
|
||||
tabBarItemHidden: !settings?.streamyStatsServerUrl,
|
||||
tabBarItemHidden:
|
||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
|
||||
@@ -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";
|
||||
@@ -7,23 +10,41 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import DraggableFlatList, {
|
||||
type RenderItemParams,
|
||||
ScaleDecorator,
|
||||
} from "react-native-draggable-flatlist";
|
||||
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 +66,8 @@ export default function NowPlayingScreen() {
|
||||
duration,
|
||||
repeatMode,
|
||||
shuffleEnabled,
|
||||
mediaSource,
|
||||
isTranscoding,
|
||||
togglePlayPause,
|
||||
next,
|
||||
previous,
|
||||
@@ -53,6 +76,7 @@ export default function NowPlayingScreen() {
|
||||
toggleShuffle,
|
||||
jumpToIndex,
|
||||
removeFromQueue,
|
||||
reorderQueue,
|
||||
stop,
|
||||
} = useMusicPlayer();
|
||||
|
||||
@@ -98,7 +122,7 @@ export default function NowPlayingScreen() {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
const _handleStop = useCallback(() => {
|
||||
stop();
|
||||
router.back();
|
||||
}, [stop, router]);
|
||||
@@ -186,14 +210,7 @@ export default function NowPlayingScreen() {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleStop}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='#666' />
|
||||
</TouchableOpacity>
|
||||
<View style={{ width: 16 }} />
|
||||
</View>
|
||||
|
||||
{viewMode === "player" ? (
|
||||
@@ -221,6 +238,8 @@ export default function NowPlayingScreen() {
|
||||
getRepeatIcon={getRepeatIcon}
|
||||
queue={queue}
|
||||
queueIndex={queueIndex}
|
||||
mediaSource={mediaSource}
|
||||
isTranscoding={isTranscoding}
|
||||
/>
|
||||
) : (
|
||||
<QueueView
|
||||
@@ -229,6 +248,7 @@ export default function NowPlayingScreen() {
|
||||
queueIndex={queueIndex}
|
||||
onJumpToIndex={jumpToIndex}
|
||||
onRemoveFromQueue={removeFromQueue}
|
||||
onReorderQueue={reorderQueue}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -259,6 +279,8 @@ interface PlayerViewProps {
|
||||
getRepeatIcon: () => string;
|
||||
queue: BaseItemDto[];
|
||||
queueIndex: number;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
}
|
||||
|
||||
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
@@ -284,7 +306,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 +366,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 */}
|
||||
@@ -357,7 +416,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
</View>
|
||||
|
||||
{/* Main Controls */}
|
||||
<View className='flex flex-row items-center justify-center mb-4'>
|
||||
<View className='flex flex-row items-center justify-center mb-2'>
|
||||
<TouchableOpacity
|
||||
onPress={onPrevious}
|
||||
disabled={!canGoPrevious || isLoading}
|
||||
@@ -395,7 +454,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
</View>
|
||||
|
||||
{/* Shuffle & Repeat Controls */}
|
||||
<View className='flex flex-row items-center justify-center mb-6'>
|
||||
<View className='flex flex-row items-center justify-center mb-2'>
|
||||
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
|
||||
<Ionicons
|
||||
name='shuffle'
|
||||
@@ -411,7 +470,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||
/>
|
||||
{repeatMode === "one" && (
|
||||
<View className='absolute -top-1 -right-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
||||
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
||||
<Text className='text-white text-[10px] font-bold'>1</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -420,7 +479,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
|
||||
{/* Queue info */}
|
||||
{queue.length > 1 && (
|
||||
<View className='items-center mb-8'>
|
||||
<View className='items-center mb-4'>
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{queueIndex + 1} of {queue.length}
|
||||
</Text>
|
||||
@@ -436,6 +495,7 @@ interface QueueViewProps {
|
||||
queueIndex: number;
|
||||
onJumpToIndex: (index: number) => void;
|
||||
onRemoveFromQueue: (index: number) => void;
|
||||
onReorderQueue: (newQueue: BaseItemDto[]) => void;
|
||||
}
|
||||
|
||||
const QueueView: React.FC<QueueViewProps> = ({
|
||||
@@ -444,9 +504,11 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
queueIndex,
|
||||
onJumpToIndex,
|
||||
onRemoveFromQueue,
|
||||
onReorderQueue,
|
||||
}) => {
|
||||
const renderQueueItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||
const index = getIndex() ?? 0;
|
||||
const isCurrentTrack = index === queueIndex;
|
||||
const isPast = index < queueIndex;
|
||||
|
||||
@@ -458,80 +520,102 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => onJumpToIndex(index)}
|
||||
className={`flex-row items-center px-4 py-3 ${isCurrentTrack ? "bg-purple-900/30" : ""}`}
|
||||
style={{ opacity: isPast ? 0.5 : 1 }}
|
||||
>
|
||||
{/* Track number / Now playing indicator */}
|
||||
<View className='w-8 items-center'>
|
||||
{isCurrentTrack ? (
|
||||
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
||||
) : (
|
||||
<Text className='text-neutral-500 text-sm'>{index + 1}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Album art */}
|
||||
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
<Ionicons name='musical-note' size={16} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View className='flex-1 mr-2'>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Remove button (not for current track) */}
|
||||
{!isCurrentTrack && (
|
||||
<ScaleDecorator>
|
||||
<TouchableOpacity
|
||||
onPress={() => onJumpToIndex(index)}
|
||||
onLongPress={drag}
|
||||
disabled={isActive}
|
||||
className='flex-row items-center px-4 py-3'
|
||||
style={{
|
||||
opacity: isPast && !isActive ? 0.5 : 1,
|
||||
backgroundColor: isActive
|
||||
? "#2a2a2a"
|
||||
: isCurrentTrack
|
||||
? "rgba(147, 52, 233, 0.3)"
|
||||
: "#121212",
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemoveFromQueue(index)}
|
||||
onPressIn={drag}
|
||||
disabled={isActive}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-2'
|
||||
className='pr-2'
|
||||
>
|
||||
<Ionicons name='close' size={20} color='#666' />
|
||||
<Ionicons
|
||||
name='reorder-three'
|
||||
size={20}
|
||||
color={isActive ? "#9334E9" : "#666"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Album art */}
|
||||
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
<Ionicons name='musical-note' size={16} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View className='flex-1 mr-2'>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Now playing indicator */}
|
||||
{isCurrentTrack && (
|
||||
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
||||
)}
|
||||
|
||||
{/* Remove button (not for current track) */}
|
||||
{!isCurrentTrack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemoveFromQueue(index)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='close' size={20} color='#666' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScaleDecorator>
|
||||
);
|
||||
},
|
||||
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
|
||||
);
|
||||
|
||||
const _upNext = queue.slice(queueIndex + 1);
|
||||
const handleDragEnd = useCallback(
|
||||
({ data }: { data: BaseItemDto[] }) => {
|
||||
onReorderQueue(data);
|
||||
},
|
||||
[onReorderQueue],
|
||||
);
|
||||
|
||||
const history = queue.slice(0, queueIndex);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
<DraggableFlatList
|
||||
data={queue}
|
||||
keyExtractor={(item, index) => `${item.Id}-${index}`}
|
||||
renderItem={renderQueueItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialScrollIndex={queueIndex > 2 ? queueIndex - 2 : 0}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: 72,
|
||||
offset: 72 * index,
|
||||
index,
|
||||
})}
|
||||
ListHeaderComponent={
|
||||
<View className='px-4 py-2'>
|
||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||
|
||||
@@ -2,7 +2,9 @@ import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Platform } from "react-native";
|
||||
@@ -188,11 +190,21 @@ export default function RootLayout() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30000,
|
||||
staleTime: 30000, // 30 seconds - data is fresh
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create MMKV-based persister for offline support
|
||||
const mmkvPersister = createSyncStoragePersister({
|
||||
storage: {
|
||||
getItem: (key) => storage.getString(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.remove(key),
|
||||
},
|
||||
});
|
||||
|
||||
function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -338,7 +350,19 @@ function Layout() {
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: mmkvPersister,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) => {
|
||||
// Only persist successful queries
|
||||
return query.state.status === "success";
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<JellyfinProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
@@ -410,6 +434,6 @@ function Layout() {
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</JellyfinProvider>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
18
bun.lock
18
bun.lock
@@ -16,7 +16,9 @@
|
||||
"@react-navigation/material-top-tabs": "7.4.9",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~54.0.30",
|
||||
"expo-application": "~7.0.8",
|
||||
@@ -59,8 +61,10 @@
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^15.0.0",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-glass-effect-view": "^1.0.0",
|
||||
"react-native-google-cast": "^4.9.1",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^3.2.1",
|
||||
@@ -580,10 +584,16 @@
|
||||
|
||||
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
|
||||
|
||||
"@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.91.15", "", { "dependencies": { "@tanstack/query-core": "5.90.16" } }, "sha512-vnPSfQVo41EKJN8v20nkhWNZPyB1dMJIy5icOvCGzcCJzsmRefYY1owtr63ICOcjOiPPTuNEfPsdjdBhkzYnmA=="],
|
||||
|
||||
"@tanstack/query-sync-storage-persister": ["@tanstack/query-sync-storage-persister@5.90.18", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "@tanstack/query-persist-client-core": "5.91.15" } }, "sha512-tKngFopz/TuAe7LBDg7IOhWPh9blxdQ6QG/vVL2dFzRmlPNcSo4WdCSONqSDioJkcyTwh1YCSlcikmJ1WnSb3Q=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
|
||||
|
||||
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
@@ -1628,10 +1638,14 @@
|
||||
|
||||
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
|
||||
|
||||
"react-native-draggable-flatlist": ["react-native-draggable-flatlist@4.0.3", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw=="],
|
||||
|
||||
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],
|
||||
|
||||
"react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="],
|
||||
|
||||
"react-native-glass-effect-view": ["react-native-glass-effect-view@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ABYG0oIiqbXsxe2R/cMhNgDn3YgwDLz/2TIN2XOxQopXC+MiGsG9C32VYQvO2sYehcu5JmI3h3EzwLwl6lJhhA=="],
|
||||
|
||||
"react-native-google-cast": ["react-native-google-cast@4.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/HvIKAaWHtG6aTNCxrNrqA2ftWGkfH0M/2iN+28pdGUXpKmueb33mgL1m8D4zzwEODQMcmpfoCsym1IwDvugBQ=="],
|
||||
|
||||
"react-native-image-colors": ["react-native-image-colors@2.5.0", "", { "dependencies": { "node-vibrant": "^4.0.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-3zSDgNj5HaZ0PDWaXkc4BpWpZRM5N4gBsoPC7DBfM/+op69Yvwbc0S1T7CnxBWbvShtOvRE+b2BUBadVn+6z/g=="],
|
||||
@@ -2130,6 +2144,8 @@
|
||||
|
||||
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
|
||||
|
||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
|
||||
|
||||
@@ -16,6 +16,7 @@ export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
color={isFavorite ? "purple" : "white"}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -123,7 +123,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
<AddToWatchlist item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -142,7 +145,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
<AddToWatchlist item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -155,6 +161,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
user,
|
||||
itemWithSources,
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -183,6 +183,12 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
|
||||
if (!source || !videoStream) return null;
|
||||
|
||||
// Dolby Vision video check
|
||||
const isDolbyVision =
|
||||
videoStream.VideoRangeType === "DOVI" ||
|
||||
videoStream.DvVersionMajor != null ||
|
||||
videoStream.DvVersionMinor != null;
|
||||
|
||||
return (
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
@@ -195,6 +201,15 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||
/>
|
||||
{isDolbyVision && (
|
||||
<Badge
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name='sparkles-outline' size={16} color='white' />
|
||||
}
|
||||
text={"DV"}
|
||||
/>
|
||||
)}
|
||||
<Badge
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
|
||||
@@ -104,7 +104,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
color={color === "white" ? "white" : "#9334E9"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
|
||||
157
components/music/CreatePlaylistModal.tsx
Normal file
157
components/music/CreatePlaylistModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Keyboard } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useCreatePlaylist } from "@/hooks/usePlaylistMutations";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onPlaylistCreated?: (playlistId: string) => void;
|
||||
initialTrackId?: string;
|
||||
}
|
||||
|
||||
export const CreatePlaylistModal: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
onPlaylistCreated,
|
||||
initialTrackId,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const createPlaylist = useCreatePlaylist();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const snapPoints = useMemo(() => ["40%"], []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
const result = await createPlaylist.mutateAsync({
|
||||
name: name.trim(),
|
||||
trackIds: initialTrackId ? [initialTrackId] : undefined,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
onPlaylistCreated?.(result);
|
||||
}
|
||||
setOpen(false);
|
||||
}, [name, createPlaylist, initialTrackId, onPlaylistCreated, setOpen]);
|
||||
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
keyboardBehavior='interactive'
|
||||
keyboardBlurBehavior='restore'
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl mb-6'>
|
||||
{t("music.playlists.create_playlist")}
|
||||
</Text>
|
||||
|
||||
<Text className='text-neutral-400 mb-2 text-sm'>
|
||||
{t("music.playlists.playlist_name")}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
placeholder={t("music.playlists.enter_name")}
|
||||
placeholderTextColor='#737373'
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoFocus
|
||||
returnKeyType='done'
|
||||
onSubmitEditing={handleCreate}
|
||||
style={{
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
color: "white",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={handleCreate}
|
||||
disabled={!isValid || createPlaylist.isPending}
|
||||
className={`py-4 rounded-xl ${isValid ? "bg-purple-600" : "bg-neutral-700"}`}
|
||||
>
|
||||
{createPlaylist.isPending ? (
|
||||
<ActivityIndicator color='white' />
|
||||
) : (
|
||||
<Text
|
||||
className={`text-center font-semibold ${isValid ? "text-white" : "text-neutral-500"}`}
|
||||
>
|
||||
{t("music.playlists.create")}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -11,6 +10,17 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -20,6 +30,18 @@ const HORIZONTAL_MARGIN = Platform.OS === "android" ? 8 : 16;
|
||||
const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52;
|
||||
const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50;
|
||||
|
||||
// Gesture thresholds
|
||||
const VELOCITY_THRESHOLD = 1000;
|
||||
|
||||
// Logarithmic slowdown - never stops, just gets progressively slower
|
||||
const rubberBand = (distance: number, scale: number = 8): number => {
|
||||
"worklet";
|
||||
const absDistance = Math.abs(distance);
|
||||
const sign = distance < 0 ? -1 : 1;
|
||||
// Logarithmic: keeps growing but slower and slower
|
||||
return sign * scale * Math.log(1 + absDistance / scale);
|
||||
};
|
||||
|
||||
export const MiniPlayerBar: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -32,8 +54,12 @@ export const MiniPlayerBar: React.FC = () => {
|
||||
duration,
|
||||
togglePlayPause,
|
||||
next,
|
||||
stop,
|
||||
} = useMusicPlayer();
|
||||
|
||||
// Gesture state
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!api || !currentTrack) return null;
|
||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||
@@ -68,35 +94,102 @@ export const MiniPlayerBar: React.FC = () => {
|
||||
[next],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
// Pan gesture for swipe up (open modal) and swipe down (dismiss)
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetY([-15, 15])
|
||||
.onUpdate((event) => {
|
||||
// Logarithmic slowdown - keeps moving but progressively slower
|
||||
translateY.value = rubberBand(event.translationY, 6);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
const velocity = event.velocityY;
|
||||
const currentPosition = translateY.value;
|
||||
|
||||
// Swipe up - open modal (check position OR velocity)
|
||||
if (currentPosition < -16 || velocity < -VELOCITY_THRESHOLD) {
|
||||
// Slow return animation - won't jank with navigation
|
||||
translateY.value = withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
runOnJS(handlePress)();
|
||||
return;
|
||||
}
|
||||
// Swipe down - stop playback and dismiss (check position OR velocity)
|
||||
if (currentPosition > 16 || velocity > VELOCITY_THRESHOLD) {
|
||||
// No need to reset - component will unmount
|
||||
runOnJS(handleDismiss)();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only animate back if no action was triggered
|
||||
translateY.value = withTiming(0, {
|
||||
duration: 200,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
});
|
||||
|
||||
// Animated styles for the container
|
||||
const animatedContainerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
// Animated styles for the inner bar
|
||||
const animatedBarStyle = useAnimatedStyle(() => ({
|
||||
height: interpolate(
|
||||
translateY.value,
|
||||
[-50, 0, 50],
|
||||
[BAR_HEIGHT + 12, BAR_HEIGHT, BAR_HEIGHT],
|
||||
Extrapolation.EXTEND,
|
||||
),
|
||||
opacity: interpolate(
|
||||
translateY.value,
|
||||
[0, 30],
|
||||
[1, 0.6],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}));
|
||||
|
||||
if (!currentTrack) return null;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Album art */}
|
||||
<View style={styles.albumArt}>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={styles.albumImage}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.albumPlaceholder}>
|
||||
<Ionicons name='musical-note' size={20} color='#888' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Tappable area: Album art + Track info */}
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
style={styles.tappableArea}
|
||||
>
|
||||
{/* Album art */}
|
||||
<View style={styles.albumArt}>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={styles.albumImage}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.albumPlaceholder}>
|
||||
<Ionicons name='musical-note' size={20} color='#888' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View style={styles.trackInfo}>
|
||||
<Text numberOfLines={1} style={styles.trackTitle}>
|
||||
{currentTrack.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={styles.artistName}>
|
||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Track info */}
|
||||
<View style={styles.trackInfo}>
|
||||
<Text numberOfLines={1} style={styles.trackTitle}>
|
||||
{currentTrack.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={styles.artistName}>
|
||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Controls */}
|
||||
<View style={styles.controls}>
|
||||
@@ -136,31 +229,40 @@ export const MiniPlayerBar: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
bottom:
|
||||
BOTTOM_TAB_HEIGHT +
|
||||
insets.bottom +
|
||||
(Platform.OS === "android" ? 32 : 4),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.9}
|
||||
style={styles.touchable}
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
bottom:
|
||||
BOTTOM_TAB_HEIGHT +
|
||||
insets.bottom +
|
||||
(Platform.OS === "android" ? 32 : 4),
|
||||
},
|
||||
animatedContainerStyle,
|
||||
]}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
{content}
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={styles.androidContainer}>{content}</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Animated.View style={[styles.touchable, animatedBarStyle]}>
|
||||
{Platform.OS === "ios" ? (
|
||||
<GlassEffectView style={styles.blurContainer}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: 10,
|
||||
paddingLeft: 20,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</View>
|
||||
</GlassEffectView>
|
||||
) : (
|
||||
<View style={styles.androidContainer}>{content}</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -180,25 +282,24 @@ const styles = StyleSheet.create({
|
||||
overflow: "hidden",
|
||||
},
|
||||
blurContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: 10,
|
||||
paddingLeft: 20,
|
||||
paddingVertical: 0,
|
||||
height: BAR_HEIGHT,
|
||||
backgroundColor: "rgba(40, 40, 40, 0.5)",
|
||||
flex: 1,
|
||||
},
|
||||
androidContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
height: BAR_HEIGHT,
|
||||
backgroundColor: "rgba(28, 28, 30, 0.97)",
|
||||
borderRadius: 14,
|
||||
borderWidth: 0.5,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
tappableArea: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
albumArt: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
|
||||
@@ -7,6 +7,7 @@ import TrackPlayer, {
|
||||
usePlaybackState,
|
||||
useProgress,
|
||||
} from "react-native-track-player";
|
||||
import { audioStorageEvents, getLocalPath } from "@/providers/AudioStorage";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
export const MusicPlaybackEngine: React.FC = () => {
|
||||
@@ -20,6 +21,7 @@ export const MusicPlaybackEngine: React.FC = () => {
|
||||
reportProgress,
|
||||
onTrackEnd,
|
||||
syncFromTrackPlayer,
|
||||
triggerLookahead,
|
||||
} = useMusicPlayer();
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
@@ -62,12 +64,53 @@ export const MusicPlaybackEngine: React.FC = () => {
|
||||
}
|
||||
}, [position, reportProgress]);
|
||||
|
||||
// Listen for track end
|
||||
// Listen for track changes (native -> JS)
|
||||
// This triggers look-ahead caching, checks for cached versions, and handles track end
|
||||
useEffect(() => {
|
||||
const subscription =
|
||||
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
|
||||
Event.PlaybackActiveTrackChanged,
|
||||
async (event) => {
|
||||
// Trigger look-ahead caching when a new track starts playing
|
||||
if (event.track) {
|
||||
triggerLookahead();
|
||||
|
||||
// Check if there's a cached version we should use instead
|
||||
const trackId = event.track.id;
|
||||
const currentUrl = event.track.url as string;
|
||||
|
||||
// Only check if currently using a remote URL
|
||||
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
|
||||
const cachedPath = getLocalPath(trackId);
|
||||
if (cachedPath) {
|
||||
console.log(
|
||||
`[AudioCache] Switching to cached version for ${trackId}`,
|
||||
);
|
||||
try {
|
||||
// Load the cached version, preserving position if any
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
if (currentIndex !== undefined && currentIndex >= 0) {
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
const track = queue[currentIndex];
|
||||
// Remove and re-add with cached URL
|
||||
await TrackPlayer.remove(currentIndex);
|
||||
await TrackPlayer.add(
|
||||
{ ...track, url: cachedPath },
|
||||
currentIndex,
|
||||
);
|
||||
await TrackPlayer.skip(currentIndex);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AudioCache] Failed to switch to cached version:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no next track and the previous track ended, call onTrackEnd
|
||||
if (event.lastTrack && !event.track) {
|
||||
onTrackEnd();
|
||||
@@ -76,7 +119,54 @@ export const MusicPlaybackEngine: React.FC = () => {
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [onTrackEnd]);
|
||||
}, [onTrackEnd, triggerLookahead]);
|
||||
|
||||
// Listen for audio cache download completion and update queue URLs
|
||||
useEffect(() => {
|
||||
const onComplete = async ({
|
||||
itemId,
|
||||
localPath,
|
||||
}: {
|
||||
itemId: string;
|
||||
localPath: string;
|
||||
}) => {
|
||||
console.log(`[AudioCache] Track ${itemId} cached successfully`);
|
||||
|
||||
try {
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
|
||||
// Find the track in the queue
|
||||
const trackIndex = queue.findIndex((t) => t.id === itemId);
|
||||
|
||||
// Only update if track is in queue and not currently playing
|
||||
if (trackIndex >= 0 && trackIndex !== currentIndex) {
|
||||
const track = queue[trackIndex];
|
||||
const localUrl = localPath.startsWith("file://")
|
||||
? localPath
|
||||
: `file://${localPath}`;
|
||||
|
||||
// Skip if already using local URL
|
||||
if (track.url === localUrl) return;
|
||||
|
||||
console.log(
|
||||
`[AudioCache] Updating queue track ${trackIndex} to use cached file`,
|
||||
);
|
||||
|
||||
// Remove old track and insert updated one at same position
|
||||
await TrackPlayer.remove(trackIndex);
|
||||
await TrackPlayer.add({ ...track, url: localUrl }, trackIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[AudioCache] Failed to update queue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
audioStorageEvents.on("complete", onComplete);
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", onComplete);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// No visual component needed - TrackPlayer is headless
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
getLocalPath,
|
||||
isPermanentDownloading,
|
||||
isPermanentlyDownloaded,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
@@ -16,6 +22,7 @@ interface Props {
|
||||
index?: number;
|
||||
queue?: BaseItemDto[];
|
||||
showArtwork?: boolean;
|
||||
onOptionsPress?: (track: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
export const MusicTrackItem: React.FC<Props> = ({
|
||||
@@ -23,17 +30,12 @@ export const MusicTrackItem: React.FC<Props> = ({
|
||||
index,
|
||||
queue,
|
||||
showArtwork = true,
|
||||
onOptionsPress,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const {
|
||||
playTrack,
|
||||
playNext,
|
||||
addToQueue,
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
loadingTrackId,
|
||||
} = useMusicPlayer();
|
||||
const { playTrack, currentTrack, isPlaying, loadingTrackId } =
|
||||
useMusicPlayer();
|
||||
const { isConnected, serverConnected } = useNetworkStatus();
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
const albumId = track.AlbumId || track.ParentId;
|
||||
@@ -46,42 +48,78 @@ export const MusicTrackItem: React.FC<Props> = ({
|
||||
const isCurrentTrack = currentTrack?.Id === track.Id;
|
||||
const isTrackLoading = loadingTrackId === track.Id;
|
||||
|
||||
// Track download status with reactivity to completion events
|
||||
// Only track permanent downloads - we don't show UI for auto-caching
|
||||
const [downloadStatus, setDownloadStatus] = useState<
|
||||
"none" | "downloading" | "downloaded"
|
||||
>(() => {
|
||||
if (isPermanentlyDownloaded(track.Id)) return "downloaded";
|
||||
if (isPermanentDownloading(track.Id)) return "downloading";
|
||||
return "none";
|
||||
});
|
||||
|
||||
// Listen for download completion/error events (only for permanent downloads)
|
||||
useEffect(() => {
|
||||
const onComplete = (event: { itemId: string; permanent: boolean }) => {
|
||||
if (event.itemId === track.Id && event.permanent) {
|
||||
setDownloadStatus("downloaded");
|
||||
}
|
||||
};
|
||||
const onError = (event: { itemId: string }) => {
|
||||
if (event.itemId === track.Id) {
|
||||
setDownloadStatus("none");
|
||||
}
|
||||
};
|
||||
|
||||
audioStorageEvents.on("complete", onComplete);
|
||||
audioStorageEvents.on("error", onError);
|
||||
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", onComplete);
|
||||
audioStorageEvents.off("error", onError);
|
||||
};
|
||||
}, [track.Id]);
|
||||
|
||||
// Also check periodically if permanent download started (for when download is triggered externally)
|
||||
useEffect(() => {
|
||||
if (downloadStatus === "none" && isPermanentDownloading(track.Id)) {
|
||||
setDownloadStatus("downloading");
|
||||
}
|
||||
});
|
||||
|
||||
const _isDownloaded = downloadStatus === "downloaded";
|
||||
// Check if available locally (either cached or permanently downloaded)
|
||||
const isAvailableLocally = !!getLocalPath(track.Id);
|
||||
// Consider offline if either no network connection OR server is unreachable
|
||||
const isOffline = !isConnected || serverConnected === false;
|
||||
const isUnavailableOffline = isOffline && !isAvailableLocally;
|
||||
|
||||
const duration = useMemo(() => {
|
||||
if (!track.RunTimeTicks) return "";
|
||||
return formatDuration(track.RunTimeTicks);
|
||||
}, [track.RunTimeTicks]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isUnavailableOffline) return;
|
||||
playTrack(track, queue);
|
||||
}, [playTrack, track, queue]);
|
||||
}, [playTrack, track, queue, isUnavailableOffline]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
const options = ["Play Next", "Add to Queue", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
onOptionsPress?.(track);
|
||||
}, [onOptionsPress, track]);
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
title: track.Name ?? undefined,
|
||||
message: (track.Artists?.join(", ") || track.AlbumArtist) ?? undefined,
|
||||
},
|
||||
(selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
playNext(track);
|
||||
} else if (selectedIndex === 1) {
|
||||
addToQueue(track);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, track, playNext, addToQueue]);
|
||||
const handleOptionsPress = useCallback(() => {
|
||||
onOptionsPress?.(track);
|
||||
}, [onOptionsPress, track]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={300}
|
||||
disabled={isUnavailableOffline}
|
||||
className={`flex flex-row items-center py-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
|
||||
style={isUnavailableOffline ? { opacity: 0.5 } : undefined}
|
||||
>
|
||||
{index !== undefined && (
|
||||
<View className='w-8 items-center'>
|
||||
@@ -135,7 +173,7 @@ export const MusicTrackItem: React.FC<Props> = ({
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='flex-1 mr-4'>
|
||||
<View className='flex-1 mr-3'>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
|
||||
@@ -147,7 +185,34 @@ export const MusicTrackItem: React.FC<Props> = ({
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className='text-neutral-500 text-xs'>{duration}</Text>
|
||||
<Text className='text-neutral-500 text-xs mr-2'>{duration}</Text>
|
||||
|
||||
{/* Download status indicator */}
|
||||
{downloadStatus === "downloading" && (
|
||||
<ActivityIndicator
|
||||
size={14}
|
||||
color='#9334E9'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
{downloadStatus === "downloaded" && (
|
||||
<Ionicons
|
||||
name='checkmark-circle'
|
||||
size={16}
|
||||
color='#22c55e'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{onOptionsPress && (
|
||||
<TouchableOpacity
|
||||
onPress={handleOptionsPress}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-1'
|
||||
>
|
||||
<Ionicons name='ellipsis-vertical' size={18} color='#737373' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
136
components/music/PlaylistOptionsSheet.tsx
Normal file
136
components/music/PlaylistOptionsSheet.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
playlist: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const PlaylistOptionsSheet: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
playlist,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const deletePlaylist = useDeletePlaylist();
|
||||
|
||||
const snapPoints = useMemo(() => ["25%"], []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeletePlaylist = useCallback(() => {
|
||||
if (!playlist?.Id) return;
|
||||
|
||||
Alert.alert(
|
||||
t("music.playlists.delete_playlist"),
|
||||
t("music.playlists.delete_confirm", { name: playlist.Name }),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.delete"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deletePlaylist.mutate(
|
||||
{ playlistId: playlist.Id! },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [playlist, deletePlaylist, setOpen, router, t]);
|
||||
|
||||
if (!playlist) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
|
||||
<TouchableOpacity
|
||||
onPress={handleDeletePlaylist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='trash-outline' size={22} color='#ef4444' />
|
||||
<Text className='text-red-500 ml-4 text-base'>
|
||||
{t("music.playlists.delete_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
const _styles = StyleSheet.create({
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#404040",
|
||||
},
|
||||
});
|
||||
262
components/music/PlaylistPickerSheet.tsx
Normal file
262
components/music/PlaylistPickerSheet.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
trackToAdd: BaseItemDto | null;
|
||||
onCreateNew: () => void;
|
||||
}
|
||||
|
||||
export const PlaylistPickerSheet: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
trackToAdd,
|
||||
onCreateNew,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const addToPlaylist = useAddToPlaylist();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const snapPoints = useMemo(() => ["75%"], []);
|
||||
|
||||
// Fetch all playlists
|
||||
const { data: playlists, isLoading } = useQuery({
|
||||
queryKey: ["music-playlists-picker", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
recursive: true,
|
||||
mediaTypes: ["Audio"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: Boolean(api && user?.Id && open),
|
||||
});
|
||||
|
||||
const filteredPlaylists = useMemo(() => {
|
||||
if (!playlists) return [];
|
||||
if (!search) return playlists;
|
||||
return playlists.filter((playlist) =>
|
||||
playlist.Name?.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}, [playlists, search]);
|
||||
|
||||
const showSearch = (playlists?.length || 0) > 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch("");
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectPlaylist = useCallback(
|
||||
async (playlist: BaseItemDto) => {
|
||||
if (!trackToAdd?.Id || !playlist.Id) return;
|
||||
|
||||
await addToPlaylist.mutateAsync({
|
||||
playlistId: playlist.Id,
|
||||
trackIds: [trackToAdd.Id],
|
||||
playlistName: playlist.Name || undefined,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
[trackToAdd, addToPlaylist, setOpen],
|
||||
);
|
||||
|
||||
const handleCreateNew = useCallback(() => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
onCreateNew();
|
||||
}, 300);
|
||||
}, [onCreateNew, setOpen]);
|
||||
|
||||
const getPlaylistImageUrl = useCallback(
|
||||
(playlist: BaseItemDto) => {
|
||||
if (!api) return null;
|
||||
return `${api.basePath}/Items/${playlist.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl mb-2'>
|
||||
{t("music.track_options.add_to_playlist")}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 mb-4'>{trackToAdd?.Name}</Text>
|
||||
|
||||
{showSearch && (
|
||||
<Input
|
||||
placeholder={t("music.playlists.search_playlists")}
|
||||
className='mb-4 border-neutral-800 border'
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
returnKeyType='done'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create New Playlist Button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleCreateNew}
|
||||
className='flex-row items-center bg-purple-900/30 rounded-xl px-4 py-3.5 mb-4'
|
||||
>
|
||||
<View className='w-12 h-12 rounded-lg bg-purple-600 items-center justify-center mr-3'>
|
||||
<Ionicons name='add' size={28} color='white' />
|
||||
</View>
|
||||
<Text className='text-purple-400 font-semibold text-base'>
|
||||
{t("music.playlists.create_new")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isLoading ? (
|
||||
<View className='py-8 items-center'>
|
||||
<ActivityIndicator color='#9334E9' />
|
||||
</View>
|
||||
) : filteredPlaylists.length === 0 ? (
|
||||
<View className='py-8 items-center'>
|
||||
<Text className='text-neutral-500'>
|
||||
{search ? t("search.no_results") : t("music.no_playlists")}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='rounded-xl overflow-hidden bg-neutral-800'>
|
||||
{filteredPlaylists.map((playlist, index) => (
|
||||
<View key={playlist.Id}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSelectPlaylist(playlist)}
|
||||
className='flex-row items-center px-4 py-3'
|
||||
disabled={addToPlaylist.isPending}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: getPlaylistImageUrl(playlist) || undefined,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text numberOfLines={1} className='text-white text-base'>
|
||||
{playlist.Name}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{playlist.ChildCount} {t("music.tabs.tracks")}
|
||||
</Text>
|
||||
</View>
|
||||
{addToPlaylist.isPending && (
|
||||
<ActivityIndicator size='small' color='#9334E9' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{index < filteredPlaylists.length - 1 && (
|
||||
<View style={styles.separator} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#404040",
|
||||
},
|
||||
});
|
||||
437
components/music/TrackOptionsSheet.tsx
Normal file
437
components/music/TrackOptionsSheet.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
downloadTrack,
|
||||
isCached,
|
||||
isPermanentDownloading,
|
||||
isPermanentlyDownloaded,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
track: BaseItemDto | null;
|
||||
onAddToPlaylist: () => void;
|
||||
playlistId?: string;
|
||||
onRemoveFromPlaylist?: () => void;
|
||||
}
|
||||
|
||||
export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
track,
|
||||
onAddToPlaylist,
|
||||
playlistId,
|
||||
onRemoveFromPlaylist,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
const { playNext, addToQueue } = useMusicPlayer();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
|
||||
// Counter to trigger re-evaluation of download status when storage changes
|
||||
const [storageUpdateCounter, setStorageUpdateCounter] = useState(0);
|
||||
|
||||
// Listen for storage events to update download status
|
||||
useEffect(() => {
|
||||
const handleComplete = (event: { itemId: string }) => {
|
||||
if (event.itemId === track?.Id) {
|
||||
setStorageUpdateCounter((c) => c + 1);
|
||||
}
|
||||
};
|
||||
|
||||
audioStorageEvents.on("complete", handleComplete);
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", handleComplete);
|
||||
};
|
||||
}, [track?.Id]);
|
||||
|
||||
// Use a placeholder item for useFavorite when track is null
|
||||
const { isFavorite, toggleFavorite } = useFavorite(
|
||||
track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
|
||||
);
|
||||
|
||||
const snapPoints = useMemo(() => ["65%"], []);
|
||||
|
||||
// Check download status (storageUpdateCounter triggers re-evaluation when download completes)
|
||||
const isAlreadyDownloaded = useMemo(
|
||||
() => isPermanentlyDownloaded(track?.Id),
|
||||
[track?.Id, storageUpdateCounter],
|
||||
);
|
||||
const isOnlyCached = useMemo(
|
||||
() => isCached(track?.Id),
|
||||
[track?.Id, storageUpdateCounter],
|
||||
);
|
||||
const isCurrentlyDownloading = useMemo(
|
||||
() => isPermanentDownloading(track?.Id),
|
||||
[track?.Id, storageUpdateCounter],
|
||||
);
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!track) return null;
|
||||
const albumId = track.AlbumId || track.ParentId;
|
||||
if (albumId) {
|
||||
return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=200&maxWidth=200`;
|
||||
}
|
||||
return getPrimaryImageUrl({ api, item: track });
|
||||
}, [api, track]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePlayNext = useCallback(() => {
|
||||
if (track) {
|
||||
playNext(track);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [track, playNext, setOpen]);
|
||||
|
||||
const handleAddToQueue = useCallback(() => {
|
||||
if (track) {
|
||||
addToQueue(track);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [track, addToQueue, setOpen]);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
onAddToPlaylist();
|
||||
}, 300);
|
||||
}, [onAddToPlaylist, setOpen]);
|
||||
|
||||
const handleRemoveFromPlaylist = useCallback(() => {
|
||||
if (onRemoveFromPlaylist) {
|
||||
onRemoveFromPlaylist();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [onRemoveFromPlaylist, setOpen]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!track?.Id || !api || !user?.Id || isAlreadyDownloaded) return;
|
||||
|
||||
setIsDownloadingTrack(true);
|
||||
try {
|
||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||
if (result?.url && !result.isTranscoding) {
|
||||
await downloadTrack(track.Id, result.url, {
|
||||
permanent: true,
|
||||
container: result.mediaSource?.Container || undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
setIsDownloadingTrack(false);
|
||||
setOpen(false);
|
||||
}, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
|
||||
|
||||
const handleGoToArtist = useCallback(() => {
|
||||
const artistId = track?.ArtistItems?.[0]?.Id;
|
||||
if (artistId) {
|
||||
setOpen(false);
|
||||
router.push({
|
||||
pathname: "/music/artist/[artistId]",
|
||||
params: { artistId },
|
||||
});
|
||||
}
|
||||
}, [track?.ArtistItems, router, setOpen]);
|
||||
|
||||
const handleGoToAlbum = useCallback(() => {
|
||||
const albumId = track?.AlbumId || track?.ParentId;
|
||||
if (albumId) {
|
||||
setOpen(false);
|
||||
router.push({
|
||||
pathname: "/music/album/[albumId]",
|
||||
params: { albumId },
|
||||
});
|
||||
}
|
||||
}, [track?.AlbumId, track?.ParentId, router, setOpen]);
|
||||
|
||||
const handleToggleFavorite = useCallback(() => {
|
||||
if (track) {
|
||||
toggleFavorite();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [track, toggleFavorite, setOpen]);
|
||||
|
||||
// Check if navigation options are available
|
||||
const hasArtist = !!track?.ArtistItems?.[0]?.Id;
|
||||
const hasAlbum = !!(track?.AlbumId || track?.ParentId);
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{/* Track Info Header */}
|
||||
<View className='flex-row items-center mb-6 px-2'>
|
||||
<View
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='musical-note' size={24} color='#737373' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className='text-white font-semibold text-base'
|
||||
>
|
||||
{track.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
|
||||
{track.Artists?.join(", ") || track.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Playback Options */}
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayNext}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='play-forward' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.play_next")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.separator} />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleAddToQueue}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='list' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.add_to_queue")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Library Options */}
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
|
||||
<TouchableOpacity
|
||||
onPress={handleToggleFavorite}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons
|
||||
name={isFavorite ? "heart" : "heart-outline"}
|
||||
size={22}
|
||||
color={isFavorite ? "#ec4899" : "white"}
|
||||
/>
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.separator} />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleAddToPlaylist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='albums-outline' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.add_to_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{playlistId && (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<TouchableOpacity
|
||||
onPress={handleRemoveFromPlaylist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='trash-outline' size={22} color='#ef4444' />
|
||||
<Text className='text-red-500 ml-4 text-base'>
|
||||
{t("music.track_options.remove_from_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.separator} />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleDownload}
|
||||
disabled={
|
||||
isAlreadyDownloaded ||
|
||||
isCurrentlyDownloading ||
|
||||
isDownloadingTrack
|
||||
}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
{isCurrentlyDownloading || isDownloadingTrack ? (
|
||||
<ActivityIndicator size={22} color='white' />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={
|
||||
isAlreadyDownloaded ? "checkmark-circle" : "download-outline"
|
||||
}
|
||||
size={22}
|
||||
color={isAlreadyDownloaded ? "#22c55e" : "white"}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
className={`ml-4 text-base ${isAlreadyDownloaded ? "text-green-500" : "text-white"}`}
|
||||
>
|
||||
{isCurrentlyDownloading || isDownloadingTrack
|
||||
? t("music.track_options.downloading")
|
||||
: isAlreadyDownloaded
|
||||
? t("music.track_options.downloaded")
|
||||
: t("music.track_options.download")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isOnlyCached && !isAlreadyDownloaded && (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<View className='flex-row items-center px-4 py-3.5'>
|
||||
<Ionicons name='cloud-done-outline' size={22} color='#737373' />
|
||||
<Text className='text-neutral-500 ml-4 text-base'>
|
||||
{t("music.track_options.cached")}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Navigation Options */}
|
||||
{(hasArtist || hasAlbum) && (
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
|
||||
{hasArtist && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToArtist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='person-outline' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.go_to_artist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{hasAlbum && <View style={styles.separator} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasAlbum && (
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToAlbum}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='disc-outline' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.go_to_album")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#404040",
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import {
|
||||
clearCache,
|
||||
clearPermanentDownloads,
|
||||
getStorageStats,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -12,6 +18,7 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -29,6 +36,11 @@ export const StorageSettings = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: musicCacheStats } = useQuery({
|
||||
queryKey: ["musicCacheStats"],
|
||||
queryFn: () => getStorageStats(),
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
@@ -39,6 +51,32 @@ export const StorageSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onClearMusicCacheClicked = useCallback(async () => {
|
||||
try {
|
||||
await clearCache();
|
||||
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
successHapticFeedback();
|
||||
toast.success(t("home.settings.storage.music_cache_cleared"));
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
|
||||
|
||||
const onDeleteDownloadedSongsClicked = useCallback(async () => {
|
||||
try {
|
||||
await clearPermanentDownloads();
|
||||
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
successHapticFeedback();
|
||||
toast.success(t("home.settings.storage.downloaded_songs_deleted"));
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
return ((value / total) * 100).toFixed(2);
|
||||
};
|
||||
@@ -102,13 +140,41 @@ export const StorageSettings = () => {
|
||||
</View>
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteClicked}
|
||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||
/>
|
||||
</ListGroup>
|
||||
<>
|
||||
<ListGroup
|
||||
title={t("home.settings.storage.music_cache_title")}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.storage.music_cache_description")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ListItem
|
||||
onPress={onClearMusicCacheClicked}
|
||||
title={t("home.settings.storage.clear_music_cache")}
|
||||
subtitle={t("home.settings.storage.music_cache_size", {
|
||||
size: (musicCacheStats?.cacheSize ?? 0).bytesToReadable(),
|
||||
})}
|
||||
/>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteDownloadedSongsClicked}
|
||||
title={t("home.settings.storage.delete_all_downloaded_songs")}
|
||||
subtitle={t("home.settings.storage.downloaded_songs_size", {
|
||||
size: (musicCacheStats?.permanentSize ?? 0).bytesToReadable(),
|
||||
})}
|
||||
/>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteClicked}
|
||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.50.1",
|
||||
"channel": "0.51.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.50.1",
|
||||
"channel": "0.51.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.50.1",
|
||||
"channel": "0.51.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
193
hooks/usePlaylistMutations.ts
Normal file
193
hooks/usePlaylistMutations.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { getLibraryApi, getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* Hook to create a new playlist
|
||||
*/
|
||||
export const useCreatePlaylist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
name,
|
||||
trackIds,
|
||||
}: {
|
||||
name: string;
|
||||
trackIds?: string[];
|
||||
}): Promise<string | undefined> => {
|
||||
if (!api || !user?.Id) {
|
||||
throw new Error("API not configured");
|
||||
}
|
||||
|
||||
const response = await getPlaylistsApi(api).createPlaylist({
|
||||
createPlaylistDto: {
|
||||
Name: name,
|
||||
Ids: trackIds,
|
||||
UserId: user.Id,
|
||||
MediaType: "Audio",
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.Id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlists"],
|
||||
refetchType: "all",
|
||||
});
|
||||
toast.success(t("music.playlists.created"));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t("music.playlists.failed_to_create"));
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to add a track to a playlist
|
||||
*/
|
||||
export const useAddToPlaylist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
playlistId,
|
||||
trackIds,
|
||||
}: {
|
||||
playlistId: string;
|
||||
trackIds: string[];
|
||||
playlistName?: string;
|
||||
}): Promise<void> => {
|
||||
if (!api || !user?.Id) {
|
||||
throw new Error("API not configured");
|
||||
}
|
||||
|
||||
await getPlaylistsApi(api).addItemToPlaylist({
|
||||
playlistId,
|
||||
ids: trackIds,
|
||||
userId: user.Id,
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlist", variables.playlistId],
|
||||
});
|
||||
if (variables.playlistName) {
|
||||
toast.success(
|
||||
t("music.playlists.added_to", { name: variables.playlistName }),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("music.playlists.added"));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t("music.playlists.failed_to_add"));
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to remove a track from a playlist
|
||||
*/
|
||||
export const useRemoveFromPlaylist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
playlistId,
|
||||
entryIds,
|
||||
}: {
|
||||
playlistId: string;
|
||||
entryIds: string[];
|
||||
playlistName?: string;
|
||||
}): Promise<void> => {
|
||||
if (!api) {
|
||||
throw new Error("API not configured");
|
||||
}
|
||||
|
||||
await getPlaylistsApi(api).removeItemFromPlaylist({
|
||||
playlistId,
|
||||
entryIds,
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlist", variables.playlistId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlist-tracks", variables.playlistId],
|
||||
});
|
||||
if (variables.playlistName) {
|
||||
toast.success(
|
||||
t("music.playlists.removed_from", { name: variables.playlistName }),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("music.playlists.removed"));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t("music.playlists.failed_to_remove"));
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a playlist
|
||||
*/
|
||||
export const useDeletePlaylist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
playlistId,
|
||||
}: {
|
||||
playlistId: string;
|
||||
}): Promise<void> => {
|
||||
if (!api) {
|
||||
throw new Error("API not configured");
|
||||
}
|
||||
|
||||
await getLibraryApi(api).deleteItem({
|
||||
itemId: playlistId,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlists"],
|
||||
refetchType: "all",
|
||||
});
|
||||
toast.success(t("music.playlists.deleted"));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t("music.playlists.failed_to_delete"));
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
@@ -5,42 +5,92 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
|
||||
class DownloadService : Service() {
|
||||
private val TAG = "DownloadService"
|
||||
private val NOTIFICATION_ID = 1001
|
||||
private val CHANNEL_ID = "download_channel"
|
||||
|
||||
|
||||
// Time threshold to detect if we're in boot context (10 minutes after boot)
|
||||
private val BOOT_THRESHOLD_MS = 10 * 60 * 1000L
|
||||
|
||||
private val binder = DownloadServiceBinder()
|
||||
private var activeDownloadCount = 0
|
||||
private var currentDownloadTitle = "Preparing download..."
|
||||
private var currentProgress = 0
|
||||
|
||||
private var isForegroundStarted = false
|
||||
|
||||
inner class DownloadServiceBinder : Binder() {
|
||||
fun getService(): DownloadService = this@DownloadService
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "DownloadService created")
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
Log.d(TAG, "DownloadService bound")
|
||||
return binder
|
||||
}
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "DownloadService started")
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
|
||||
// On Android 15+, dataSync foreground services cannot be started from BOOT_COMPLETED context
|
||||
// Check if we're likely in a boot context and skip foreground start if so
|
||||
if (Build.VERSION.SDK_INT >= 35 && isLikelyBootContext()) {
|
||||
Log.w(TAG, "Skipping foreground start - likely boot context on Android 15+")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
startForegroundSafely()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're likely in a boot context by checking system uptime.
|
||||
* If the system has been up for less than the threshold, we might be in boot context.
|
||||
*/
|
||||
private fun isLikelyBootContext(): Boolean {
|
||||
val uptimeMs = SystemClock.elapsedRealtime()
|
||||
return uptimeMs < BOOT_THRESHOLD_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Start foreground service safely with proper service type for Android 14+
|
||||
*/
|
||||
private fun startForegroundSafely() {
|
||||
if (isForegroundStarted) return
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
createNotification(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
}
|
||||
isForegroundStarted = true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start foreground service", e)
|
||||
// If we can't start foreground, stop the service
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "DownloadService destroyed")
|
||||
@@ -86,7 +136,7 @@ class DownloadService : Service() {
|
||||
activeDownloadCount++
|
||||
Log.d(TAG, "Download started, active count: $activeDownloadCount")
|
||||
if (activeDownloadCount == 1) {
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
startForegroundSafely()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +144,10 @@ class DownloadService : Service() {
|
||||
activeDownloadCount = maxOf(0, activeDownloadCount - 1)
|
||||
Log.d(TAG, "Download stopped, active count: $activeDownloadCount")
|
||||
if (activeDownloadCount == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
if (isForegroundStarted) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
isForegroundStarted = false
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
||||
implementation 'io.github.mengzhidaren:vlc-android-sdk:3.6.3'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
"@react-navigation/material-top-tabs": "7.4.9",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~54.0.30",
|
||||
"expo-application": "~7.0.8",
|
||||
@@ -78,8 +80,10 @@
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^15.0.0",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-glass-effect-view": "^1.0.0",
|
||||
"react-native-google-cast": "^4.9.1",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^3.2.1",
|
||||
|
||||
660
providers/AudioStorage/index.ts
Normal file
660
providers/AudioStorage/index.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
/**
|
||||
* Audio Storage Module
|
||||
*
|
||||
* Unified storage manager for audio files supporting:
|
||||
* - Look-ahead cache (auto-managed, ephemeral, stored in cache directory)
|
||||
* - Future: Full music downloads (user-initiated, permanent, stored in documents)
|
||||
*
|
||||
* getLocalPath() checks permanent storage first, then cache.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { Directory, File, Paths } from "expo-file-system";
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
import type {
|
||||
DownloadCompleteEvent as BGDownloadCompleteEvent,
|
||||
DownloadErrorEvent as BGDownloadErrorEvent,
|
||||
} from "@/modules";
|
||||
import { BackgroundDownloader } from "@/modules";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type {
|
||||
AudioStorageIndex,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadOptions,
|
||||
StoredTrackInfo,
|
||||
} from "./types";
|
||||
|
||||
// Storage keys
|
||||
const AUDIO_STORAGE_INDEX_KEY = "audio_storage.v1.json";
|
||||
|
||||
// Directory names
|
||||
const AUDIO_CACHE_DIR = "streamyfin-audio-cache";
|
||||
const AUDIO_PERMANENT_DIR = "streamyfin-audio";
|
||||
|
||||
// Default limits
|
||||
const DEFAULT_MAX_CACHE_TRACKS = 10;
|
||||
const DEFAULT_MAX_CACHE_SIZE_BYTES = 500 * 1024 * 1024; // 500MB
|
||||
|
||||
// Configurable limits (can be updated at runtime)
|
||||
let configuredMaxCacheSizeBytes = DEFAULT_MAX_CACHE_SIZE_BYTES;
|
||||
|
||||
// Event emitter for notifying about download completion
|
||||
class AudioStorageEventEmitter extends EventEmitter<{
|
||||
complete: (event: DownloadCompleteEvent) => void;
|
||||
error: (event: DownloadErrorEvent) => void;
|
||||
}> {}
|
||||
|
||||
export const audioStorageEvents = new AudioStorageEventEmitter();
|
||||
|
||||
// Track active downloads: taskId -> { itemId, permanent }
|
||||
const activeDownloads = new Map<
|
||||
number,
|
||||
{ itemId: string; permanent: boolean }
|
||||
>();
|
||||
|
||||
// Track items being downloaded by itemId for quick lookup
|
||||
const downloadingItems = new Set<string>();
|
||||
|
||||
// Track permanent downloads separately for UI indicator
|
||||
const permanentDownloadingItems = new Set<string>();
|
||||
|
||||
// Cached index (loaded from storage on init)
|
||||
let storageIndex: AudioStorageIndex | null = null;
|
||||
|
||||
// Directories (initialized on first use)
|
||||
let cacheDir: Directory | null = null;
|
||||
let permanentDir: Directory | null = null;
|
||||
|
||||
// Event listener subscriptions (for cleanup)
|
||||
let _completeSubscription: EventSubscription | null = null;
|
||||
let _errorSubscription: EventSubscription | null = null;
|
||||
let listenersSetup = false;
|
||||
|
||||
/**
|
||||
* Get the storage index from MMKV
|
||||
*/
|
||||
function getStorageIndex(): AudioStorageIndex {
|
||||
if (storageIndex) {
|
||||
return storageIndex;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = storage.getString(AUDIO_STORAGE_INDEX_KEY);
|
||||
if (data) {
|
||||
storageIndex = JSON.parse(data) as AudioStorageIndex;
|
||||
return storageIndex;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
storageIndex = {
|
||||
tracks: {},
|
||||
totalCacheSize: 0,
|
||||
totalPermanentSize: 0,
|
||||
};
|
||||
return storageIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the storage index to MMKV
|
||||
*/
|
||||
function saveStorageIndex(): void {
|
||||
if (storageIndex) {
|
||||
try {
|
||||
storage.set(AUDIO_STORAGE_INDEX_KEY, JSON.stringify(storageIndex));
|
||||
} catch {
|
||||
// Ignore save errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directories exist
|
||||
*/
|
||||
async function ensureDirectories(): Promise<void> {
|
||||
try {
|
||||
if (!cacheDir) {
|
||||
cacheDir = new Directory(Paths.cache, AUDIO_CACHE_DIR);
|
||||
if (!cacheDir.exists) {
|
||||
await cacheDir.create();
|
||||
}
|
||||
}
|
||||
|
||||
if (!permanentDir) {
|
||||
permanentDir = new Directory(Paths.document, AUDIO_PERMANENT_DIR);
|
||||
if (!permanentDir.exists) {
|
||||
await permanentDir.create();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[AudioStorage] Failed to create directories:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum cache size in megabytes
|
||||
* Call this when settings change
|
||||
*/
|
||||
export function setMaxCacheSizeMB(sizeMB: number): void {
|
||||
configuredMaxCacheSizeBytes = sizeMB * 1024 * 1024;
|
||||
console.log(
|
||||
`[AudioStorage] Max cache size set to ${sizeMB}MB (${configuredMaxCacheSizeBytes} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize audio storage - call this on app startup
|
||||
*/
|
||||
export async function initAudioStorage(): Promise<void> {
|
||||
console.log("[AudioStorage] Initializing...");
|
||||
try {
|
||||
await ensureDirectories();
|
||||
getStorageIndex();
|
||||
setupEventListeners();
|
||||
console.log("[AudioStorage] Initialization complete");
|
||||
} catch (error) {
|
||||
console.warn("[AudioStorage] Initialization error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up BackgroundDownloader event listeners
|
||||
* Safe to call multiple times - will only set up once
|
||||
*/
|
||||
function setupEventListeners(): void {
|
||||
// Prevent duplicate listeners
|
||||
if (listenersSetup) return;
|
||||
listenersSetup = true;
|
||||
|
||||
try {
|
||||
console.log("[AudioStorage] Setting up event listeners...");
|
||||
|
||||
_completeSubscription = BackgroundDownloader.addCompleteListener(
|
||||
(event: BGDownloadCompleteEvent) => {
|
||||
console.log(
|
||||
`[AudioStorage] Complete event received: taskId=${event.taskId}, activeDownloads=${JSON.stringify([...activeDownloads.entries()])}`,
|
||||
);
|
||||
const downloadInfo = activeDownloads.get(event.taskId);
|
||||
if (!downloadInfo) {
|
||||
console.log(
|
||||
`[AudioStorage] Ignoring complete event for unknown taskId: ${event.taskId}`,
|
||||
);
|
||||
return; // Not an audio download
|
||||
}
|
||||
|
||||
handleDownloadComplete(event, downloadInfo);
|
||||
},
|
||||
);
|
||||
|
||||
_errorSubscription = BackgroundDownloader.addErrorListener(
|
||||
(event: BGDownloadErrorEvent) => {
|
||||
console.log(
|
||||
`[AudioStorage] Error event received: taskId=${event.taskId}, error=${event.error}`,
|
||||
);
|
||||
const downloadInfo = activeDownloads.get(event.taskId);
|
||||
if (!downloadInfo) return; // Not an audio download
|
||||
|
||||
handleDownloadError(event, downloadInfo);
|
||||
},
|
||||
);
|
||||
|
||||
console.log("[AudioStorage] Event listeners set up successfully");
|
||||
} catch (error) {
|
||||
console.warn("[AudioStorage] Failed to setup event listeners:", error);
|
||||
listenersSetup = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle download completion
|
||||
*/
|
||||
async function handleDownloadComplete(
|
||||
event: BGDownloadCompleteEvent,
|
||||
downloadInfo: { itemId: string; permanent: boolean },
|
||||
): Promise<void> {
|
||||
const { itemId, permanent } = downloadInfo;
|
||||
|
||||
try {
|
||||
const file = new File(`file://${event.filePath}`);
|
||||
const fileInfo = file.info();
|
||||
const size = fileInfo.size || 0;
|
||||
|
||||
const index = getStorageIndex();
|
||||
|
||||
// Add to index
|
||||
const trackInfo: StoredTrackInfo = {
|
||||
itemId,
|
||||
localPath: event.filePath,
|
||||
size,
|
||||
storedAt: Date.now(),
|
||||
permanent,
|
||||
};
|
||||
|
||||
index.tracks[itemId] = trackInfo;
|
||||
|
||||
if (permanent) {
|
||||
index.totalPermanentSize += size;
|
||||
} else {
|
||||
index.totalCacheSize += size;
|
||||
}
|
||||
|
||||
saveStorageIndex();
|
||||
|
||||
console.log(
|
||||
`[AudioStorage] Downloaded ${itemId} (${(size / 1024 / 1024).toFixed(1)}MB, permanent=${permanent})`,
|
||||
);
|
||||
|
||||
// Emit completion event
|
||||
audioStorageEvents.emit("complete", {
|
||||
itemId,
|
||||
localPath: event.filePath,
|
||||
permanent,
|
||||
});
|
||||
|
||||
// Clean up tracking
|
||||
activeDownloads.delete(event.taskId);
|
||||
downloadingItems.delete(itemId);
|
||||
permanentDownloadingItems.delete(itemId);
|
||||
|
||||
// Evict old cache if needed (only for cache downloads)
|
||||
if (!permanent) {
|
||||
evictCacheIfNeeded().catch(() => {
|
||||
// Ignore eviction errors
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AudioStorage] Error handling download complete:`, error);
|
||||
activeDownloads.delete(event.taskId);
|
||||
downloadingItems.delete(itemId);
|
||||
permanentDownloadingItems.delete(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle download error
|
||||
*/
|
||||
function handleDownloadError(
|
||||
event: BGDownloadErrorEvent,
|
||||
downloadInfo: { itemId: string; permanent: boolean },
|
||||
): void {
|
||||
const { itemId } = downloadInfo;
|
||||
|
||||
console.error(`[AudioStorage] Download failed for ${itemId}:`, event.error);
|
||||
|
||||
audioStorageEvents.emit("error", {
|
||||
itemId,
|
||||
error: event.error,
|
||||
});
|
||||
|
||||
activeDownloads.delete(event.taskId);
|
||||
downloadingItems.delete(itemId);
|
||||
permanentDownloadingItems.delete(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the local file path for a track if it exists
|
||||
* Checks permanent storage first, then cache
|
||||
* Returns the path WITH file:// prefix for TrackPlayer
|
||||
*/
|
||||
export function getLocalPath(itemId: string | undefined): string | null {
|
||||
if (!itemId) return null;
|
||||
|
||||
try {
|
||||
const index = getStorageIndex();
|
||||
const info = index.tracks[itemId];
|
||||
|
||||
if (info) {
|
||||
// Verify file still exists (File constructor needs file:// URI)
|
||||
try {
|
||||
const fileUri = info.localPath.startsWith("file://")
|
||||
? info.localPath
|
||||
: `file://${info.localPath}`;
|
||||
const file = new File(fileUri);
|
||||
if (file.exists) {
|
||||
// Return the URI with file:// prefix for TrackPlayer
|
||||
return fileUri;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, remove from index
|
||||
if (info.permanent) {
|
||||
index.totalPermanentSize -= info.size;
|
||||
} else {
|
||||
index.totalCacheSize -= info.size;
|
||||
}
|
||||
delete index.tracks[itemId];
|
||||
saveStorageIndex();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a track is currently being downloaded (any type)
|
||||
*/
|
||||
export function isDownloading(itemId: string | undefined): boolean {
|
||||
if (!itemId) return false;
|
||||
return downloadingItems.has(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a track is currently being permanently downloaded (user-initiated)
|
||||
* Use this for UI indicators - we don't want to show spinners for auto-caching
|
||||
*/
|
||||
export function isPermanentDownloading(itemId: string | undefined): boolean {
|
||||
if (!itemId) return false;
|
||||
return permanentDownloadingItems.has(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a track is permanently downloaded (not just cached)
|
||||
*/
|
||||
export function isPermanentlyDownloaded(itemId: string | undefined): boolean {
|
||||
if (!itemId) return false;
|
||||
|
||||
try {
|
||||
const index = getStorageIndex();
|
||||
const info = index.tracks[itemId];
|
||||
|
||||
if (info?.permanent) {
|
||||
// Verify file still exists
|
||||
try {
|
||||
const fileUri = info.localPath.startsWith("file://")
|
||||
? info.localPath
|
||||
: `file://${info.localPath}`;
|
||||
const file = new File(fileUri);
|
||||
if (file.exists) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a track is cached (not permanently downloaded)
|
||||
*/
|
||||
export function isCached(itemId: string | undefined): boolean {
|
||||
if (!itemId) return false;
|
||||
|
||||
try {
|
||||
const index = getStorageIndex();
|
||||
const info = index.tracks[itemId];
|
||||
|
||||
if (info && !info.permanent) {
|
||||
// Verify file still exists
|
||||
try {
|
||||
const fileUri = info.localPath.startsWith("file://")
|
||||
? info.localPath
|
||||
: `file://${info.localPath}`;
|
||||
const file = new File(fileUri);
|
||||
if (file.exists) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a track to storage
|
||||
* @param itemId - Jellyfin item ID
|
||||
* @param url - Stream URL to download from
|
||||
* @param options - Download options (permanent: true for user downloads, false for cache)
|
||||
*/
|
||||
export async function downloadTrack(
|
||||
itemId: string,
|
||||
url: string,
|
||||
options: DownloadOptions = { permanent: false },
|
||||
): Promise<void> {
|
||||
const { permanent } = options;
|
||||
|
||||
// Skip if already downloading
|
||||
if (isDownloading(itemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already permanently downloaded
|
||||
if (isPermanentlyDownloaded(itemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If requesting permanent download and file is only cached, delete cached version first
|
||||
if (permanent && isCached(itemId)) {
|
||||
console.log(
|
||||
`[AudioStorage] Upgrading cached track to permanent: ${itemId}`,
|
||||
);
|
||||
await deleteTrack(itemId);
|
||||
}
|
||||
|
||||
// Skip if already cached and not requesting permanent
|
||||
if (!permanent && getLocalPath(itemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure listeners are set up
|
||||
setupEventListeners();
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const targetDir = permanent ? permanentDir : cacheDir;
|
||||
|
||||
if (!targetDir) {
|
||||
console.warn("[AudioStorage] Target directory not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the actual container format as extension, fallback to m4a
|
||||
const extension = options.container?.toLowerCase() || "m4a";
|
||||
const filename = `${itemId}.${extension}`;
|
||||
const destinationPath =
|
||||
`${targetDir.uri.replace(/\/$/, "")}/${filename}`.replace("file://", "");
|
||||
|
||||
console.log(
|
||||
`[AudioStorage] Starting download: ${itemId} (permanent=${permanent})`,
|
||||
);
|
||||
|
||||
try {
|
||||
downloadingItems.add(itemId);
|
||||
if (permanent) {
|
||||
permanentDownloadingItems.add(itemId);
|
||||
}
|
||||
const taskId = await BackgroundDownloader.startDownload(
|
||||
url,
|
||||
destinationPath,
|
||||
);
|
||||
activeDownloads.set(taskId, { itemId, permanent });
|
||||
console.log(
|
||||
`[AudioStorage] Download started with taskId=${taskId}, tracking ${activeDownloads.size} downloads`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[AudioStorage] Failed to start download:`, error);
|
||||
downloadingItems.delete(itemId);
|
||||
permanentDownloadingItems.delete(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a download in progress
|
||||
*/
|
||||
export function cancelDownload(itemId: string): void {
|
||||
for (const [taskId, info] of activeDownloads.entries()) {
|
||||
if (info.itemId === itemId) {
|
||||
try {
|
||||
BackgroundDownloader.cancelDownload(taskId);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
activeDownloads.delete(taskId);
|
||||
downloadingItems.delete(itemId);
|
||||
permanentDownloadingItems.delete(itemId);
|
||||
console.log(`[AudioStorage] Cancelled download: ${itemId}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a stored track
|
||||
*/
|
||||
export async function deleteTrack(itemId: string): Promise<void> {
|
||||
const index = getStorageIndex();
|
||||
const info = index.tracks[itemId];
|
||||
|
||||
if (!info) return;
|
||||
|
||||
try {
|
||||
const file = new File(info.localPath);
|
||||
if (file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[AudioStorage] Failed to delete file:`, error);
|
||||
}
|
||||
|
||||
if (info.permanent) {
|
||||
index.totalPermanentSize -= info.size;
|
||||
} else {
|
||||
index.totalCacheSize -= info.size;
|
||||
}
|
||||
delete index.tracks[itemId];
|
||||
saveStorageIndex();
|
||||
|
||||
console.log(`[AudioStorage] Deleted track: ${itemId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict old cache entries if limits are exceeded
|
||||
*/
|
||||
async function evictCacheIfNeeded(
|
||||
maxTracks: number = DEFAULT_MAX_CACHE_TRACKS,
|
||||
maxSizeBytes: number = configuredMaxCacheSizeBytes,
|
||||
): Promise<void> {
|
||||
const index = getStorageIndex();
|
||||
|
||||
// Get all cache entries sorted by storedAt (oldest first)
|
||||
const cacheEntries = Object.values(index.tracks)
|
||||
.filter((t) => !t.permanent)
|
||||
.sort((a, b) => a.storedAt - b.storedAt);
|
||||
|
||||
// Evict if over track limit or size limit
|
||||
while (
|
||||
cacheEntries.length > maxTracks ||
|
||||
index.totalCacheSize > maxSizeBytes
|
||||
) {
|
||||
const oldest = cacheEntries.shift();
|
||||
if (!oldest) break;
|
||||
|
||||
console.log(
|
||||
`[AudioStorage] Evicting cache entry: ${oldest.itemId} (${(oldest.size / 1024 / 1024).toFixed(1)}MB)`,
|
||||
);
|
||||
|
||||
try {
|
||||
const file = new File(oldest.localPath);
|
||||
if (file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
|
||||
index.totalCacheSize -= oldest.size;
|
||||
delete index.tracks[oldest.itemId];
|
||||
}
|
||||
|
||||
saveStorageIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached tracks (keeps permanent downloads)
|
||||
*/
|
||||
export async function clearCache(): Promise<void> {
|
||||
const index = getStorageIndex();
|
||||
|
||||
const cacheEntries = Object.values(index.tracks).filter((t) => !t.permanent);
|
||||
|
||||
for (const entry of cacheEntries) {
|
||||
try {
|
||||
const file = new File(entry.localPath);
|
||||
if (file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
delete index.tracks[entry.itemId];
|
||||
}
|
||||
|
||||
index.totalCacheSize = 0;
|
||||
saveStorageIndex();
|
||||
|
||||
console.log(`[AudioStorage] Cache cleared`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all permanent downloads (keeps cache)
|
||||
*/
|
||||
export async function clearPermanentDownloads(): Promise<void> {
|
||||
const index = getStorageIndex();
|
||||
|
||||
const permanentEntries = Object.values(index.tracks).filter(
|
||||
(t) => t.permanent,
|
||||
);
|
||||
|
||||
for (const entry of permanentEntries) {
|
||||
try {
|
||||
const fileUri = entry.localPath.startsWith("file://")
|
||||
? entry.localPath
|
||||
: `file://${entry.localPath}`;
|
||||
const file = new File(fileUri);
|
||||
if (file.exists) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
delete index.tracks[entry.itemId];
|
||||
}
|
||||
|
||||
index.totalPermanentSize = 0;
|
||||
saveStorageIndex();
|
||||
|
||||
console.log(`[AudioStorage] Permanent downloads cleared`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
export function getStorageStats(): {
|
||||
cacheCount: number;
|
||||
cacheSize: number;
|
||||
permanentCount: number;
|
||||
permanentSize: number;
|
||||
} {
|
||||
const index = getStorageIndex();
|
||||
const entries = Object.values(index.tracks);
|
||||
|
||||
return {
|
||||
cacheCount: entries.filter((t) => !t.permanent).length,
|
||||
cacheSize: index.totalCacheSize,
|
||||
permanentCount: entries.filter((t) => t.permanent).length,
|
||||
permanentSize: index.totalPermanentSize,
|
||||
};
|
||||
}
|
||||
42
providers/AudioStorage/types.ts
Normal file
42
providers/AudioStorage/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Audio Storage Types
|
||||
*
|
||||
* Shared foundation supporting both:
|
||||
* - Look-ahead cache (auto-managed, ephemeral)
|
||||
* - Future full music downloads (user-initiated, permanent)
|
||||
*/
|
||||
|
||||
export interface StoredTrackInfo {
|
||||
itemId: string;
|
||||
localPath: string;
|
||||
size: number;
|
||||
storedAt: number;
|
||||
permanent: boolean; // true = user download, false = cache
|
||||
}
|
||||
|
||||
export interface AudioStorageIndex {
|
||||
tracks: Record<string, StoredTrackInfo>;
|
||||
totalCacheSize: number;
|
||||
totalPermanentSize: number;
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
permanent: boolean;
|
||||
container?: string; // File extension/format (e.g., "mp3", "flac", "m4a")
|
||||
}
|
||||
|
||||
export interface DownloadCompleteEvent {
|
||||
itemId: string;
|
||||
localPath: string;
|
||||
permanent: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadErrorEvent {
|
||||
itemId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface DownloadProgressEvent {
|
||||
itemId: string;
|
||||
progress: number; // 0-1
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.50.1" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.51.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.50.1"`,
|
||||
}, DeviceId="${deviceId}", Version="0.51.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi, getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -18,9 +21,17 @@ import TrackPlayer, {
|
||||
RepeatMode as TPRepeatMode,
|
||||
type Track,
|
||||
} from "react-native-track-player";
|
||||
import {
|
||||
downloadTrack,
|
||||
getLocalPath,
|
||||
initAudioStorage,
|
||||
isDownloading,
|
||||
setMaxCacheSizeMB,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import native from "@/utils/profiles/native";
|
||||
|
||||
// Storage keys
|
||||
const STORAGE_KEYS = {
|
||||
@@ -33,6 +44,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 +63,9 @@ interface MusicPlayerState {
|
||||
playSessionId: string | null;
|
||||
repeatMode: RepeatMode;
|
||||
shuffleEnabled: boolean;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
trackMediaInfoMap: Record<string, TrackMediaInfo>;
|
||||
}
|
||||
|
||||
interface MusicPlayerContextType extends MusicPlayerState {
|
||||
@@ -68,6 +87,7 @@ interface MusicPlayerContextType extends MusicPlayerState {
|
||||
playNext: (tracks: BaseItemDto | BaseItemDto[]) => void;
|
||||
removeFromQueue: (index: number) => void;
|
||||
moveInQueue: (fromIndex: number, toIndex: number) => void;
|
||||
reorderQueue: (newQueue: BaseItemDto[]) => void;
|
||||
clearQueue: () => void;
|
||||
jumpToIndex: (index: number) => void;
|
||||
|
||||
@@ -82,6 +102,9 @@ interface MusicPlayerContextType extends MusicPlayerState {
|
||||
reportProgress: () => void;
|
||||
onTrackEnd: () => void;
|
||||
syncFromTrackPlayer: () => void;
|
||||
|
||||
// Audio caching
|
||||
triggerLookahead: () => void;
|
||||
}
|
||||
|
||||
const MusicPlayerContext = createContext<MusicPlayerContextType | undefined>(
|
||||
@@ -191,66 +214,33 @@ const shuffleArray = <T,>(array: T[], currentIndex: number): T[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const getAudioStreamUrl = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
itemId: string,
|
||||
): Promise<{ url: string; sessionId: string | null } | null> => {
|
||||
try {
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{ itemId },
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
userId,
|
||||
deviceProfile: native,
|
||||
startTimeTicks: 0,
|
||||
isPlayback: true,
|
||||
autoOpenLiveStream: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sessionId = res.data.PlaySessionId || null;
|
||||
const mediaSource = res.data.MediaSources?.[0];
|
||||
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
return {
|
||||
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
// Direct stream
|
||||
const streamParams = new URLSearchParams({
|
||||
static: "true",
|
||||
container: mediaSource?.Container || "mp3",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
deviceId: api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${api.basePath}/Audio/${itemId}/stream?${streamParams.toString()}`,
|
||||
sessionId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert BaseItemDto to TrackPlayer Track
|
||||
const itemToTrack = (item: BaseItemDto, url: string, api: Api): Track => {
|
||||
const itemToTrack = (
|
||||
item: BaseItemDto,
|
||||
url: string,
|
||||
api: Api,
|
||||
preferLocalAudio = true,
|
||||
): Track => {
|
||||
const albumId = item.AlbumId || item.ParentId;
|
||||
const artworkId = albumId || item.Id;
|
||||
const artwork = artworkId
|
||||
? `${api.basePath}/Items/${artworkId}/Images/Primary?maxHeight=512&maxWidth=512&quality=90`
|
||||
: undefined;
|
||||
|
||||
// Check if track is cached locally (permanent downloads take precedence)
|
||||
// getLocalPath returns full file:// URI if cached, null otherwise
|
||||
const cachedUrl = preferLocalAudio ? getLocalPath(item.Id) : null;
|
||||
const finalUrl = cachedUrl || url;
|
||||
|
||||
if (cachedUrl) {
|
||||
console.log(
|
||||
`[MusicPlayer] Using cached file for ${item.Name}: ${cachedUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.Id || "",
|
||||
url,
|
||||
url: finalUrl,
|
||||
title: item.Name || "Unknown",
|
||||
artist: item.Artists?.join(", ") || item.AlbumArtist || "Unknown Artist",
|
||||
album: item.Album || undefined,
|
||||
@@ -264,6 +254,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const initializedRef = useRef(false);
|
||||
const playerSetupRef = useRef(false);
|
||||
|
||||
@@ -281,17 +272,28 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
playSessionId: null,
|
||||
repeatMode: loadRepeatMode(),
|
||||
shuffleEnabled: loadShuffleEnabled(),
|
||||
mediaSource: null,
|
||||
isTranscoding: false,
|
||||
trackMediaInfoMap: {},
|
||||
});
|
||||
|
||||
const lastReportRef = useRef<number>(0);
|
||||
|
||||
// Setup TrackPlayer
|
||||
// Setup TrackPlayer and AudioStorage
|
||||
useEffect(() => {
|
||||
const setupPlayer = async () => {
|
||||
if (playerSetupRef.current) return;
|
||||
|
||||
try {
|
||||
await TrackPlayer.setupPlayer();
|
||||
// Initialize audio storage for caching
|
||||
await initAudioStorage();
|
||||
|
||||
await TrackPlayer.setupPlayer({
|
||||
minBuffer: 120, // Minimum 2 minutes buffer for network resilience
|
||||
maxBuffer: 240, // Maximum 4 minutes buffer
|
||||
playBuffer: 5, // Start playback after 5 seconds buffered
|
||||
backBuffer: 30, // Keep 30 seconds behind for seeking
|
||||
});
|
||||
await TrackPlayer.updateOptions({
|
||||
capabilities: [
|
||||
Capability.Play,
|
||||
@@ -318,6 +320,13 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
setupPlayer();
|
||||
}, []);
|
||||
|
||||
// Update audio cache size when settings change
|
||||
useEffect(() => {
|
||||
if (settings?.audioMaxCacheSizeMB) {
|
||||
setMaxCacheSizeMB(settings.audioMaxCacheSizeMB);
|
||||
}
|
||||
}, [settings?.audioMaxCacheSizeMB]);
|
||||
|
||||
// Sync repeat mode to TrackPlayer
|
||||
useEffect(() => {
|
||||
const syncRepeatMode = async () => {
|
||||
@@ -471,11 +480,35 @@ 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;
|
||||
|
||||
const preferLocal = settings?.preferLocalAudio ?? true;
|
||||
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const item = queue[i];
|
||||
if (!item.Id) continue;
|
||||
|
||||
// Check for cached/downloaded version
|
||||
const cachedUrl = getLocalPath(item.Id);
|
||||
|
||||
// If preferLocal and we have a local file, use it directly without server request
|
||||
if (preferLocal && cachedUrl) {
|
||||
tracks.push(itemToTrack(item, cachedUrl, api, true));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to get stream URL from server
|
||||
const result = await getAudioStreamUrl(api, user.Id, item.Id);
|
||||
|
||||
if (result) {
|
||||
tracks.push(itemToTrack(item, result.url, api));
|
||||
tracks.push(itemToTrack(item, result.url, api, preferLocal));
|
||||
// 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,17 @@ 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;
|
||||
}
|
||||
} else if (cachedUrl) {
|
||||
// Fallback to cached version if server is unreachable
|
||||
console.log(
|
||||
`[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`,
|
||||
);
|
||||
tracks.push(itemToTrack(item, cachedUrl, api, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,10 +559,14 @@ 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);
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
console.error("[MusicPlayer] Error loading queue:", error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
@@ -647,8 +695,11 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
state.currentTrack.Id!,
|
||||
);
|
||||
if (result) {
|
||||
const preferLocal = settings?.preferLocalAudio ?? true;
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(itemToTrack(state.currentTrack, result.url, api));
|
||||
await TrackPlayer.add(
|
||||
itemToTrack(state.currentTrack, result.url, api, preferLocal),
|
||||
);
|
||||
await TrackPlayer.seekTo(state.progress);
|
||||
await TrackPlayer.play();
|
||||
setState((prev) => ({
|
||||
@@ -662,7 +713,14 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
await TrackPlayer.play();
|
||||
setState((prev) => ({ ...prev, isPlaying: true }));
|
||||
}
|
||||
}, [api, user?.Id, state.streamUrl, state.currentTrack, state.progress]);
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
state.streamUrl,
|
||||
state.currentTrack,
|
||||
state.progress,
|
||||
settings?.preferLocalAudio,
|
||||
]);
|
||||
|
||||
const togglePlayPause = useCallback(async () => {
|
||||
if (state.isPlaying) {
|
||||
@@ -686,11 +744,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 +766,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 +812,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 +835,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 +897,9 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
playSessionId: null,
|
||||
repeatMode: state.repeatMode,
|
||||
shuffleEnabled: state.shuffleEnabled,
|
||||
mediaSource: null,
|
||||
isTranscoding: false,
|
||||
trackMediaInfoMap: {},
|
||||
});
|
||||
}, [
|
||||
state.currentTrack,
|
||||
@@ -823,13 +916,22 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
if (!api || !user?.Id) return;
|
||||
|
||||
const tracksArray = Array.isArray(tracks) ? tracks : [tracks];
|
||||
const preferLocal = settings?.preferLocalAudio ?? true;
|
||||
|
||||
// Add to TrackPlayer queue
|
||||
for (const item of tracksArray) {
|
||||
if (!item.Id) continue;
|
||||
const cachedUrl = getLocalPath(item.Id);
|
||||
const result = await getAudioStreamUrl(api, user.Id, item.Id);
|
||||
if (result) {
|
||||
await TrackPlayer.add(itemToTrack(item, result.url, api));
|
||||
await TrackPlayer.add(
|
||||
itemToTrack(item, result.url, api, preferLocal),
|
||||
);
|
||||
} else if (cachedUrl) {
|
||||
console.log(
|
||||
`[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`,
|
||||
);
|
||||
await TrackPlayer.add(itemToTrack(item, cachedUrl, api, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,7 +941,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
originalQueue: [...prev.originalQueue, ...tracksArray],
|
||||
}));
|
||||
},
|
||||
[api, user?.Id],
|
||||
[api, user?.Id, settings?.preferLocalAudio],
|
||||
);
|
||||
|
||||
const playNext = useCallback(
|
||||
@@ -849,15 +951,25 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
const tracksArray = Array.isArray(tracks) ? tracks : [tracks];
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
const insertIndex = (currentIndex ?? -1) + 1;
|
||||
const preferLocal = settings?.preferLocalAudio ?? true;
|
||||
|
||||
// Add to TrackPlayer queue after current track
|
||||
for (let i = tracksArray.length - 1; i >= 0; i--) {
|
||||
const item = tracksArray[i];
|
||||
if (!item.Id) continue;
|
||||
const cachedUrl = getLocalPath(item.Id);
|
||||
const result = await getAudioStreamUrl(api, user.Id, item.Id);
|
||||
if (result) {
|
||||
await TrackPlayer.add(
|
||||
itemToTrack(item, result.url, api),
|
||||
itemToTrack(item, result.url, api, preferLocal),
|
||||
insertIndex,
|
||||
);
|
||||
} else if (cachedUrl) {
|
||||
console.log(
|
||||
`[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`,
|
||||
);
|
||||
await TrackPlayer.add(
|
||||
itemToTrack(item, cachedUrl, api, true),
|
||||
insertIndex,
|
||||
);
|
||||
}
|
||||
@@ -878,7 +990,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
};
|
||||
});
|
||||
},
|
||||
[api, user?.Id],
|
||||
[api, user?.Id, settings?.preferLocalAudio],
|
||||
);
|
||||
|
||||
const removeFromQueue = useCallback(async (index: number) => {
|
||||
@@ -952,6 +1064,63 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
// Reorder queue with a new array (used by drag-to-reorder UI)
|
||||
const reorderQueue = useCallback(
|
||||
async (newQueue: BaseItemDto[]) => {
|
||||
// Find where the current track ended up in the new order
|
||||
const currentTrackId = state.currentTrack?.Id;
|
||||
const newIndex = currentTrackId
|
||||
? newQueue.findIndex((t) => t.Id === currentTrackId)
|
||||
: 0;
|
||||
|
||||
// Build the reordering operations for TrackPlayer
|
||||
// We need to match TrackPlayer's queue to the new order
|
||||
const tpQueue = await TrackPlayer.getQueue();
|
||||
|
||||
// Create a map of trackId -> current TrackPlayer index
|
||||
const currentPositions = new Map<string, number>();
|
||||
tpQueue.forEach((track, idx) => {
|
||||
currentPositions.set(track.id, idx);
|
||||
});
|
||||
|
||||
// Move tracks one by one to match the new order
|
||||
// Work backwards to avoid index shifting issues
|
||||
for (let targetIdx = newQueue.length - 1; targetIdx >= 0; targetIdx--) {
|
||||
const trackId = newQueue[targetIdx].Id;
|
||||
if (!trackId) continue;
|
||||
|
||||
const currentIdx = currentPositions.get(trackId);
|
||||
if (currentIdx !== undefined && currentIdx !== targetIdx) {
|
||||
await TrackPlayer.move(currentIdx, targetIdx);
|
||||
|
||||
// Update positions map after move
|
||||
currentPositions.forEach((pos, id) => {
|
||||
if (currentIdx < targetIdx) {
|
||||
// Moving down: items between shift up
|
||||
if (pos > currentIdx && pos <= targetIdx) {
|
||||
currentPositions.set(id, pos - 1);
|
||||
}
|
||||
} else {
|
||||
// Moving up: items between shift down
|
||||
if (pos >= targetIdx && pos < currentIdx) {
|
||||
currentPositions.set(id, pos + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
currentPositions.set(trackId, targetIdx);
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
queue: newQueue,
|
||||
queueIndex: newIndex >= 0 ? newIndex : 0,
|
||||
currentTrack: newIndex >= 0 ? newQueue[newIndex] : prev.currentTrack,
|
||||
}));
|
||||
},
|
||||
[state.currentTrack?.Id],
|
||||
);
|
||||
|
||||
const clearQueue = useCallback(async () => {
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
@@ -999,11 +1168,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,
|
||||
@@ -1082,6 +1259,52 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
// For other modes, TrackPlayer handles it via repeat mode setting
|
||||
}, [state.repeatMode]);
|
||||
|
||||
// Look-ahead cache: pre-cache upcoming N tracks (excludes current track to avoid bandwidth competition)
|
||||
const triggerLookahead = useCallback(async () => {
|
||||
// Check if caching is enabled in settings
|
||||
if (settings?.audioLookaheadEnabled === false) return;
|
||||
if (!api || !user?.Id) return;
|
||||
|
||||
try {
|
||||
const tpQueue = await TrackPlayer.getQueue();
|
||||
const currentIdx = await TrackPlayer.getActiveTrackIndex();
|
||||
if (currentIdx === undefined || currentIdx < 0) return;
|
||||
|
||||
// Cache next N tracks (from settings, default 1) - excludes current to avoid bandwidth competition
|
||||
const lookaheadCount = settings?.audioLookaheadCount ?? 1;
|
||||
const tracksToCache = tpQueue.slice(
|
||||
currentIdx + 1,
|
||||
currentIdx + 1 + lookaheadCount,
|
||||
);
|
||||
|
||||
for (const track of tracksToCache) {
|
||||
const itemId = track.id;
|
||||
// Skip if already stored locally or currently downloading
|
||||
if (!itemId || getLocalPath(itemId) || isDownloading(itemId)) continue;
|
||||
|
||||
// Get stream URL for this track
|
||||
const result = await getAudioStreamUrl(api, user.Id, itemId);
|
||||
|
||||
// Only cache direct streams (not transcoding - can't cache dynamic content)
|
||||
if (result?.url && !result.isTranscoding) {
|
||||
downloadTrack(itemId, result.url, {
|
||||
permanent: false,
|
||||
container: result.mediaSource?.Container || undefined,
|
||||
}).catch(() => {
|
||||
// Silent fail - caching is best-effort
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent fail - look-ahead caching is best-effort
|
||||
}
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
settings?.audioLookaheadEnabled,
|
||||
settings?.audioLookaheadCount,
|
||||
]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
@@ -1100,6 +1323,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
playNext,
|
||||
removeFromQueue,
|
||||
moveInQueue,
|
||||
reorderQueue,
|
||||
clearQueue,
|
||||
jumpToIndex,
|
||||
setRepeatMode,
|
||||
@@ -1110,6 +1334,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
reportProgress: reportPlaybackProgress,
|
||||
onTrackEnd,
|
||||
syncFromTrackPlayer,
|
||||
triggerLookahead,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
@@ -1128,6 +1353,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
playNext,
|
||||
removeFromQueue,
|
||||
moveInQueue,
|
||||
reorderQueue,
|
||||
clearQueue,
|
||||
jumpToIndex,
|
||||
setRepeatMode,
|
||||
@@ -1138,6 +1364,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
reportPlaybackProgress,
|
||||
onTrackEnd,
|
||||
syncFromTrackPlayer,
|
||||
triggerLookahead,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ const NetworkStatusContext = createContext<NetworkStatusContextType | null>(
|
||||
async function checkApiReachable(basePath?: string): Promise<boolean> {
|
||||
if (!basePath) return false;
|
||||
try {
|
||||
const response = await fetch(basePath, { method: "HEAD" });
|
||||
const url = basePath.endsWith("/") ? basePath : `${basePath}/`;
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -225,6 +225,17 @@
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"jellyseerr": {
|
||||
@@ -277,6 +288,7 @@
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
@@ -297,7 +309,16 @@
|
||||
"app_usage": "App {{usedSpace}}%",
|
||||
"device_usage": "Device {{availableSpace}}%",
|
||||
"size_used": "{{used}} of {{total}} Used",
|
||||
"delete_all_downloaded_files": "Delete All Downloaded Files"
|
||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
@@ -390,7 +411,9 @@
|
||||
"subtitle": "Subtitle",
|
||||
"play": "Play",
|
||||
"none": "None",
|
||||
"track": "Track"
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
@@ -603,7 +626,8 @@
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists"
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
@@ -622,7 +646,41 @@
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found"
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
|
||||
@@ -202,6 +202,13 @@ export type Settings = {
|
||||
videoPlayerIOS: VideoPlayerIOS;
|
||||
// Appearance
|
||||
hideRemoteSessionButton: boolean;
|
||||
hideWatchlistsTab: boolean;
|
||||
// Audio look-ahead caching
|
||||
audioLookaheadEnabled: boolean;
|
||||
audioLookaheadCount: number;
|
||||
audioMaxCacheSizeMB: number;
|
||||
// Music playback
|
||||
preferLocalAudio: boolean;
|
||||
};
|
||||
|
||||
export interface Lockable<T> {
|
||||
@@ -284,6 +291,13 @@ export const defaultValues: Settings = {
|
||||
videoPlayerIOS: VideoPlayerIOS.VLC,
|
||||
// Appearance
|
||||
hideRemoteSessionButton: false,
|
||||
hideWatchlistsTab: false,
|
||||
// Audio look-ahead caching defaults
|
||||
audioLookaheadEnabled: true,
|
||||
audioLookaheadCount: 1,
|
||||
audioMaxCacheSizeMB: 500,
|
||||
// Music playback
|
||||
preferLocalAudio: true,
|
||||
};
|
||||
|
||||
const loadSettings = (): Partial<Settings> => {
|
||||
|
||||
68
utils/jellyfin/audio/getAudioStreamUrl.ts
Normal file
68
utils/jellyfin/audio/getAudioStreamUrl.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import native from "@/utils/profiles/native";
|
||||
|
||||
export interface AudioStreamResult {
|
||||
url: string;
|
||||
sessionId: string | null;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audio stream URL for a Jellyfin item
|
||||
* Handles both direct streaming and transcoding scenarios
|
||||
*/
|
||||
export const getAudioStreamUrl = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
itemId: string,
|
||||
): Promise<AudioStreamResult | null> => {
|
||||
try {
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{ itemId },
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
userId,
|
||||
deviceProfile: native,
|
||||
startTimeTicks: 0,
|
||||
isPlayback: true,
|
||||
autoOpenLiveStream: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sessionId = res.data.PlaySessionId || null;
|
||||
const mediaSource = res.data.MediaSources?.[0] || null;
|
||||
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
return {
|
||||
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
|
||||
sessionId,
|
||||
mediaSource,
|
||||
isTranscoding: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Direct stream
|
||||
const streamParams = new URLSearchParams({
|
||||
static: "true",
|
||||
container: mediaSource?.Container || "mp3",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
deviceId: api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${api.basePath}/Audio/${itemId}/stream?${streamParams.toString()}`,
|
||||
sessionId,
|
||||
mediaSource,
|
||||
isTranscoding: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -3,9 +3,48 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
|
||||
/**
|
||||
* Audio profiles for react-native-track-player based on platform capabilities.
|
||||
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
||||
*/
|
||||
const getAudioDirectPlayProfile = () => {
|
||||
if (Platform.OS === "ios") {
|
||||
// iOS AVPlayer supported formats
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,flac,alac,wav,aiff,caf",
|
||||
AudioCodec: "mp3,aac,alac,flac,opus,pcm",
|
||||
};
|
||||
}
|
||||
|
||||
// Android ExoPlayer supported formats
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
||||
AudioCodec: "mp3,aac,flac,vorbis,opus,pcm",
|
||||
};
|
||||
};
|
||||
|
||||
const getAudioCodecProfile = () => {
|
||||
if (Platform.OS === "ios") {
|
||||
// iOS AVPlayer codec constraints
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,pcm",
|
||||
};
|
||||
}
|
||||
|
||||
// Android ExoPlayer codec constraints
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,vorbis,opus,pcm",
|
||||
};
|
||||
};
|
||||
|
||||
export const generateDeviceProfile = () => {
|
||||
/**
|
||||
* Device profile for Native video player
|
||||
@@ -37,10 +76,7 @@ export const generateDeviceProfile = () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
getAudioCodecProfile(),
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
@@ -50,12 +86,7 @@ export const generateDeviceProfile = () => {
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts,truehd",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
getAudioDirectPlayProfile(),
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user