mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-12 04:16:37 +00:00
refactor: improve music design
This commit is contained in:
@@ -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} />}
|
||||
/>
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
|
||||
85
components/music/AnimatedEqualizer.tsx
Normal file
85
components/music/AnimatedEqualizer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
|
||||
70
components/music/MusicAlbumRowCard.tsx
Normal file
70
components/music/MusicAlbumRowCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
173
components/music/PlaylistSortSheet.tsx
Normal file
173
components/music/PlaylistSortSheet.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
@@ -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={{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user