mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
fix: music downloading not playing + queue drag and drop
Some checks failed
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Some checks failed
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
@@ -5,13 +7,68 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
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() {
|
export default function MusicSettingsPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
@@ -67,6 +124,51 @@ export default function MusicSettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</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>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -139,7 +139,10 @@ export default function AlbumDetailScreen() {
|
|||||||
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
|
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
|
||||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
if (result?.url && !result.isTranscoding) {
|
if (result?.url && !result.isTranscoding) {
|
||||||
await downloadTrack(track.Id, result.url, { permanent: true });
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -150,7 +153,8 @@ export default function AlbumDetailScreen() {
|
|||||||
|
|
||||||
const isLoading = loadingAlbum || loadingTracks;
|
const isLoading = loadingAlbum || loadingTracks;
|
||||||
|
|
||||||
if (isLoading) {
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !album) {
|
||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ export default function ArtistDetailScreen() {
|
|||||||
|
|
||||||
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
||||||
|
|
||||||
if (isLoading) {
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !artist) {
|
||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
|
|||||||
@@ -146,7 +146,10 @@ export default function PlaylistDetailScreen() {
|
|||||||
if (!track.Id || getLocalPath(track.Id)) continue;
|
if (!track.Id || getLocalPath(track.Id)) continue;
|
||||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
if (result?.url && !result.isTranscoding) {
|
if (result?.url && !result.isTranscoding) {
|
||||||
await downloadTrack(track.Id, result.url, { permanent: true });
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -157,7 +160,8 @@ export default function PlaylistDetailScreen() {
|
|||||||
|
|
||||||
const isLoading = loadingPlaylist || loadingTracks;
|
const isLoading = loadingPlaylist || loadingTracks;
|
||||||
|
|
||||||
if (isLoading) {
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !playlist) {
|
||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
<Loader />
|
<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 (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
|||||||
@@ -124,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 (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -132,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 (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
|||||||
@@ -232,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 (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -240,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 =
|
const msg =
|
||||||
(latestError as Error | undefined)?.message ||
|
(latestError as Error | undefined)?.message ||
|
||||||
(recentlyPlayedError as Error | undefined)?.message ||
|
(recentlyPlayedError as Error | undefined)?.message ||
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
FlatList,
|
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import DraggableFlatList, {
|
||||||
|
type RenderItemParams,
|
||||||
|
ScaleDecorator,
|
||||||
|
} from "react-native-draggable-flatlist";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
@@ -73,6 +76,7 @@ export default function NowPlayingScreen() {
|
|||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
jumpToIndex,
|
jumpToIndex,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
|
reorderQueue,
|
||||||
stop,
|
stop,
|
||||||
} = useMusicPlayer();
|
} = useMusicPlayer();
|
||||||
|
|
||||||
@@ -244,6 +248,7 @@ export default function NowPlayingScreen() {
|
|||||||
queueIndex={queueIndex}
|
queueIndex={queueIndex}
|
||||||
onJumpToIndex={jumpToIndex}
|
onJumpToIndex={jumpToIndex}
|
||||||
onRemoveFromQueue={removeFromQueue}
|
onRemoveFromQueue={removeFromQueue}
|
||||||
|
onReorderQueue={reorderQueue}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -490,6 +495,7 @@ interface QueueViewProps {
|
|||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
onJumpToIndex: (index: number) => void;
|
onJumpToIndex: (index: number) => void;
|
||||||
onRemoveFromQueue: (index: number) => void;
|
onRemoveFromQueue: (index: number) => void;
|
||||||
|
onReorderQueue: (newQueue: BaseItemDto[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QueueView: React.FC<QueueViewProps> = ({
|
const QueueView: React.FC<QueueViewProps> = ({
|
||||||
@@ -498,9 +504,11 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
queueIndex,
|
queueIndex,
|
||||||
onJumpToIndex,
|
onJumpToIndex,
|
||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
|
onReorderQueue,
|
||||||
}) => {
|
}) => {
|
||||||
const renderQueueItem = useCallback(
|
const renderQueueItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
|
const index = getIndex() ?? 0;
|
||||||
const isCurrentTrack = index === queueIndex;
|
const isCurrentTrack = index === queueIndex;
|
||||||
const isPast = index < queueIndex;
|
const isPast = index < queueIndex;
|
||||||
|
|
||||||
@@ -512,80 +520,102 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<ScaleDecorator>
|
||||||
onPress={() => onJumpToIndex(index)}
|
<TouchableOpacity
|
||||||
className={`flex-row items-center px-4 py-3 ${isCurrentTrack ? "bg-purple-900/30" : ""}`}
|
onPress={() => onJumpToIndex(index)}
|
||||||
style={{ opacity: isPast ? 0.5 : 1 }}
|
onLongPress={drag}
|
||||||
>
|
disabled={isActive}
|
||||||
{/* Track number / Now playing indicator */}
|
className='flex-row items-center px-4 py-3'
|
||||||
<View className='w-8 items-center'>
|
style={{
|
||||||
{isCurrentTrack ? (
|
opacity: isPast && !isActive ? 0.5 : 1,
|
||||||
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
backgroundColor: isActive
|
||||||
) : (
|
? "#2a2a2a"
|
||||||
<Text className='text-neutral-500 text-sm'>{index + 1}</Text>
|
: isCurrentTrack
|
||||||
)}
|
? "rgba(147, 52, 233, 0.3)"
|
||||||
</View>
|
: "#121212",
|
||||||
|
}}
|
||||||
{/* Album art */}
|
>
|
||||||
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
|
{/* Drag handle */}
|
||||||
{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 && (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => onRemoveFromQueue(index)}
|
onPressIn={drag}
|
||||||
|
disabled={isActive}
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
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>
|
||||||
)}
|
|
||||||
</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],
|
[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);
|
const history = queue.slice(0, queueIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<DraggableFlatList
|
||||||
data={queue}
|
data={queue}
|
||||||
keyExtractor={(item, index) => `${item.Id}-${index}`}
|
keyExtractor={(item, index) => `${item.Id}-${index}`}
|
||||||
renderItem={renderQueueItem}
|
renderItem={renderQueueItem}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
initialScrollIndex={queueIndex > 2 ? queueIndex - 2 : 0}
|
|
||||||
getItemLayout={(_, index) => ({
|
|
||||||
length: 72,
|
|
||||||
offset: 72 * index,
|
|
||||||
index,
|
|
||||||
})}
|
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View className='px-4 py-2'>
|
<View className='px-4 py-2'>
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -61,6 +61,7 @@
|
|||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^15.0.0",
|
"react-native-device-info": "^15.0.0",
|
||||||
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-glass-effect-view": "^1.0.0",
|
"react-native-glass-effect-view": "^1.0.0",
|
||||||
@@ -1637,6 +1638,8 @@
|
|||||||
|
|
||||||
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
|
"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-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-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=="],
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const MusicPlaybackEngine: React.FC = () => {
|
|||||||
currentIndex,
|
currentIndex,
|
||||||
);
|
);
|
||||||
await TrackPlayer.skip(currentIndex);
|
await TrackPlayer.skip(currentIndex);
|
||||||
|
await TrackPlayer.play();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import {
|
import {
|
||||||
|
audioStorageEvents,
|
||||||
downloadTrack,
|
downloadTrack,
|
||||||
isCached,
|
isCached,
|
||||||
isPermanentDownloading,
|
isPermanentDownloading,
|
||||||
@@ -62,6 +63,22 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
|
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
|
// Use a placeholder item for useFavorite when track is null
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(
|
const { isFavorite, toggleFavorite } = useFavorite(
|
||||||
@@ -70,15 +87,18 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
|||||||
|
|
||||||
const snapPoints = useMemo(() => ["65%"], []);
|
const snapPoints = useMemo(() => ["65%"], []);
|
||||||
|
|
||||||
// Check download status
|
// Check download status (storageUpdateCounter triggers re-evaluation when download completes)
|
||||||
const isAlreadyDownloaded = useMemo(
|
const isAlreadyDownloaded = useMemo(
|
||||||
() => isPermanentlyDownloaded(track?.Id),
|
() => isPermanentlyDownloaded(track?.Id),
|
||||||
[track?.Id],
|
[track?.Id, storageUpdateCounter],
|
||||||
|
);
|
||||||
|
const isOnlyCached = useMemo(
|
||||||
|
() => isCached(track?.Id),
|
||||||
|
[track?.Id, storageUpdateCounter],
|
||||||
);
|
);
|
||||||
const isOnlyCached = useMemo(() => isCached(track?.Id), [track?.Id]);
|
|
||||||
const isCurrentlyDownloading = useMemo(
|
const isCurrentlyDownloading = useMemo(
|
||||||
() => isPermanentDownloading(track?.Id),
|
() => isPermanentDownloading(track?.Id),
|
||||||
[track?.Id],
|
[track?.Id, storageUpdateCounter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const imageUrl = useMemo(() => {
|
const imageUrl = useMemo(() => {
|
||||||
@@ -150,7 +170,10 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
if (result?.url && !result.isTranscoding) {
|
if (result?.url && !result.isTranscoding) {
|
||||||
await downloadTrack(track.Id, result.url, { permanent: true });
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silent fail
|
// Silent fail
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^15.0.0",
|
"react-native-device-info": "^15.0.0",
|
||||||
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-glass-effect-view": "^1.0.0",
|
"react-native-glass-effect-view": "^1.0.0",
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ const AUDIO_PERMANENT_DIR = "streamyfin-audio";
|
|||||||
|
|
||||||
// Default limits
|
// Default limits
|
||||||
const DEFAULT_MAX_CACHE_TRACKS = 10;
|
const DEFAULT_MAX_CACHE_TRACKS = 10;
|
||||||
const DEFAULT_MAX_CACHE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB
|
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
|
// Event emitter for notifying about download completion
|
||||||
class AudioStorageEventEmitter extends EventEmitter<{
|
class AudioStorageEventEmitter extends EventEmitter<{
|
||||||
@@ -130,6 +133,17 @@ async function ensureDirectories(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Initialize audio storage - call this on app startup
|
||||||
*/
|
*/
|
||||||
@@ -447,9 +461,11 @@ export async function downloadTrack(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use .m4a extension - compatible with iOS/Android and most audio formats
|
// Use the actual container format as extension, fallback to m4a
|
||||||
const filename = `${itemId}.m4a`;
|
const extension = options.container?.toLowerCase() || "m4a";
|
||||||
const destinationPath = `${targetDir.uri}/${filename}`.replace("file://", "");
|
const filename = `${itemId}.${extension}`;
|
||||||
|
const destinationPath =
|
||||||
|
`${targetDir.uri.replace(/\/$/, "")}/${filename}`.replace("file://", "");
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[AudioStorage] Starting download: ${itemId} (permanent=${permanent})`,
|
`[AudioStorage] Starting download: ${itemId} (permanent=${permanent})`,
|
||||||
@@ -529,7 +545,7 @@ export async function deleteTrack(itemId: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
async function evictCacheIfNeeded(
|
async function evictCacheIfNeeded(
|
||||||
maxTracks: number = DEFAULT_MAX_CACHE_TRACKS,
|
maxTracks: number = DEFAULT_MAX_CACHE_TRACKS,
|
||||||
maxSizeBytes: number = DEFAULT_MAX_CACHE_SIZE_BYTES,
|
maxSizeBytes: number = configuredMaxCacheSizeBytes,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const index = getStorageIndex();
|
const index = getStorageIndex();
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface AudioStorageIndex {
|
|||||||
|
|
||||||
export interface DownloadOptions {
|
export interface DownloadOptions {
|
||||||
permanent: boolean;
|
permanent: boolean;
|
||||||
|
container?: string; // File extension/format (e.g., "mp3", "flac", "m4a")
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadCompleteEvent {
|
export interface DownloadCompleteEvent {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
getLocalPath,
|
getLocalPath,
|
||||||
initAudioStorage,
|
initAudioStorage,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
|
setMaxCacheSizeMB,
|
||||||
} from "@/providers/AudioStorage";
|
} from "@/providers/AudioStorage";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { settingsAtom } from "@/utils/atoms/settings";
|
import { settingsAtom } from "@/utils/atoms/settings";
|
||||||
@@ -86,6 +87,7 @@ interface MusicPlayerContextType extends MusicPlayerState {
|
|||||||
playNext: (tracks: BaseItemDto | BaseItemDto[]) => void;
|
playNext: (tracks: BaseItemDto | BaseItemDto[]) => void;
|
||||||
removeFromQueue: (index: number) => void;
|
removeFromQueue: (index: number) => void;
|
||||||
moveInQueue: (fromIndex: number, toIndex: number) => void;
|
moveInQueue: (fromIndex: number, toIndex: number) => void;
|
||||||
|
reorderQueue: (newQueue: BaseItemDto[]) => void;
|
||||||
clearQueue: () => void;
|
clearQueue: () => void;
|
||||||
jumpToIndex: (index: number) => void;
|
jumpToIndex: (index: number) => void;
|
||||||
|
|
||||||
@@ -286,7 +288,12 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
// Initialize audio storage for caching
|
// Initialize audio storage for caching
|
||||||
await initAudioStorage();
|
await initAudioStorage();
|
||||||
|
|
||||||
await TrackPlayer.setupPlayer();
|
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({
|
await TrackPlayer.updateOptions({
|
||||||
capabilities: [
|
capabilities: [
|
||||||
Capability.Play,
|
Capability.Play,
|
||||||
@@ -313,6 +320,13 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
setupPlayer();
|
setupPlayer();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Update audio cache size when settings change
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.audioMaxCacheSizeMB) {
|
||||||
|
setMaxCacheSizeMB(settings.audioMaxCacheSizeMB);
|
||||||
|
}
|
||||||
|
}, [settings?.audioMaxCacheSizeMB]);
|
||||||
|
|
||||||
// Sync repeat mode to TrackPlayer
|
// Sync repeat mode to TrackPlayer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncRepeatMode = async () => {
|
const syncRepeatMode = async () => {
|
||||||
@@ -476,9 +490,15 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
const item = queue[i];
|
const item = queue[i];
|
||||||
if (!item.Id) continue;
|
if (!item.Id) continue;
|
||||||
|
|
||||||
// First check for cached version (for offline fallback)
|
// Check for cached/downloaded version
|
||||||
const cachedUrl = getLocalPath(item.Id);
|
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
|
// Try to get stream URL from server
|
||||||
const result = await getAudioStreamUrl(api, user.Id, item.Id);
|
const result = await getAudioStreamUrl(api, user.Id, item.Id);
|
||||||
|
|
||||||
@@ -545,7 +565,8 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
reportPlaybackStart(currentTrack, state.playSessionId);
|
reportPlaybackStart(currentTrack, state.playSessionId);
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
|
console.error("[MusicPlayer] Error loading queue:", error);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -1043,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 clearQueue = useCallback(async () => {
|
||||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||||
const queue = await TrackPlayer.getQueue();
|
const queue = await TrackPlayer.getQueue();
|
||||||
@@ -1181,7 +1259,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
// For other modes, TrackPlayer handles it via repeat mode setting
|
// For other modes, TrackPlayer handles it via repeat mode setting
|
||||||
}, [state.repeatMode]);
|
}, [state.repeatMode]);
|
||||||
|
|
||||||
// Cache current track + look-ahead: pre-cache current and next N tracks
|
// Look-ahead cache: pre-cache upcoming N tracks (excludes current track to avoid bandwidth competition)
|
||||||
const triggerLookahead = useCallback(async () => {
|
const triggerLookahead = useCallback(async () => {
|
||||||
// Check if caching is enabled in settings
|
// Check if caching is enabled in settings
|
||||||
if (settings?.audioLookaheadEnabled === false) return;
|
if (settings?.audioLookaheadEnabled === false) return;
|
||||||
@@ -1192,10 +1270,10 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
const currentIdx = await TrackPlayer.getActiveTrackIndex();
|
const currentIdx = await TrackPlayer.getActiveTrackIndex();
|
||||||
if (currentIdx === undefined || currentIdx < 0) return;
|
if (currentIdx === undefined || currentIdx < 0) return;
|
||||||
|
|
||||||
// Cache current track + next N tracks (from settings, default 2)
|
// Cache next N tracks (from settings, default 1) - excludes current to avoid bandwidth competition
|
||||||
const lookaheadCount = settings?.audioLookaheadCount ?? 2;
|
const lookaheadCount = settings?.audioLookaheadCount ?? 1;
|
||||||
const tracksToCache = tpQueue.slice(
|
const tracksToCache = tpQueue.slice(
|
||||||
currentIdx,
|
currentIdx + 1,
|
||||||
currentIdx + 1 + lookaheadCount,
|
currentIdx + 1 + lookaheadCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1209,7 +1287,10 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
|
|
||||||
// Only cache direct streams (not transcoding - can't cache dynamic content)
|
// Only cache direct streams (not transcoding - can't cache dynamic content)
|
||||||
if (result?.url && !result.isTranscoding) {
|
if (result?.url && !result.isTranscoding) {
|
||||||
downloadTrack(itemId, result.url, { permanent: false }).catch(() => {
|
downloadTrack(itemId, result.url, {
|
||||||
|
permanent: false,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
}).catch(() => {
|
||||||
// Silent fail - caching is best-effort
|
// Silent fail - caching is best-effort
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1242,6 +1323,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
playNext,
|
playNext,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
moveInQueue,
|
moveInQueue,
|
||||||
|
reorderQueue,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
jumpToIndex,
|
jumpToIndex,
|
||||||
setRepeatMode,
|
setRepeatMode,
|
||||||
@@ -1271,6 +1353,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
playNext,
|
playNext,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
moveInQueue,
|
moveInQueue,
|
||||||
|
reorderQueue,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
jumpToIndex,
|
jumpToIndex,
|
||||||
setRepeatMode,
|
setRepeatMode,
|
||||||
|
|||||||
@@ -232,7 +232,9 @@
|
|||||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||||
"caching_title": "Caching",
|
"caching_title": "Caching",
|
||||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||||
"lookahead_enabled": "Enable Look-Ahead Caching"
|
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||||
|
"lookahead_count": "Tracks to Pre-cache",
|
||||||
|
"max_cache_size": "Max Cache Size"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
|
|||||||
@@ -294,8 +294,8 @@ export const defaultValues: Settings = {
|
|||||||
hideWatchlistsTab: false,
|
hideWatchlistsTab: false,
|
||||||
// Audio look-ahead caching defaults
|
// Audio look-ahead caching defaults
|
||||||
audioLookaheadEnabled: true,
|
audioLookaheadEnabled: true,
|
||||||
audioLookaheadCount: 2,
|
audioLookaheadCount: 1,
|
||||||
audioMaxCacheSizeMB: 100,
|
audioMaxCacheSizeMB: 500,
|
||||||
// Music playback
|
// Music playback
|
||||||
preferLocalAudio: true,
|
preferLocalAudio: true,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user