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

@@ -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",
},
});