From b1da9f8777ea0d7fe6f14c1762c23d8eca8de15e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 4 Jan 2026 00:09:21 +0100 Subject: [PATCH] feat: add actions to song rows --- .../music/album/[albumId].tsx | 46 ++- .../music/artist/[artistId].tsx | 46 ++- .../music/playlist/[playlistId].tsx | 52 +++- .../music/[libraryId]/playlists.tsx | 49 +++- .../music/[libraryId]/suggestions.tsx | 41 ++- app/(auth)/now-playing.tsx | 11 +- components/music/CreatePlaylistModal.tsx | 157 +++++++++++ components/music/MusicTrackItem.tsx | 51 ++-- components/music/PlaylistPickerSheet.tsx | 262 ++++++++++++++++++ components/music/TrackOptionsSheet.tsx | 204 ++++++++++++++ hooks/usePlaylistMutations.ts | 150 ++++++++++ translations/en.json | 26 +- 12 files changed, 1045 insertions(+), 50 deletions(-) create mode 100644 components/music/CreatePlaylistModal.tsx create mode 100644 components/music/PlaylistPickerSheet.tsx create mode 100644 components/music/TrackOptionsSheet.tsx create mode 100644 hooks/usePlaylistMutations.ts diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx index 67095640..3cbae3f6 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx @@ -1,17 +1,21 @@ import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Dimensions, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { MusicTrackItem } from "@/components/music/MusicTrackItem"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -29,6 +33,24 @@ export default function AlbumDetailScreen() { const { t } = useTranslation(); const { playQueue } = useMusicPlayer(); + const [selectedTrack, setSelectedTrack] = useState(null); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + + const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { + setSelectedTrack(track); + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + const { data: album, isLoading: loadingAlbum } = useQuery({ queryKey: ["music-album", albumId, user?.Id], queryFn: async () => { @@ -190,9 +212,31 @@ export default function AlbumDetailScreen() { index={index + 1} queue={tracks} showArtwork={false} + onOptionsPress={handleTrackOptionsPress} /> )} keyExtractor={(item) => item.Id!} + ListFooterComponent={ + <> + + + + + } /> ); } diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx index e7f69cb6..5ef4b71f 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx @@ -1,19 +1,23 @@ import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Dimensions, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HorizontalScroll } from "@/components/common/HorizontalScroll"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { MusicAlbumCard } from "@/components/music/MusicAlbumCard"; import { MusicTrackItem } from "@/components/music/MusicTrackItem"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -30,6 +34,24 @@ export default function ArtistDetailScreen() { const { t } = useTranslation(); const { playQueue } = useMusicPlayer(); + const [selectedTrack, setSelectedTrack] = useState(null); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + + const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { + setSelectedTrack(track); + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + const { data: artist, isLoading: loadingArtist } = useQuery({ queryKey: ["music-artist", artistId, user?.Id], queryFn: async () => { @@ -217,12 +239,34 @@ export default function ArtistDetailScreen() { track={track} index={index + 1} queue={section.data} + onOptionsPress={handleTrackOptionsPress} /> )) )} )} keyExtractor={(item) => item.id} + ListFooterComponent={ + <> + + + + + } /> ); } diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx index 8b4c3980..20a5b600 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx @@ -1,17 +1,21 @@ import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Dimensions, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { MusicTrackItem } from "@/components/music/MusicTrackItem"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -29,6 +33,24 @@ export default function PlaylistDetailScreen() { const { t } = useTranslation(); const { playQueue } = useMusicPlayer(); + const [selectedTrack, setSelectedTrack] = useState(null); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + + const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { + setSelectedTrack(track); + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + const { data: playlist, isLoading: loadingPlaylist } = useQuery({ queryKey: ["music-playlist", playlistId, user?.Id], queryFn: async () => { @@ -181,9 +203,35 @@ export default function PlaylistDetailScreen() { } renderItem={({ item, index }) => ( - + )} keyExtractor={(item) => item.Id!} + ListFooterComponent={ + <> + + + + + } /> ); } diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx index bc1cfd11..686d5a62 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx @@ -1,15 +1,22 @@ +import { Ionicons } from "@expo/vector-icons"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useRoute } from "@react-navigation/native"; +import { useNavigation, useRoute } from "@react-navigation/native"; import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; -import { useCallback, useMemo } from "react"; +import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dimensions, RefreshControl, View } from "react-native"; +import { + Dimensions, + RefreshControl, + TouchableOpacity, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -18,6 +25,7 @@ const ITEMS_PER_PAGE = 40; export default function PlaylistsScreen() { const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>(); const route = useRoute(); + const navigation = useNavigation(); const libraryId = (Array.isArray(localParams.libraryId) ? localParams.libraryId[0] @@ -27,8 +35,24 @@ export default function PlaylistsScreen() { const insets = useSafeAreaInsets(); const { t } = useTranslation(); + const [createModalOpen, setCreateModalOpen] = useState(false); + const isReady = Boolean(api && user?.Id && libraryId); + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + setCreateModalOpen(true)} + className='mr-4' + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + ), + }); + }, [navigation]); + const { data, isLoading, @@ -123,7 +147,20 @@ export default function PlaylistsScreen() { if (playlists.length === 0) { return ( - {t("music.no_playlists")} + {t("music.no_playlists")} + setCreateModalOpen(true)} + className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full' + > + + + {t("music.playlists.create_playlist")} + + + ); } @@ -167,6 +204,10 @@ export default function PlaylistsScreen() { ) : null } /> + ); } diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx index 0b49c803..495c8a39 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx @@ -5,15 +5,18 @@ import { FlashList } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HorizontalScroll } from "@/components/common/HorizontalScroll"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; +import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { MusicAlbumCard } from "@/components/music/MusicAlbumCard"; import { MusicTrackItem } from "@/components/music/MusicTrackItem"; +import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; +import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { writeDebugLog } from "@/utils/log"; @@ -29,6 +32,24 @@ export default function SuggestionsScreen() { const insets = useSafeAreaInsets(); const { t } = useTranslation(); + const [selectedTrack, setSelectedTrack] = useState(null); + const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); + const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + + const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { + setSelectedTrack(track); + setTrackOptionsOpen(true); + }, []); + + const handleAddToPlaylist = useCallback(() => { + setPlaylistPickerOpen(true); + }, []); + + const handleCreateNewPlaylist = useCallback(() => { + setCreatePlaylistOpen(true); + }, []); + const isReady = Boolean(api && user?.Id && libraryId); writeDebugLog("Music suggestions params", { @@ -276,6 +297,7 @@ export default function SuggestionsScreen() { track={track} index={index + 1} queue={section.data} + onOptionsPress={handleTrackOptionsPress} /> )) )} @@ -283,6 +305,23 @@ export default function SuggestionsScreen() { )} keyExtractor={(item) => item.title} /> + + + ); } diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx index d315efa7..a9b045af 100644 --- a/app/(auth)/now-playing.tsx +++ b/app/(auth)/now-playing.tsx @@ -118,7 +118,7 @@ export default function NowPlayingScreen() { router.back(); }, [router]); - const handleStop = useCallback(() => { + const _handleStop = useCallback(() => { stop(); router.back(); }, [stop, router]); @@ -206,14 +206,7 @@ export default function NowPlayingScreen() { - - - - + {viewMode === "player" ? ( diff --git a/components/music/CreatePlaylistModal.tsx b/components/music/CreatePlaylistModal.tsx new file mode 100644 index 00000000..fea1421f --- /dev/null +++ b/components/music/CreatePlaylistModal.tsx @@ -0,0 +1,157 @@ +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetTextInput, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, Keyboard } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useCreatePlaylist } from "@/hooks/usePlaylistMutations"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + onPlaylistCreated?: (playlistId: string) => void; + initialTrackId?: string; +} + +export const CreatePlaylistModal: React.FC = ({ + open, + setOpen, + onPlaylistCreated, + initialTrackId, +}) => { + const bottomSheetModalRef = useRef(null); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const createPlaylist = useCreatePlaylist(); + + const [name, setName] = useState(""); + const snapPoints = useMemo(() => ["40%"], []); + + useEffect(() => { + if (open) { + setName(""); + bottomSheetModalRef.current?.present(); + } else { + bottomSheetModalRef.current?.dismiss(); + } + }, [open]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + Keyboard.dismiss(); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleCreate = useCallback(async () => { + if (!name.trim()) return; + + const result = await createPlaylist.mutateAsync({ + name: name.trim(), + trackIds: initialTrackId ? [initialTrackId] : undefined, + }); + + if (result) { + onPlaylistCreated?.(result); + } + setOpen(false); + }, [name, createPlaylist, initialTrackId, onPlaylistCreated, setOpen]); + + const isValid = name.trim().length > 0; + + return ( + + + + {t("music.playlists.create_playlist")} + + + + {t("music.playlists.playlist_name")} + + + + + + + ); +}; diff --git a/components/music/MusicTrackItem.tsx b/components/music/MusicTrackItem.tsx index 0642fa2f..add4350e 100644 --- a/components/music/MusicTrackItem.tsx +++ b/components/music/MusicTrackItem.tsx @@ -1,4 +1,3 @@ -import { useActionSheet } from "@expo/react-native-action-sheet"; import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; @@ -16,6 +15,7 @@ interface Props { index?: number; queue?: BaseItemDto[]; showArtwork?: boolean; + onOptionsPress?: (track: BaseItemDto) => void; } export const MusicTrackItem: React.FC = ({ @@ -23,17 +23,11 @@ export const MusicTrackItem: React.FC = ({ index, queue, showArtwork = true, + onOptionsPress, }) => { const [api] = useAtom(apiAtom); - const { showActionSheetWithOptions } = useActionSheet(); - const { - playTrack, - playNext, - addToQueue, - currentTrack, - isPlaying, - loadingTrackId, - } = useMusicPlayer(); + const { playTrack, currentTrack, isPlaying, loadingTrackId } = + useMusicPlayer(); const imageUrl = useMemo(() => { const albumId = track.AlbumId || track.ParentId; @@ -56,25 +50,12 @@ export const MusicTrackItem: React.FC = ({ }, [playTrack, track, queue]); const handleLongPress = useCallback(() => { - const options = ["Play Next", "Add to Queue", "Cancel"]; - const cancelButtonIndex = 2; + onOptionsPress?.(track); + }, [onOptionsPress, track]); - showActionSheetWithOptions( - { - options, - cancelButtonIndex, - title: track.Name ?? undefined, - message: (track.Artists?.join(", ") || track.AlbumArtist) ?? undefined, - }, - (selectedIndex) => { - if (selectedIndex === 0) { - playNext(track); - } else if (selectedIndex === 1) { - addToQueue(track); - } - }, - ); - }, [showActionSheetWithOptions, track, playNext, addToQueue]); + const handleOptionsPress = useCallback(() => { + onOptionsPress?.(track); + }, [onOptionsPress, track]); return ( = ({ )} - + = ({ - {duration} + {duration} + + {onOptionsPress && ( + + + + )} ); }; diff --git a/components/music/PlaylistPickerSheet.tsx b/components/music/PlaylistPickerSheet.tsx new file mode 100644 index 00000000..6cd7dc1b --- /dev/null +++ b/components/music/PlaylistPickerSheet.tsx @@ -0,0 +1,262 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Input } from "@/components/common/Input"; +import { Text } from "@/components/common/Text"; +import { useAddToPlaylist } from "@/hooks/usePlaylistMutations"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + trackToAdd: BaseItemDto | null; + onCreateNew: () => void; +} + +export const PlaylistPickerSheet: React.FC = ({ + open, + setOpen, + trackToAdd, + onCreateNew, +}) => { + const bottomSheetModalRef = useRef(null); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const addToPlaylist = useAddToPlaylist(); + + const [search, setSearch] = useState(""); + const snapPoints = useMemo(() => ["75%"], []); + + // Fetch all playlists + const { data: playlists, isLoading } = useQuery({ + queryKey: ["music-playlists-picker", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + includeItemTypes: ["Playlist"], + sortBy: ["SortName"], + sortOrder: ["Ascending"], + recursive: true, + mediaTypes: ["Audio"], + }); + + return response.data.Items || []; + }, + enabled: Boolean(api && user?.Id && open), + }); + + const filteredPlaylists = useMemo(() => { + if (!playlists) return []; + if (!search) return playlists; + return playlists.filter((playlist) => + playlist.Name?.toLowerCase().includes(search.toLowerCase()), + ); + }, [playlists, search]); + + const showSearch = (playlists?.length || 0) > 10; + + useEffect(() => { + if (open) { + setSearch(""); + bottomSheetModalRef.current?.present(); + } else { + bottomSheetModalRef.current?.dismiss(); + } + }, [open]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleSelectPlaylist = useCallback( + async (playlist: BaseItemDto) => { + if (!trackToAdd?.Id || !playlist.Id) return; + + await addToPlaylist.mutateAsync({ + playlistId: playlist.Id, + trackIds: [trackToAdd.Id], + playlistName: playlist.Name || undefined, + }); + + setOpen(false); + }, + [trackToAdd, addToPlaylist, setOpen], + ); + + const handleCreateNew = useCallback(() => { + setOpen(false); + setTimeout(() => { + onCreateNew(); + }, 300); + }, [onCreateNew, setOpen]); + + const getPlaylistImageUrl = useCallback( + (playlist: BaseItemDto) => { + if (!api) return null; + return `${api.basePath}/Items/${playlist.Id}/Images/Primary?maxHeight=100&maxWidth=100`; + }, + [api], + ); + + return ( + + + + {t("music.track_options.add_to_playlist")} + + {trackToAdd?.Name} + + {showSearch && ( + + )} + + {/* Create New Playlist Button */} + + + + + + {t("music.playlists.create_new")} + + + + {isLoading ? ( + + + + ) : filteredPlaylists.length === 0 ? ( + + + {search ? t("search.no_results") : t("music.no_playlists")} + + + ) : ( + + {filteredPlaylists.map((playlist, index) => ( + + handleSelectPlaylist(playlist)} + className='flex-row items-center px-4 py-3' + disabled={addToPlaylist.isPending} + > + + + + + + {playlist.Name} + + + {playlist.ChildCount} {t("music.tabs.tracks")} + + + {addToPlaylist.isPending && ( + + )} + + {index < filteredPlaylists.length - 1 && ( + + )} + + ))} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: "#404040", + }, +}); diff --git a/components/music/TrackOptionsSheet.tsx b/components/music/TrackOptionsSheet.tsx new file mode 100644 index 00000000..be57aefb --- /dev/null +++ b/components/music/TrackOptionsSheet.tsx @@ -0,0 +1,204 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +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"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + track: BaseItemDto | null; + onAddToPlaylist: () => void; +} + +export const TrackOptionsSheet: React.FC = ({ + open, + setOpen, + track, + onAddToPlaylist, +}) => { + const bottomSheetModalRef = useRef(null); + const [api] = useAtom(apiAtom); + const { playNext, addToQueue } = useMusicPlayer(); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + + const snapPoints = useMemo(() => ["45%"], []); + + const imageUrl = useMemo(() => { + if (!track) return null; + const albumId = track.AlbumId || track.ParentId; + if (albumId) { + return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=200&maxWidth=200`; + } + return getPrimaryImageUrl({ api, item: track }); + }, [api, track]); + + useEffect(() => { + if (open) bottomSheetModalRef.current?.present(); + else bottomSheetModalRef.current?.dismiss(); + }, [open]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handlePlayNext = useCallback(() => { + if (track) { + playNext(track); + setOpen(false); + } + }, [track, playNext, setOpen]); + + const handleAddToQueue = useCallback(() => { + if (track) { + addToQueue(track); + setOpen(false); + } + }, [track, addToQueue, setOpen]); + + const handleAddToPlaylist = useCallback(() => { + setOpen(false); + setTimeout(() => { + onAddToPlaylist(); + }, 300); + }, [onAddToPlaylist, setOpen]); + + if (!track) return null; + + return ( + + + {/* Track Info Header */} + + + {imageUrl ? ( + + ) : ( + + + + )} + + + + {track.Name} + + + {track.Artists?.join(", ") || track.AlbumArtist} + + + + + {/* Options */} + + + + + {t("music.track_options.play_next")} + + + + + + + + + {t("music.track_options.add_to_queue")} + + + + + + + + + {t("music.track_options.add_to_playlist")} + + + + + + ); +}; + +const styles = StyleSheet.create({ + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: "#404040", + }, +}); diff --git a/hooks/usePlaylistMutations.ts b/hooks/usePlaylistMutations.ts new file mode 100644 index 00000000..30742427 --- /dev/null +++ b/hooks/usePlaylistMutations.ts @@ -0,0 +1,150 @@ +import { getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner-native"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +/** + * Hook to create a new playlist + */ +export const useCreatePlaylist = () => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + const mutation = useMutation({ + mutationFn: async ({ + name, + trackIds, + }: { + name: string; + trackIds?: string[]; + }): Promise => { + if (!api || !user?.Id) { + throw new Error("API not configured"); + } + + const response = await getPlaylistsApi(api).createPlaylist({ + name, + ids: trackIds, + userId: user.Id, + mediaType: "Audio", + }); + + return response.data.Id; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["music-playlists"], + }); + toast.success(t("music.playlists.created")); + }, + onError: (error: Error) => { + toast.error(error.message || t("music.playlists.failed_to_create")); + }, + }); + + return mutation; +}; + +/** + * Hook to add a track to a playlist + */ +export const useAddToPlaylist = () => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + const mutation = useMutation({ + mutationFn: async ({ + playlistId, + trackIds, + }: { + playlistId: string; + trackIds: string[]; + playlistName?: string; + }): Promise => { + if (!api || !user?.Id) { + throw new Error("API not configured"); + } + + await getPlaylistsApi(api).addItemToPlaylist({ + playlistId, + ids: trackIds, + userId: user.Id, + }); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["music-playlists"], + }); + queryClient.invalidateQueries({ + queryKey: ["music-playlist", variables.playlistId], + }); + if (variables.playlistName) { + toast.success( + t("music.playlists.added_to", { name: variables.playlistName }), + ); + } else { + toast.success(t("music.playlists.added")); + } + }, + onError: (error: Error) => { + toast.error(error.message || t("music.playlists.failed_to_add")); + }, + }); + + return mutation; +}; + +/** + * Hook to remove a track from a playlist + */ +export const useRemoveFromPlaylist = () => { + const api = useAtomValue(apiAtom); + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + const mutation = useMutation({ + mutationFn: async ({ + playlistId, + entryIds, + }: { + playlistId: string; + entryIds: string[]; + playlistName?: string; + }): Promise => { + if (!api) { + throw new Error("API not configured"); + } + + await getPlaylistsApi(api).removeItemFromPlaylist({ + playlistId, + entryIds, + }); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["music-playlists"], + }); + queryClient.invalidateQueries({ + queryKey: ["music-playlist", variables.playlistId], + }); + if (variables.playlistName) { + toast.success( + t("music.playlists.removed_from", { name: variables.playlistName }), + ); + } else { + toast.success(t("music.playlists.removed")); + } + }, + onError: (error: Error) => { + toast.error(error.message || t("music.playlists.failed_to_remove")); + }, + }); + + return mutation; +}; diff --git a/translations/en.json b/translations/en.json index 6aa25df9..0a563703 100644 --- a/translations/en.json +++ b/translations/en.json @@ -603,7 +603,8 @@ "suggestions": "Suggestions", "albums": "Albums", "artists": "Artists", - "playlists": "Playlists" + "playlists": "Playlists", + "tracks": "tracks" }, "filters": { "all": "All" @@ -622,7 +623,28 @@ "no_playlists": "No playlists found", "album_not_found": "Album not found", "artist_not_found": "Artist not found", - "playlist_not_found": "Playlist not found" + "playlist_not_found": "Playlist not found", + "track_options": { + "play_next": "Play Next", + "add_to_queue": "Add to Queue", + "add_to_playlist": "Add to Playlist" + }, + "playlists": { + "create_playlist": "Create Playlist", + "playlist_name": "Playlist Name", + "enter_name": "Enter playlist name", + "create": "Create", + "search_playlists": "Search playlists...", + "added_to": "Added to {{name}}", + "added": "Added to playlist", + "removed_from": "Removed from {{name}}", + "removed": "Removed from playlist", + "created": "Playlist created", + "create_new": "Create New Playlist", + "failed_to_add": "Failed to add to playlist", + "failed_to_remove": "Failed to remove from playlist", + "failed_to_create": "Failed to create playlist" + } }, "watchlists": { "title": "Watchlists",