refactor: improve music design

This commit is contained in:
Fredrik Burmester
2026-01-05 21:28:00 +01:00
parent 090ed98233
commit a26980ddab
16 changed files with 497 additions and 144 deletions

View File

@@ -227,7 +227,7 @@ export default function ArtistDetailScreen() {
{section.type === "albums" ? (
<HorizontalScroll
data={section.data}
height={200}
height={178}
keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />}
/>

View File

@@ -8,12 +8,7 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
TouchableOpacity,
View,
} from "react-native";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
@@ -30,8 +25,7 @@ import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
const ARTWORK_SIZE = 120;
export default function PlaylistDetailScreen() {
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();

View File

@@ -13,8 +13,7 @@ import { useTranslation } from "react-i18next";
const { Navigator } = createMaterialTopTabNavigator();
const TAB_LABEL_FONT_SIZE = 13;
const TAB_ITEM_HORIZONTAL_PADDING = 18;
const TAB_ITEM_MIN_WIDTH = 110;
const TAB_ITEM_HORIZONTAL_PADDING = 12;
export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions,
@@ -48,7 +47,6 @@ const Layout = () => {
},
tabBarItemStyle: {
width: "auto",
minWidth: TAB_ITEM_MIN_WIDTH,
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
},
tabBarStyle: { backgroundColor: "black" },

View File

@@ -6,11 +6,11 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, RefreshControl, View } from "react-native";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40;
@@ -65,13 +65,6 @@ export default function AlbumsScreen() {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const numColumns = 2;
const screenWidth = Dimensions.get("window").width;
const gap = 12;
const padding = 16;
const itemWidth =
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
@@ -98,11 +91,10 @@ export default function AlbumsScreen() {
<View className='flex-1 bg-black'>
<FlashList
data={albums}
numColumns={numColumns}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
paddingHorizontal: padding,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
@@ -113,17 +105,7 @@ export default function AlbumsScreen() {
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item, index }) => (
<View
style={{
width: itemWidth,
marginRight: index % numColumns === 0 ? gap : 0,
marginBottom: gap,
}}
>
<MusicAlbumCard album={item} width={itemWidth} />
</View>
)}
renderItem={({ item }) => <MusicAlbumRowCard album={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (

View File

@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, RefreshControl, View } from "react-native";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
@@ -71,13 +71,6 @@ export default function ArtistsScreen() {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const numColumns = 3;
const screenWidth = Dimensions.get("window").width;
const gap = 12;
const padding = 16;
const itemWidth =
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
@@ -135,11 +128,10 @@ export default function ArtistsScreen() {
<View className='flex-1 bg-black'>
<FlashList
data={artists}
numColumns={numColumns}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
paddingHorizontal: padding,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
@@ -150,17 +142,7 @@ export default function ArtistsScreen() {
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item, index }) => (
<View
style={{
width: itemWidth,
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
marginBottom: gap,
}}
>
<MusicArtistCard artist={item} size={itemWidth} />
</View>
)}
renderItem={({ item }) => <MusicArtistCard artist={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (

View File

@@ -7,17 +7,17 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dimensions,
RefreshControl,
TouchableOpacity,
View,
} from "react-native";
import { 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 {
type PlaylistSortOption,
type PlaylistSortOrder,
PlaylistSortSheet,
} from "@/components/music/PlaylistSortSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40;
@@ -36,9 +36,20 @@ export default function PlaylistsScreen() {
const { t } = useTranslation();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const [sortBy, setSortBy] = useState<PlaylistSortOption>("SortName");
const [sortOrder, setSortOrder] = useState<PlaylistSortOrder>("Ascending");
const isReady = Boolean(api && user?.Id && libraryId);
const handleSortChange = useCallback(
(newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
},
[],
);
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
@@ -63,13 +74,13 @@ export default function PlaylistsScreen() {
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-playlists", libraryId, user?.Id],
queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
queryFn: async ({ pageParam = 0 }) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
includeItemTypes: ["Playlist"],
sortBy: ["SortName"],
sortOrder: ["Ascending"],
sortBy: [sortBy],
sortOrder: [sortOrder],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
recursive: true,
@@ -93,13 +104,6 @@ export default function PlaylistsScreen() {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const numColumns = 2;
const screenWidth = Dimensions.get("window").width;
const gap = 12;
const padding = 16;
const itemWidth =
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
@@ -171,11 +175,10 @@ export default function PlaylistsScreen() {
<View className='flex-1 bg-black'>
<FlashList
data={playlists}
numColumns={numColumns}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
paddingHorizontal: padding,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
@@ -186,17 +189,26 @@ export default function PlaylistsScreen() {
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item, index }) => (
<View
style={{
width: itemWidth,
marginRight: index % numColumns === 0 ? gap : 0,
marginBottom: gap,
}}
ListHeaderComponent={
<TouchableOpacity
onPress={() => setSortSheetOpen(true)}
className='flex-row items-center mb-2 py-1'
>
<MusicPlaylistCard playlist={item} width={itemWidth} />
</View>
)}
<Ionicons name='swap-vertical' size={18} color='#9334E9' />
<Text className='text-purple-500 text-sm ml-1.5'>
{t(
`music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`,
)}
</Text>
<Ionicons
name={sortOrder === "Ascending" ? "arrow-up" : "arrow-down"}
size={14}
color='#9334E9'
style={{ marginLeft: 4 }}
/>
</TouchableOpacity>
}
renderItem={({ item }) => <MusicPlaylistCard playlist={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
@@ -210,6 +222,13 @@ export default function PlaylistsScreen() {
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
<PlaylistSortSheet
open={sortSheetOpen}
setOpen={setSortSheetOpen}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
/>
</View>
);
}

View File

@@ -290,7 +290,7 @@ export default function SuggestionsScreen() {
{section.type === "albums" ? (
<HorizontalScroll
data={section.data}
height={200}
height={178}
keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />}
/>

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useRef } from "react";
import { Animated, Easing, View } from "react-native";
interface Props {
color?: string;
barWidth?: number;
barCount?: number;
height?: number;
gap?: number;
}
export const AnimatedEqualizer: React.FC<Props> = ({
color = "#9334E9",
barWidth = 3,
barCount = 3,
height = 12,
gap = 2,
}) => {
const animations = useRef(
Array.from({ length: barCount }, () => new Animated.Value(0)),
).current;
useEffect(() => {
const durations = [600, 700, 550];
const minScale = [0.2, 0.3, 0.25];
const maxScale = [1, 0.85, 0.95];
// Set initial staggered values
animations.forEach((anim, index) => {
anim.setValue(index === 1 ? 0.8 : index === 2 ? 0.4 : 0.2);
});
const barAnimations = animations.map((anim, index) => {
return Animated.loop(
Animated.sequence([
Animated.timing(anim, {
toValue: maxScale[index % maxScale.length],
duration: durations[index % durations.length],
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(anim, {
toValue: minScale[index % minScale.length],
duration: durations[index % durations.length],
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
]),
);
});
Animated.parallel(barAnimations).start();
return () => {
animations.forEach((anim) => {
anim.stopAnimation();
});
};
}, [animations]);
return (
<View
style={{
flexDirection: "row",
alignItems: "flex-end",
height,
gap,
marginRight: 6,
}}
>
{animations.map((anim, index) => (
<Animated.View
key={index}
style={{
width: barWidth,
height,
backgroundColor: color,
borderRadius: 1,
transform: [{ scaleY: anim }],
}}
/>
))}
</View>
);
};

View File

@@ -13,7 +13,7 @@ interface Props {
width?: number;
}
export const MusicAlbumCard: React.FC<Props> = ({ album, width = 150 }) => {
export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();

View File

@@ -0,0 +1,70 @@
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, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface Props {
album: BaseItemDto;
}
const IMAGE_SIZE = 56;
export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const imageUrl = useMemo(
() => getPrimaryImageUrl({ api, item: album }),
[api, album],
);
const handlePress = useCallback(() => {
router.push({
pathname: "/music/album/[albumId]",
params: { albumId: album.Id! },
});
}, [router, album.Id]);
return (
<TouchableOpacity
onPress={handlePress}
className='flex-row items-center py-2'
>
<View
style={{
width: IMAGE_SIZE,
height: IMAGE_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{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'>
<Text className='text-2xl'>🎵</Text>
</View>
)}
</View>
<View className='flex-1 ml-3'>
<Text numberOfLines={1} className='text-white text-base font-medium'>
{album.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
{album.AlbumArtist || album.Artists?.join(", ")}
</Text>
</View>
</TouchableOpacity>
);
};

View File

@@ -13,7 +13,9 @@ interface Props {
size?: number;
}
export const MusicArtistCard: React.FC<Props> = ({ artist, size = 100 }) => {
const IMAGE_SIZE = 48;
export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
@@ -32,14 +34,13 @@ export const MusicArtistCard: React.FC<Props> = ({ artist, size = 100 }) => {
return (
<TouchableOpacity
onPress={handlePress}
style={{ width: size }}
className='flex flex-col items-center'
className='flex-row items-center py-2'
>
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
width: IMAGE_SIZE,
height: IMAGE_SIZE,
borderRadius: IMAGE_SIZE / 2,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
@@ -53,13 +54,13 @@ export const MusicArtistCard: React.FC<Props> = ({ artist, size = 100 }) => {
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-3xl'>👤</Text>
<Text className='text-xl'>👤</Text>
</View>
)}
</View>
<Text
numberOfLines={2}
className='text-white text-xs font-medium mt-2 text-center'
numberOfLines={1}
className='text-white text-base font-medium ml-3 flex-1'
>
{artist.Name}
</Text>

View File

@@ -1,11 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
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 { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getLocalPath } from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface Props {
@@ -13,11 +17,11 @@ interface Props {
width?: number;
}
export const MusicPlaylistCard: React.FC<Props> = ({
playlist,
width = 150,
}) => {
const IMAGE_SIZE = 56;
export const MusicPlaylistCard: React.FC<Props> = ({ playlist }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const imageUrl = useMemo(
@@ -25,6 +29,37 @@ export const MusicPlaylistCard: React.FC<Props> = ({
[api, playlist],
);
// Fetch playlist tracks to check download status
const { data: tracks } = useQuery({
queryKey: ["playlist-tracks-status", playlist.Id, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: playlist.Id,
fields: ["MediaSources"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!playlist.Id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Calculate download status
const downloadStatus = useMemo(() => {
if (!tracks || tracks.length === 0) {
return { downloaded: 0, total: playlist.ChildCount || 0 };
}
const downloaded = tracks.filter(
(track) => !!getLocalPath(track.Id),
).length;
return { downloaded, total: tracks.length };
}, [tracks, playlist.ChildCount]);
const allDownloaded =
downloadStatus.total > 0 &&
downloadStatus.downloaded === downloadStatus.total;
const hasDownloads = downloadStatus.downloaded > 0;
const handlePress = useCallback(() => {
router.push({
pathname: "/music/playlist/[playlistId]",
@@ -35,13 +70,12 @@ export const MusicPlaylistCard: React.FC<Props> = ({
return (
<TouchableOpacity
onPress={handlePress}
style={{ width }}
className='flex flex-col'
className='flex-row items-center py-2'
>
<View
style={{
width,
height: width,
width: IMAGE_SIZE,
height: IMAGE_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
@@ -56,16 +90,31 @@ export const MusicPlaylistCard: React.FC<Props> = ({
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎶</Text>
<Text className='text-2xl'>🎶</Text>
</View>
)}
</View>
<Text numberOfLines={1} className='text-white text-sm font-medium mt-2'>
{playlist.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-400 text-xs'>
{playlist.ChildCount} tracks
</Text>
<View className='flex-1 ml-3'>
<Text numberOfLines={1} className='text-white text-base font-medium'>
{playlist.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
{playlist.ChildCount} tracks
</Text>
</View>
{/* Download status indicator */}
{allDownloaded ? (
<Ionicons
name='checkmark-circle'
size={18}
color='#22c55e'
style={{ marginRight: 4 }}
/>
) : hasDownloads ? (
<Text className='text-neutral-500 text-xs mr-1'>
{downloadStatus.downloaded}/{downloadStatus.total}
</Text>
) : null}
</TouchableOpacity>
);
};

View File

@@ -5,6 +5,7 @@ import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import {
audioStorageEvents,
@@ -27,7 +28,7 @@ interface Props {
export const MusicTrackItem: React.FC<Props> = ({
track,
index,
index: _index,
queue,
showArtwork = true,
onOptionsPress,
@@ -118,24 +119,15 @@ export const MusicTrackItem: React.FC<Props> = ({
onLongPress={handleLongPress}
delayLongPress={300}
disabled={isUnavailableOffline}
className={`flex flex-row items-center py-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
className={`flex-row items-center py-1.5 pl-4 pr-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
style={isUnavailableOffline ? { opacity: 0.5 } : undefined}
>
{index !== undefined && (
<View className='w-8 items-center'>
{isCurrentTrack && isPlaying ? (
<Ionicons name='musical-note' size={16} color='#9334E9' />
) : (
<Text className='text-neutral-500 text-sm'>{index}</Text>
)}
</View>
)}
{/* Album artwork */}
{showArtwork && (
<View
style={{
width: 48,
height: 48,
width: 44,
height: 44,
borderRadius: 4,
overflow: "hidden",
backgroundColor: "#1a1a1a",
@@ -151,7 +143,7 @@ export const MusicTrackItem: React.FC<Props> = ({
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='musical-note' size={20} color='#737373' />
<Ionicons name='musical-note' size={18} color='#737373' />
</View>
)}
{isTrackLoading && (
@@ -173,20 +165,22 @@ export const MusicTrackItem: React.FC<Props> = ({
</View>
)}
{/* Track info */}
<View className='flex-1 mr-3'>
<Text
numberOfLines={1}
className={`text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
>
{track.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-400 text-xs mt-0.5'>
<View className='flex-row items-center'>
{isCurrentTrack && isPlaying && <AnimatedEqualizer />}
<Text
numberOfLines={1}
className={`flex-1 text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
>
{track.Name}
</Text>
</View>
<Text numberOfLines={1} className='text-neutral-500 text-xs mt-0.5'>
{track.Artists?.join(", ") || track.AlbumArtist}
</Text>
</View>
<Text className='text-neutral-500 text-xs mr-2'>{duration}</Text>
{/* Download status indicator */}
{downloadStatus === "downloading" && (
<ActivityIndicator
@@ -198,19 +192,23 @@ export const MusicTrackItem: React.FC<Props> = ({
{downloadStatus === "downloaded" && (
<Ionicons
name='checkmark-circle'
size={16}
size={14}
color='#22c55e'
style={{ marginRight: 8 }}
/>
)}
{/* Duration */}
<Text className='text-neutral-500 text-xs'>{duration}</Text>
{/* Options button */}
{onOptionsPress && (
<TouchableOpacity
onPress={handleOptionsPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-1'
className='pl-3 py-1'
>
<Ionicons name='ellipsis-vertical' size={18} color='#737373' />
<Ionicons name='ellipsis-vertical' size={16} color='#737373' />
</TouchableOpacity>
)}
</TouchableOpacity>

View File

@@ -0,0 +1,173 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
export type PlaylistSortOption = "SortName" | "DateCreated";
export type PlaylistSortOrder = "Ascending" | "Descending";
interface Props {
open: boolean;
setOpen: (open: boolean) => void;
sortBy: PlaylistSortOption;
sortOrder: PlaylistSortOrder;
onSortChange: (
sortBy: PlaylistSortOption,
sortOrder: PlaylistSortOrder,
) => void;
}
const SORT_OPTIONS: { key: PlaylistSortOption; label: string; icon: string }[] =
[
{ key: "SortName", label: "music.sort.alphabetical", icon: "text-outline" },
{
key: "DateCreated",
label: "music.sort.date_created",
icon: "time-outline",
},
];
export const PlaylistSortSheet: React.FC<Props> = ({
open,
setOpen,
sortBy,
sortOrder,
onSortChange,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const snapPoints = useMemo(() => ["40%"], []);
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 handleSortSelect = useCallback(
(option: PlaylistSortOption) => {
// If selecting same option, toggle order; otherwise use sensible default
if (option === sortBy) {
onSortChange(
option,
sortOrder === "Ascending" ? "Descending" : "Ascending",
);
} else {
// Default order based on sort type
const defaultOrder: PlaylistSortOrder =
option === "SortName" ? "Ascending" : "Descending";
onSortChange(option, defaultOrder);
}
setOpen(false);
},
[sortBy, sortOrder, onSortChange, setOpen],
);
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,
}}
>
<Text className='text-white text-lg font-semibold mb-4'>
{t("music.sort.title")}
</Text>
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
{SORT_OPTIONS.map((option, index) => {
const isSelected = sortBy === option.key;
return (
<React.Fragment key={option.key}>
{index > 0 && <View style={styles.separator} />}
<TouchableOpacity
onPress={() => handleSortSelect(option.key)}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons
name={option.icon as any}
size={22}
color={isSelected ? "#9334E9" : "#fff"}
/>
<Text
className={`ml-4 text-base flex-1 ${isSelected ? "text-purple-500 font-medium" : "text-white"}`}
>
{t(option.label)}
</Text>
{isSelected && (
<View className='flex-row items-center'>
<Ionicons
name={
sortOrder === "Ascending" ? "arrow-up" : "arrow-down"
}
size={18}
color='#9334E9'
/>
<Ionicons
name='checkmark'
size={22}
color='#9334E9'
style={{ marginLeft: 8 }}
/>
</View>
)}
</TouchableOpacity>
</React.Fragment>
);
})}
</View>
</BottomSheetView>
</BottomSheetModal>
);
};
const styles = StyleSheet.create({
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#404040",
},
});

View File

@@ -85,8 +85,6 @@ export const TrackOptionsSheet: React.FC<Props> = ({
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),
@@ -220,8 +218,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
return (
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{

View File

@@ -680,6 +680,11 @@
"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"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
}
},
"watchlists": {