mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
feat: add actions to song rows
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
157
components/music/CreatePlaylistModal.tsx
Normal file
157
components/music/CreatePlaylistModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
262
components/music/PlaylistPickerSheet.tsx
Normal file
262
components/music/PlaylistPickerSheet.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
204
components/music/TrackOptionsSheet.tsx
Normal file
204
components/music/TrackOptionsSheet.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
150
hooks/usePlaylistMutations.ts
Normal file
150
hooks/usePlaylistMutations.ts
Normal 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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user