feat: add actions to song rows

This commit is contained in:
Fredrik Burmester
2026-01-04 00:09:21 +01:00
parent 36d24176ae
commit b1da9f8777
12 changed files with 1045 additions and 50 deletions

View File

@@ -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<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: 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={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -1,19 +1,23 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -30,6 +34,24 @@ export default function ArtistDetailScreen() {
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: artist, isLoading: loadingArtist } = useQuery({
queryKey: ["music-artist", artistId, user?.Id],
queryFn: async () => {
@@ -217,12 +239,34 @@ export default function ArtistDetailScreen() {
track={track}
index={index + 1}
queue={section.data}
onOptionsPress={handleTrackOptionsPress}
/>
))
)}
</View>
)}
keyExtractor={(item) => item.id}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -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<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
queryKey: ["music-playlist", playlistId, user?.Id],
queryFn: async () => {
@@ -181,9 +203,35 @@ export default function PlaylistDetailScreen() {
</View>
}
renderItem={({ item, index }) => (
<MusicTrackItem track={item} index={index + 1} queue={tracks} />
<MusicTrackItem
track={item}
index={index + 1}
queue={tracks}
onOptionsPress={handleTrackOptionsPress}
/>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -1,15 +1,22 @@
import { Ionicons } from "@expo/vector-icons";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { useNavigation, useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, RefreshControl, View } from "react-native";
import {
Dimensions,
RefreshControl,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -18,6 +25,7 @@ const ITEMS_PER_PAGE = 40;
export default function PlaylistsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const navigation = useNavigation();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
@@ -27,8 +35,24 @@ export default function PlaylistsScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [createModalOpen, setCreateModalOpen] = useState(false);
const isReady = Boolean(api && user?.Id && libraryId);
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => setCreateModalOpen(true)}
className='mr-4'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name='add' size={28} color='white' />
</TouchableOpacity>
),
});
}, [navigation]);
const {
data,
isLoading,
@@ -123,7 +147,20 @@ export default function PlaylistsScreen() {
if (playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_playlists")}</Text>
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
<TouchableOpacity
onPress={() => setCreateModalOpen(true)}
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("music.playlists.create_playlist")}
</Text>
</TouchableOpacity>
<CreatePlaylistModal
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
</View>
);
}
@@ -167,6 +204,10 @@ export default function PlaylistsScreen() {
) : null
}
/>
<CreatePlaylistModal
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
</View>
);
}

View File

@@ -5,15 +5,18 @@ import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { writeDebugLog } from "@/utils/log";
@@ -29,6 +32,24 @@ export default function SuggestionsScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const isReady = Boolean(api && user?.Id && libraryId);
writeDebugLog("Music suggestions params", {
@@ -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}
/>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</View>
);
}

View File

@@ -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() {
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handleStop}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='close' size={24} color='#666' />
</TouchableOpacity>
<View style={{ width: 16 }} />
</View>
{viewMode === "player" ? (

View File

@@ -0,0 +1,157 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Keyboard } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useCreatePlaylist } from "@/hooks/usePlaylistMutations";
interface Props {
open: boolean;
setOpen: (open: boolean) => void;
onPlaylistCreated?: (playlistId: string) => void;
initialTrackId?: string;
}
export const CreatePlaylistModal: React.FC<Props> = ({
open,
setOpen,
onPlaylistCreated,
initialTrackId,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const createPlaylist = useCreatePlaylist();
const [name, setName] = useState("");
const snapPoints = useMemo(() => ["40%"], []);
useEffect(() => {
if (open) {
setName("");
bottomSheetModalRef.current?.present();
} else {
bottomSheetModalRef.current?.dismiss();
}
}, [open]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setOpen(false);
Keyboard.dismiss();
}
},
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleCreate = useCallback(async () => {
if (!name.trim()) return;
const result = await createPlaylist.mutateAsync({
name: name.trim(),
trackIds: initialTrackId ? [initialTrackId] : undefined,
});
if (result) {
onPlaylistCreated?.(result);
}
setOpen(false);
}, [name, createPlaylist, initialTrackId, onPlaylistCreated, setOpen]);
const isValid = name.trim().length > 0;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
keyboardBehavior='interactive'
keyboardBlurBehavior='restore'
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: insets.bottom + 16,
}}
>
<Text className='font-bold text-2xl mb-6'>
{t("music.playlists.create_playlist")}
</Text>
<Text className='text-neutral-400 mb-2 text-sm'>
{t("music.playlists.playlist_name")}
</Text>
<BottomSheetTextInput
placeholder={t("music.playlists.enter_name")}
placeholderTextColor='#737373'
value={name}
onChangeText={setName}
autoFocus
returnKeyType='done'
onSubmitEditing={handleCreate}
style={{
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 16,
color: "white",
marginBottom: 24,
}}
/>
<Button
onPress={handleCreate}
disabled={!isValid || createPlaylist.isPending}
className={`py-4 rounded-xl ${isValid ? "bg-purple-600" : "bg-neutral-700"}`}
>
{createPlaylist.isPending ? (
<ActivityIndicator color='white' />
) : (
<Text
className={`text-center font-semibold ${isValid ? "text-white" : "text-neutral-500"}`}
>
{t("music.playlists.create")}
</Text>
)}
</Button>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

@@ -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<Props> = ({
@@ -23,17 +23,11 @@ export const MusicTrackItem: React.FC<Props> = ({
index,
queue,
showArtwork = true,
onOptionsPress,
}) => {
const [api] = useAtom(apiAtom);
const { showActionSheetWithOptions } = useActionSheet();
const {
playTrack,
playNext,
addToQueue,
currentTrack,
isPlaying,
loadingTrackId,
} = useMusicPlayer();
const { playTrack, currentTrack, isPlaying, loadingTrackId } =
useMusicPlayer();
const imageUrl = useMemo(() => {
const albumId = track.AlbumId || track.ParentId;
@@ -56,25 +50,12 @@ export const MusicTrackItem: React.FC<Props> = ({
}, [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 (
<TouchableOpacity
@@ -135,7 +116,7 @@ export const MusicTrackItem: React.FC<Props> = ({
</View>
)}
<View className='flex-1 mr-4'>
<View className='flex-1 mr-3'>
<Text
numberOfLines={1}
className={`text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
@@ -147,7 +128,17 @@ export const MusicTrackItem: React.FC<Props> = ({
</Text>
</View>
<Text className='text-neutral-500 text-xs'>{duration}</Text>
<Text className='text-neutral-500 text-xs mr-2'>{duration}</Text>
{onOptionsPress && (
<TouchableOpacity
onPress={handleOptionsPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-1'
>
<Ionicons name='ellipsis-vertical' size={18} color='#737373' />
</TouchableOpacity>
)}
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,262 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
interface Props {
open: boolean;
setOpen: (open: boolean) => void;
trackToAdd: BaseItemDto | null;
onCreateNew: () => void;
}
export const PlaylistPickerSheet: React.FC<Props> = ({
open,
setOpen,
trackToAdd,
onCreateNew,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const addToPlaylist = useAddToPlaylist();
const [search, setSearch] = useState("");
const snapPoints = useMemo(() => ["75%"], []);
// Fetch all playlists
const { data: playlists, isLoading } = useQuery({
queryKey: ["music-playlists-picker", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
includeItemTypes: ["Playlist"],
sortBy: ["SortName"],
sortOrder: ["Ascending"],
recursive: true,
mediaTypes: ["Audio"],
});
return response.data.Items || [];
},
enabled: Boolean(api && user?.Id && open),
});
const filteredPlaylists = useMemo(() => {
if (!playlists) return [];
if (!search) return playlists;
return playlists.filter((playlist) =>
playlist.Name?.toLowerCase().includes(search.toLowerCase()),
);
}, [playlists, search]);
const showSearch = (playlists?.length || 0) > 10;
useEffect(() => {
if (open) {
setSearch("");
bottomSheetModalRef.current?.present();
} else {
bottomSheetModalRef.current?.dismiss();
}
}, [open]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setOpen(false);
}
},
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSelectPlaylist = useCallback(
async (playlist: BaseItemDto) => {
if (!trackToAdd?.Id || !playlist.Id) return;
await addToPlaylist.mutateAsync({
playlistId: playlist.Id,
trackIds: [trackToAdd.Id],
playlistName: playlist.Name || undefined,
});
setOpen(false);
},
[trackToAdd, addToPlaylist, setOpen],
);
const handleCreateNew = useCallback(() => {
setOpen(false);
setTimeout(() => {
onCreateNew();
}, 300);
}, [onCreateNew, setOpen]);
const getPlaylistImageUrl = useCallback(
(playlist: BaseItemDto) => {
if (!api) return null;
return `${api.basePath}/Items/${playlist.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
},
[api],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
>
<BottomSheetScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: insets.bottom + 16,
}}
>
<Text className='font-bold text-2xl mb-2'>
{t("music.track_options.add_to_playlist")}
</Text>
<Text className='text-neutral-500 mb-4'>{trackToAdd?.Name}</Text>
{showSearch && (
<Input
placeholder={t("music.playlists.search_playlists")}
className='mb-4 border-neutral-800 border'
value={search}
onChangeText={setSearch}
returnKeyType='done'
/>
)}
{/* Create New Playlist Button */}
<TouchableOpacity
onPress={handleCreateNew}
className='flex-row items-center bg-purple-900/30 rounded-xl px-4 py-3.5 mb-4'
>
<View className='w-12 h-12 rounded-lg bg-purple-600 items-center justify-center mr-3'>
<Ionicons name='add' size={28} color='white' />
</View>
<Text className='text-purple-400 font-semibold text-base'>
{t("music.playlists.create_new")}
</Text>
</TouchableOpacity>
{isLoading ? (
<View className='py-8 items-center'>
<ActivityIndicator color='#9334E9' />
</View>
) : filteredPlaylists.length === 0 ? (
<View className='py-8 items-center'>
<Text className='text-neutral-500'>
{search ? t("search.no_results") : t("music.no_playlists")}
</Text>
</View>
) : (
<View className='rounded-xl overflow-hidden bg-neutral-800'>
{filteredPlaylists.map((playlist, index) => (
<View key={playlist.Id}>
<TouchableOpacity
onPress={() => handleSelectPlaylist(playlist)}
className='flex-row items-center px-4 py-3'
disabled={addToPlaylist.isPending}
>
<View
style={{
width: 48,
height: 48,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#1a1a1a",
marginRight: 12,
}}
>
<Image
source={{
uri: getPlaylistImageUrl(playlist) || undefined,
}}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
</View>
<View className='flex-1'>
<Text numberOfLines={1} className='text-white text-base'>
{playlist.Name}
</Text>
<Text className='text-neutral-500 text-sm'>
{playlist.ChildCount} {t("music.tabs.tracks")}
</Text>
</View>
{addToPlaylist.isPending && (
<ActivityIndicator size='small' color='#9334E9' />
)}
</TouchableOpacity>
{index < filteredPlaylists.length - 1 && (
<View style={styles.separator} />
)}
</View>
))}
</View>
)}
</BottomSheetScrollView>
</BottomSheetModal>
);
};
const styles = StyleSheet.create({
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#404040",
},
});

View File

@@ -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<Props> = ({
open,
setOpen,
track,
onAddToPlaylist,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(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) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handlePlayNext = useCallback(() => {
if (track) {
playNext(track);
setOpen(false);
}
}, [track, playNext, setOpen]);
const handleAddToQueue = useCallback(() => {
if (track) {
addToQueue(track);
setOpen(false);
}
}, [track, addToQueue, setOpen]);
const handleAddToPlaylist = useCallback(() => {
setOpen(false);
setTimeout(() => {
onAddToPlaylist();
}, 300);
}, [onAddToPlaylist, setOpen]);
if (!track) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: insets.bottom,
}}
>
{/* Track Info Header */}
<View className='flex-row items-center mb-6 px-2'>
<View
style={{
width: 56,
height: 56,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#1a1a1a",
marginRight: 12,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='musical-note' size={24} color='#737373' />
</View>
)}
</View>
<View className='flex-1'>
<Text
numberOfLines={1}
className='text-white font-semibold text-base'
>
{track.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
{track.Artists?.join(", ") || track.AlbumArtist}
</Text>
</View>
</View>
{/* Options */}
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
<TouchableOpacity
onPress={handlePlayNext}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='play-forward' size={22} color='white' />
<Text className='text-white ml-4 text-base'>
{t("music.track_options.play_next")}
</Text>
</TouchableOpacity>
<View style={styles.separator} />
<TouchableOpacity
onPress={handleAddToQueue}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='list' size={22} color='white' />
<Text className='text-white ml-4 text-base'>
{t("music.track_options.add_to_queue")}
</Text>
</TouchableOpacity>
<View style={styles.separator} />
<TouchableOpacity
onPress={handleAddToPlaylist}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='albums-outline' size={22} color='white' />
<Text className='text-white ml-4 text-base'>
{t("music.track_options.add_to_playlist")}
</Text>
</TouchableOpacity>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};
const styles = StyleSheet.create({
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#404040",
},
});

View File

@@ -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<string | undefined> => {
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<void> => {
if (!api || !user?.Id) {
throw new Error("API not configured");
}
await getPlaylistsApi(api).addItemToPlaylist({
playlistId,
ids: trackIds,
userId: user.Id,
});
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["music-playlists"],
});
queryClient.invalidateQueries({
queryKey: ["music-playlist", variables.playlistId],
});
if (variables.playlistName) {
toast.success(
t("music.playlists.added_to", { name: variables.playlistName }),
);
} else {
toast.success(t("music.playlists.added"));
}
},
onError: (error: Error) => {
toast.error(error.message || t("music.playlists.failed_to_add"));
},
});
return mutation;
};
/**
* Hook to remove a track from a playlist
*/
export const useRemoveFromPlaylist = () => {
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { t } = useTranslation();
const mutation = useMutation({
mutationFn: async ({
playlistId,
entryIds,
}: {
playlistId: string;
entryIds: string[];
playlistName?: string;
}): Promise<void> => {
if (!api) {
throw new Error("API not configured");
}
await getPlaylistsApi(api).removeItemFromPlaylist({
playlistId,
entryIds,
});
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["music-playlists"],
});
queryClient.invalidateQueries({
queryKey: ["music-playlist", variables.playlistId],
});
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;
};

View File

@@ -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",