Merge branch 'main' into renovate/xcodeproj-1.x
@@ -1,5 +1,5 @@
|
||||
import './gesture-handler';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import "react-native-url-polyfill/auto";
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||
import Jellify from './components/jellify';
|
||||
@@ -9,14 +9,44 @@ import jellifyConfig from './tamagui.config';
|
||||
import { clientPersister } from './constants/storage';
|
||||
import { queryClient } from './constants/query-client';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { createWorkletRuntime } from 'react-native-reanimated';
|
||||
import TrackPlayer, { IOSCategory, IOSCategoryOptions } from 'react-native-track-player';
|
||||
import { CAPABILITIES } from './player/constants';
|
||||
|
||||
// export const backgroundRuntime = createWorkletRuntime('background');
|
||||
|
||||
export default function App(): React.JSX.Element {
|
||||
|
||||
const [playerIsReady, setPlayerIsReady] = useState<boolean>(false);
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
|
||||
TrackPlayer.setupPlayer({
|
||||
autoHandleInterruptions: true,
|
||||
maxCacheSize: 1000 * 100, // 100MB, TODO make this adjustable
|
||||
iosCategory: IOSCategory.Playback,
|
||||
iosCategoryOptions: [
|
||||
IOSCategoryOptions.AllowAirPlay,
|
||||
IOSCategoryOptions.AllowBluetooth,
|
||||
]
|
||||
})
|
||||
.then(() => TrackPlayer.updateOptions({
|
||||
progressUpdateEventInterval: 1,
|
||||
capabilities: CAPABILITIES,
|
||||
notificationCapabilities: CAPABILITIES,
|
||||
compactCapabilities: CAPABILITIES,
|
||||
// ratingType: RatingType.Heart,
|
||||
// likeOptions: {
|
||||
// isActive: false,
|
||||
// title: "Favorite"
|
||||
// },
|
||||
// dislikeOptions: {
|
||||
// isActive: true,
|
||||
// title: "Unfavorite"
|
||||
// }
|
||||
}))
|
||||
.finally(() => {
|
||||
setPlayerIsReady(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
@@ -26,7 +56,9 @@ export default function App(): React.JSX.Element {
|
||||
<GestureHandlerRootView>
|
||||
<TamaguiProvider config={jellifyConfig}>
|
||||
<Theme name={isDarkMode ? 'dark' : 'light'}>
|
||||
{ playerIsReady && (
|
||||
<Jellify />
|
||||
)}
|
||||
</Theme>
|
||||
</TamaguiProvider>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> **jellify** (verb) - *to make gelatinous* <br>
|
||||
[see also](https://www.merriam-webster.com/dictionary/jellify)
|
||||
|
||||
*Jellify* is a music player for [Jellyfin](https://jellyfin.org/) built with [React Native](https://reactnative.dev/). *Jellify* provides a user experience that feels familar to other popular music apps and a has featureset to match
|
||||
*Jellify* is a free and open source music player for [Jellyfin](https://jellyfin.org/). Built with [React Native](https://reactnative.dev/), *Jellify* provides a user experience that feels familar to other popular music apps and a has featureset to match
|
||||
|
||||
> *Jellify* requires a connection to a [Jellyfin](https://jellyfin.org/) server to work.
|
||||
|
||||
@@ -39,22 +39,48 @@ This app was designed with me and my dad in mind, since I wanted to give him a s
|
||||
|
||||
## 👀 Lemme see!
|
||||
### Home
|
||||

|
||||
Home
|
||||
|
||||
### Favorites / Library
|
||||

|
||||
<img src="screenshots/playlist.png" alt="Jellify Home" width="275" height="600">
|
||||
|
||||

|
||||
### Library
|
||||
Library
|
||||
|
||||

|
||||
<img src="screenshots/library.png" alt="Library" width="275" height="600">
|
||||
|
||||
Library Artists
|
||||
|
||||
<img src="screenshots/library_artists.png" alt="Library Artists" width="275" height="600">
|
||||
|
||||
Artist
|
||||
|
||||
<img src="screenshots/artist.png" alt="Artist" width="275" height="600">
|
||||
|
||||
Album
|
||||
|
||||
<img src="screenshots/album.png" alt="Album" width="275" height="600">
|
||||
|
||||
Track Options
|
||||
|
||||
<img src="screenshots/track_options.png" alt="Track Options" width="275" height="600">
|
||||
|
||||
Playlist
|
||||
|
||||
<img src="screenshots/playlist.png" alt="Playlist" width="275" height="600">
|
||||
|
||||
### Search
|
||||

|
||||
<img src="screenshots/search.png" alt="Search" width="275" height="600">
|
||||
|
||||
### Player
|
||||

|
||||
<img src="screenshots/player.png" alt="Player" width="275" height="600">
|
||||
|
||||

|
||||
<img src="screenshots/player_queue.png" alt="Queue" width="275" height="600">
|
||||
|
||||
### CarPlay (Sneak Preview)
|
||||
<img src="screenshots/carplay_nowplaying.jpeg" alt="Now Playing (CarPlay)" width="500" height="350">
|
||||
|
||||
### On the Server
|
||||
<img src="https://github.com/user-attachments/assets/741884a2-b9b7-4081-b3a0-6655d08071dc" alt="Playback Tracking" width="300" height="200">
|
||||
|
||||
## 🏗 Built with:
|
||||
### 🎨 Frontend
|
||||
@@ -73,12 +99,16 @@ This app was designed with me and my dad in mind, since I wanted to give him a s
|
||||
[React Native MMKV](https://github.com/mrousavy/react-native-mmkv)\
|
||||
[React Native File Access](https://github.com/alpha0010/react-native-file-access)
|
||||
|
||||
### 👩💻 Monitoring
|
||||
[GlitchTip](https://glitchtip.com/)
|
||||
|
||||
### 💜 Love from Wisconsin 🧀
|
||||
This is undoubtedly a passion project of [mine](https://github.com/anultravioletaurora), and I've learned a lot from working on it (and the many failed attempts before it). I hope you enjoy using it! Feature requests and bug reports are welcome :)
|
||||
|
||||
|
||||
## 🙏 Special Thanks To
|
||||
- The [Jellyfin Team](https://jellyfin.org/) for their amazing server software, SDKs, and documentation
|
||||
- All contributors of [Finamp](https://github.com/jmshrv/finamp). *Jellify* draws inspiration and wisdom from it, and is another fantastic music app for Jellyfin
|
||||
- The folks in the [Margelo Community Discord](https://discord.com/invite/6CSHz2qAvA) for their assistance
|
||||
- Tony, Trevor, [Laine](https://github.com/lainie-ftw) and [Jordan](https://github.com/jordanbleu) for their testing and feedback from the early stages of development
|
||||
- Alyssa, for your artistic abilities and the artwork you made for *Jellify*. It gave it the flair it undoubtedly needed
|
||||
- [Alyssa](https://www.instagram.com/uhh.lyssarae?igsh=MTRmczExempnbjBwZw==), for your design knowledge and for making the artwork for *Jellify*. You’ve been instrumental in shaping it’s user experience
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Client from "../../../api/client";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
/**
|
||||
* Manually marks an item as played.
|
||||
* This should only be used for non-tracks,
|
||||
* as those playbacks will be handled by the server
|
||||
*
|
||||
* This is mainly used for marking playlists
|
||||
* and albums as played, so we can use Jellyfin
|
||||
* to fetch recent ones later on
|
||||
*
|
||||
* @param item The item to mark as played
|
||||
*/
|
||||
export async function markItemPlayed(item: BaseItemDto) : Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getPlaystateApi(Client.api!)
|
||||
.markPlayedItem({
|
||||
itemId: item.Id!,
|
||||
userId: Client.user!.id
|
||||
})
|
||||
.then((response) => {
|
||||
resolve()
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BaseItemDto, MediaType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import Client from "../../../api/client";
|
||||
import { getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getLibraryApi, getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
export async function addToPlaylist(track: BaseItemDto, playlist: BaseItemDto) {
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function reorderPlaylist(playlistId: string, itemId: string, to: nu
|
||||
}
|
||||
|
||||
export async function createPlaylist(name: string) {
|
||||
console.debug("Creating new playlist");
|
||||
console.debug("Creating new playlist...");
|
||||
|
||||
return getPlaylistsApi(Client.api!)
|
||||
.createPlaylist({
|
||||
@@ -52,6 +52,15 @@ export async function createPlaylist(name: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlaylist(playlistId: string) {
|
||||
console.debug("Deleting playlist...");
|
||||
|
||||
return getLibraryApi(Client.api!)
|
||||
.deleteItem({
|
||||
itemId: playlistId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a Jellyfin playlist with the provided options.
|
||||
*
|
||||
|
||||
@@ -13,6 +13,4 @@ export const useApi = (serverUrl?: string, username?: string, password?: string,
|
||||
|
||||
return createApi(serverUrl, username, password, accessToken)
|
||||
},
|
||||
gcTime: 1000,
|
||||
refetchInterval: false
|
||||
})
|
||||
@@ -4,26 +4,6 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"
|
||||
import { BaseItemKind, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models"
|
||||
import Client from "../client"
|
||||
|
||||
export const useArtistAlbums = (artistId: string) => useQuery({
|
||||
queryKey: [QueryKeys.ArtistAlbums, artistId],
|
||||
queryFn: ({ queryKey }) => {
|
||||
return getItemsApi(Client.api!).getItems({
|
||||
includeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
recursive: true,
|
||||
excludeItemIds: [queryKey[1] as string],
|
||||
sortBy: [
|
||||
ItemSortBy.PremiereDate,
|
||||
ItemSortBy.ProductionYear,
|
||||
ItemSortBy.SortName
|
||||
],
|
||||
sortOrder: [SortOrder.Descending],
|
||||
artistIds: [queryKey[1] as string],
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data.Items ? response.data.Items! : [];
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
export const useArtistFeaturedOnAlbums = (artistId: string) => useQuery({
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchFavoriteAlbums, fetchFavoriteArtists, fetchFavoritePlaylists, fetchFavoriteTracks, fetchUserData } from "./functions/favorites";
|
||||
|
||||
export const useFavoriteArtists = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoriteArtists],
|
||||
queryFn: () => fetchFavoriteArtists()
|
||||
});
|
||||
|
||||
export const useFavoriteAlbums = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoriteAlbums],
|
||||
queryFn: () => fetchFavoriteAlbums()
|
||||
});
|
||||
|
||||
export const useFavoritePlaylists = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoritePlaylists],
|
||||
queryFn: () => fetchFavoritePlaylists()
|
||||
});
|
||||
|
||||
export const useFavoriteTracks = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoriteTracks],
|
||||
queryFn: () => fetchFavoriteTracks()
|
||||
});
|
||||
|
||||
export const useUserData = (itemId: string) => useQuery({
|
||||
queryKey: [QueryKeys.UserData, itemId],
|
||||
queryFn: () => fetchUserData(itemId)
|
||||
});
|
||||
@@ -8,6 +8,8 @@ export function fetchItemImage(itemId: string, imageType: ImageType, width: numb
|
||||
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
|
||||
console.debug("Fetching item image");
|
||||
|
||||
// Make sure images folder exists in cache, create if it doesn't
|
||||
if (!(await FileSystem.exists(`${Dirs.CacheDir}/images`)))
|
||||
await FileSystem.mkdir(`${Dirs.CacheDir}/images`)
|
||||
@@ -47,7 +49,7 @@ export function fetchItemImage(itemId: string, imageType: ImageType, width: numb
|
||||
});
|
||||
}
|
||||
|
||||
function getImageFilePath(itemId: string, width: number, height: number, imageType: ImageType) {
|
||||
export function getImageFilePath(itemId: string, width: number, height: number, imageType: ImageType) {
|
||||
return `${Dirs.CacheDir}/images/${itemId}_${imageType}_${width}x${height}.png`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import Client from "../../../api/client";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { isEmpty } from "lodash";
|
||||
|
||||
export async function fetchItem(itemId: string) : Promise<BaseItemDto> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if (isEmpty(itemId))
|
||||
reject("No item ID proviced")
|
||||
|
||||
getItemsApi(Client.api!)
|
||||
.getItems({
|
||||
ids: [
|
||||
|
||||
@@ -2,8 +2,15 @@ import Client from "../../client";
|
||||
import { BaseItemDto, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
export function fetchUserPlaylists(): Promise<BaseItemDto[]> {
|
||||
console.debug("Fetching user playlists");
|
||||
export function fetchUserPlaylists(
|
||||
sortBy: ItemSortBy[] = []
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug(`Fetching user playlists ${sortBy.length > 0 ? "sorting by " + sortBy.toString() : ""}`);
|
||||
|
||||
const defaultSorting : ItemSortBy[] = [
|
||||
ItemSortBy.IsFolder,
|
||||
ItemSortBy.SortName,
|
||||
]
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
getItemsApi(Client.api!)
|
||||
@@ -13,10 +20,7 @@ export function fetchUserPlaylists(): Promise<BaseItemDto[]> {
|
||||
fields: [
|
||||
"Path"
|
||||
],
|
||||
sortBy: [
|
||||
ItemSortBy.IsFolder,
|
||||
ItemSortBy.SortName
|
||||
],
|
||||
sortBy: sortBy.concat(defaultSorting),
|
||||
sortOrder: [
|
||||
SortOrder.Ascending
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
||||
import { QueryConfig } from "../query.config";
|
||||
import Client from "../../client";
|
||||
|
||||
export function fetchRecentlyPlayed(): Promise<BaseItemDto[]> {
|
||||
export function fetchRecentlyPlayed(offset?: number | undefined): Promise<BaseItemDto[]> {
|
||||
|
||||
console.debug("Fetching recently played items");
|
||||
|
||||
@@ -13,6 +13,7 @@ export function fetchRecentlyPlayed(): Promise<BaseItemDto[]> {
|
||||
includeItemTypes: [
|
||||
BaseItemKind.Audio
|
||||
],
|
||||
startIndex: offset,
|
||||
limit: QueryConfig.limits.recents,
|
||||
parentId: Client.library!.musicLibraryId,
|
||||
recursive: true,
|
||||
@@ -39,8 +40,8 @@ export function fetchRecentlyPlayed(): Promise<BaseItemDto[]> {
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchRecentlyPlayedArtists() : Promise<BaseItemDto[]> {
|
||||
return fetchRecentlyPlayed()
|
||||
export function fetchRecentlyPlayedArtists(offset?: number | undefined) : Promise<BaseItemDto[]> {
|
||||
return fetchRecentlyPlayed(offset)
|
||||
.then((tracks) => {
|
||||
return getItemsApi(Client.api!)
|
||||
.getItems({
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchItemImage } from "./functions/images";
|
||||
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { QueryConfig } from "./query.config";
|
||||
|
||||
export const useItemImage = (itemId: string, imageType: ImageType = ImageType.Primary, width: number = 150, height: number = 150) => useQuery({
|
||||
queryKey: [
|
||||
QueryKeys.ItemImage,
|
||||
itemId,
|
||||
imageType,
|
||||
Math.ceil(width / 100) * 100, // Images are fetched at a higher, generic resolution
|
||||
Math.ceil(height / 100) * 100 // So these keys need to match
|
||||
],
|
||||
queryFn: () => fetchItemImage(itemId, imageType, width, height),
|
||||
retry: 2,
|
||||
staleTime: QueryConfig.staleTime.oneDay,
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchItem } from "./functions/item";
|
||||
|
||||
export const useItem = (itemId: string) => useQuery({
|
||||
queryKey: [QueryKeys.Item, itemId],
|
||||
queryFn: () => fetchItem(itemId)
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchMusicLibraries, fetchPlaylistLibrary, fetchUserViews } from "./functions/libraries";
|
||||
|
||||
/**
|
||||
* @deprecated use {@link useUserViews} instead as that will respect user permissions
|
||||
* @returns
|
||||
*/
|
||||
export const useMusicLibraries = () => useQuery({
|
||||
queryKey: [QueryKeys.Libraries],
|
||||
queryFn: () => fetchMusicLibraries()
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated use {@link useUserViews} instead as that will respect user permissions
|
||||
* @returns
|
||||
*/
|
||||
export const usePlaylistLibrary = () => useQuery({
|
||||
queryKey: [QueryKeys.Playlist],
|
||||
queryFn: () => fetchPlaylistLibrary()
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export const useUserViews = () => useQuery({
|
||||
queryKey: [QueryKeys.UserViews],
|
||||
queryFn: () => fetchUserViews()
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUserPlaylists } from "./functions/playlists";
|
||||
import { fetchFavoritePlaylists } from "./functions/favorites";
|
||||
|
||||
export const useFavoritePlaylists = () => useQuery({
|
||||
queryKey: [QueryKeys.FavoritePlaylists],
|
||||
queryFn: () => fetchFavoritePlaylists()
|
||||
});
|
||||
|
||||
export const useUserPlaylists = () => useQuery({
|
||||
queryKey: [QueryKeys.UserPlaylists],
|
||||
queryFn: () => fetchUserPlaylists()
|
||||
});
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from "./functions/recents";
|
||||
|
||||
export const useRecentlyPlayed = () => useQuery({
|
||||
queryKey: [QueryKeys.RecentlyPlayed],
|
||||
queryFn: () => fetchRecentlyPlayed()
|
||||
});
|
||||
|
||||
export const useRecentlyPlayedArtists = () => useQuery({
|
||||
queryKey: [QueryKeys.RecentlyPlayedArtists],
|
||||
queryFn: () => fetchRecentlyPlayedArtists()
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSearchSuggestions } from "./functions/suggestions";
|
||||
|
||||
export const useSearchSuggestions = () => useQuery({
|
||||
queryKey: [QueryKeys.SearchSuggestions],
|
||||
queryFn: () => fetchSearchSuggestions()
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { ItemSortBy } from "@jellyfin/sdk/lib/generated-client/models/item-sort-by";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Client from "../client";
|
||||
|
||||
export const useItemTracks = (itemId: string, sort: boolean = false) => useQuery({
|
||||
queryKey: [QueryKeys.ItemTracks, itemId, sort],
|
||||
queryFn: () => {
|
||||
|
||||
console.debug(`Fetching item tracks ${sort ? "sorted" : "unsorted"}`)
|
||||
|
||||
let sortBy: ItemSortBy[] = [];
|
||||
|
||||
if (sort) {
|
||||
sortBy = [
|
||||
ItemSortBy.ParentIndexNumber,
|
||||
ItemSortBy.IndexNumber,
|
||||
ItemSortBy.SortName
|
||||
]
|
||||
}
|
||||
|
||||
return getItemsApi(Client.api!).getItems({
|
||||
parentId: itemId,
|
||||
sortBy
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data.Items ? response.data.Items! : [];
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1,26 +1,27 @@
|
||||
import { StackParamList } from "../types";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { HomeAlbumProps } from "../types";
|
||||
import { YStack, XStack, Separator } from "tamagui";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { ItemSortBy } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { H3, H5, Text } from "../Global/helpers/text";
|
||||
import { FlatList } from "react-native";
|
||||
import { RunTimeTicks } from "../Global/helpers/time-codes";
|
||||
import Track from "../Global/components/track";
|
||||
import { useItemTracks } from "../../api/queries/tracks";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import FavoriteButton from "../Global/components/favorite-button";
|
||||
import BlurhashedImage from "../Global/components/blurhashed-image";
|
||||
import Avatar from "../Global/components/avatar";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import Client from "../../api/client";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface AlbumProps {
|
||||
album: BaseItemDto,
|
||||
navigation: NativeStackNavigationProp<StackParamList>;
|
||||
}
|
||||
|
||||
export default function Album({
|
||||
album,
|
||||
navigation
|
||||
}: AlbumProps): React.JSX.Element {
|
||||
export function AlbumScreen({
|
||||
route,
|
||||
navigation
|
||||
} : HomeAlbumProps): React.JSX.Element {
|
||||
|
||||
const { album } = route.params;
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () => {
|
||||
@@ -31,30 +32,57 @@ export default function Album({
|
||||
})
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
const { data: tracks } = useItemTracks(album.Id!, true);
|
||||
const { data: tracks } = useQuery({
|
||||
queryKey: [QueryKeys.ItemTracks, album.Id!],
|
||||
queryFn: () => {
|
||||
|
||||
let sortBy: ItemSortBy[] = [];
|
||||
|
||||
sortBy = [
|
||||
ItemSortBy.ParentIndexNumber,
|
||||
ItemSortBy.IndexNumber,
|
||||
ItemSortBy.SortName
|
||||
]
|
||||
|
||||
return getItemsApi(Client.api!).getItems({
|
||||
parentId: album.Id!,
|
||||
sortBy
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data.Items ? response.data.Items! : [];
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={tracks}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
numColumns={1}
|
||||
ItemSeparatorComponent={() => <Separator />}
|
||||
ListHeaderComponent={() => (
|
||||
<YStack
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
marginTop={"$4"}
|
||||
minHeight={width / 1.1}
|
||||
>
|
||||
<BlurhashedImage
|
||||
item={album}
|
||||
width={width / 1.1}
|
||||
height={width / 1.1}
|
||||
/>
|
||||
ListHeaderComponent={(
|
||||
useMemo(() => {
|
||||
return (
|
||||
<YStack
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
marginTop={"$4"}
|
||||
minHeight={width / 1.1}
|
||||
>
|
||||
<BlurhashedImage
|
||||
item={album}
|
||||
width={width / 1.1}
|
||||
height={width / 1.1}
|
||||
/>
|
||||
|
||||
<H5>{ album.Name ?? "Untitled Album" }</H5>
|
||||
<Text>{ album.ProductionYear?.toString() ?? "" }</Text>
|
||||
</YStack>
|
||||
<H5>{ album.Name ?? "Untitled Album" }</H5>
|
||||
<Text>{ album.ProductionYear?.toString() ?? "" }</Text>
|
||||
</YStack>
|
||||
)
|
||||
}, [
|
||||
album
|
||||
])
|
||||
|
||||
)}
|
||||
renderItem={({ item: track, index }) => {
|
||||
@@ -65,11 +93,12 @@ export default function Album({
|
||||
tracklist={tracks!}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
queue={album}
|
||||
/>
|
||||
)
|
||||
|
||||
}}
|
||||
ListFooterComponent={() => (
|
||||
ListFooterComponent={(
|
||||
<YStack justifyContent="flex-start">
|
||||
<XStack flex={1} marginTop={"$3"} justifyContent="flex-end">
|
||||
<Text
|
||||
@@ -85,6 +114,7 @@ export default function Album({
|
||||
<H3>Album Artists</H3>
|
||||
<FlatList
|
||||
horizontal
|
||||
keyExtractor={(item) => item.Id!}
|
||||
data={album.ArtistItems}
|
||||
renderItem={({ index, item: artist }) => {
|
||||
return (
|
||||
@@ -105,7 +135,9 @@ export default function Album({
|
||||
</YStack>
|
||||
|
||||
)}
|
||||
// style={{
|
||||
// overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
// }}
|
||||
/>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { RouteProp } from "@react-navigation/native";
|
||||
import Album from "../component";
|
||||
import { StackParamList } from "../../types";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
|
||||
|
||||
export function AlbumScreen({ route, navigation } : { route: RouteProp<StackParamList, "Album">, navigation: NativeStackNavigationProp<StackParamList> }): React.JSX.Element {
|
||||
return (
|
||||
<Album
|
||||
album={route.params.album }
|
||||
navigation={navigation}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useFavoriteAlbums } from "../../api/queries/favorites";
|
||||
import { AlbumsProps } from "../types";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import { ItemCard } from "../Global/components/item-card";
|
||||
import { FlatList, RefreshControl } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchFavoriteAlbums } from "../../api/queries/functions/favorites";
|
||||
|
||||
export default function Albums({ navigation }: AlbumsProps) : React.JSX.Element {
|
||||
const { data: albums, refetch, isPending } = useFavoriteAlbums();
|
||||
const { data: albums, refetch, isPending } = useQuery({
|
||||
queryKey: [QueryKeys.FavoriteAlbums],
|
||||
queryFn: () => fetchFavoriteAlbums()
|
||||
});
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
@@ -34,6 +39,9 @@ export default function Albums({ navigation }: AlbumsProps) : React.JSX.Element
|
||||
/>
|
||||
)
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { ScrollView, YStack } from "tamagui";
|
||||
import { useArtistAlbums } from "../../api/queries/artist";
|
||||
import { FlatList } from "react-native";
|
||||
import { ItemCard } from "../Global/components/item-card";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../types";
|
||||
import { H2 } from "../Global/helpers/text";
|
||||
import { useState } from "react";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemDto, BaseItemKind, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import FavoriteButton from "../Global/components/favorite-button";
|
||||
import BlurhashedImage from "../Global/components/blurhashed-image";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import Client from "../../api/client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
interface ArtistProps {
|
||||
artist: BaseItemDto
|
||||
@@ -35,12 +38,33 @@ export default function Artist({
|
||||
|
||||
const bannerHeight = height / 6;
|
||||
|
||||
const { data: albums } = useArtistAlbums(artist.Id!);
|
||||
const { data: albums } = useQuery({
|
||||
queryKey: [QueryKeys.ArtistAlbums, artist.Id!],
|
||||
queryFn: ({ queryKey }) => {
|
||||
return getItemsApi(Client.api!).getItems({
|
||||
includeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
recursive: true,
|
||||
excludeItemIds: [queryKey[1] as string],
|
||||
sortBy: [
|
||||
ItemSortBy.PremiereDate,
|
||||
ItemSortBy.ProductionYear,
|
||||
ItemSortBy.SortName
|
||||
],
|
||||
sortOrder: [SortOrder.Descending],
|
||||
artistIds: [queryKey[1] as string],
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data.Items ? response.data.Items! : [];
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
alignContent="center"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
alignContent="center">
|
||||
removeClippedSubviews
|
||||
>
|
||||
<YStack alignContent="center" justifyContent="center" minHeight={bannerHeight}>
|
||||
<BlurhashedImage
|
||||
borderRadius={0}
|
||||
@@ -73,6 +97,9 @@ export default function Artist({
|
||||
/>
|
||||
)
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { useFavoriteArtists } from "../../api/queries/favorites";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import React from "react";
|
||||
import { FlatList, RefreshControl } from "react-native";
|
||||
import { ItemCard } from "../Global/components/item-card";
|
||||
import { ArtistsProps } from "../types";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchRecentlyPlayedArtists } from "../../api/queries/functions/recents";
|
||||
import { fetchFavoriteArtists } from "../../api/queries/functions/favorites";
|
||||
|
||||
export default function Artists({ navigation }: ArtistsProps): React.JSX.Element {
|
||||
export default function Artists({
|
||||
navigation,
|
||||
route
|
||||
}: ArtistsProps): React.JSX.Element {
|
||||
|
||||
const { data: artists, refetch, isPending } = useFavoriteArtists();
|
||||
const { data: artists, refetch, isPending } =
|
||||
route.params.query ===
|
||||
QueryKeys.FavoriteArtists ? useQuery({
|
||||
queryKey: [QueryKeys.FavoriteArtists],
|
||||
queryFn: () => fetchFavoriteArtists()
|
||||
}) :
|
||||
|
||||
QueryKeys.RecentlyPlayedArtists ? useQuery({
|
||||
queryKey: [QueryKeys.RecentlyPlayedArtists],
|
||||
queryFn: () => fetchRecentlyPlayedArtists()
|
||||
}) :
|
||||
|
||||
useQuery({
|
||||
queryKey: [QueryKeys.FavoriteArtists],
|
||||
queryFn: () => fetchFavoriteArtists()
|
||||
});
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
@@ -34,6 +55,9 @@ export default function Artists({ navigation }: ArtistsProps): React.JSX.Element
|
||||
/>
|
||||
)
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -18,12 +18,21 @@ const CarPlayHome : ListTemplate = new ListTemplate({
|
||||
]
|
||||
}
|
||||
],
|
||||
onItemSelect: async (item) => {
|
||||
console.log(item);
|
||||
onItemSelect: async ({ index }) => {
|
||||
|
||||
const tracks = await fetchRecentlyPlayed()
|
||||
switch (index) {
|
||||
case 0:
|
||||
|
||||
CarPlay.pushTemplate(CarPlayRecentlyPlayed(tracks))
|
||||
break;
|
||||
case 1:
|
||||
const tracks = await fetchRecentlyPlayed()
|
||||
CarPlay.pushTemplate(CarPlayRecentlyPlayed(tracks))
|
||||
break;
|
||||
case 2:
|
||||
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { getImageFilePath } from "../../api/queries/functions/images";
|
||||
import Client from "../../api/client";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { GridTemplate } from "react-native-carplay";
|
||||
import { GridTemplate, ListTemplate } from "react-native-carplay";
|
||||
|
||||
export const CarPlayRecentlyPlayed = (recentTracks : BaseItemDto[]) => new GridTemplate({
|
||||
export const CarPlayRecentlyPlayed = (recentTracks : BaseItemDto[]) => new ListTemplate({
|
||||
title: "Recently Played",
|
||||
buttons: recentTracks.map(track => {
|
||||
items: recentTracks.map(track => {
|
||||
return {
|
||||
id: track.Id!,
|
||||
titleVariants: [
|
||||
track.Name ? track.Name : "Untitled Track"
|
||||
],
|
||||
image: {
|
||||
uri: Client.api ? getImageApi(Client.api).getItemImageUrlById(track.Id!) : ""
|
||||
}
|
||||
text: track.Name ? track.Name : "Untitled Track",
|
||||
// image: {
|
||||
// uri: `file://${getImageFilePath(track.Id!, 150, 150, ImageType.Primary)}`
|
||||
// }
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { ScrollView } from "tamagui";
|
||||
export default function Index() : React.JSX.Element {
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<ScrollView>
|
||||
<ScrollView removeClippedSubviews>
|
||||
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
|
||||
interface CategoryRoute {
|
||||
name: any; // ¯\_(ツ)_/¯
|
||||
iconName: string;
|
||||
};
|
||||
|
||||
const Categories : CategoryRoute[] = [
|
||||
{ name: "Artists", iconName: "microphone-variant" },
|
||||
{ name: "Albums", iconName: "music-box-multiple" },
|
||||
{ name: "Tracks", iconName: "music-note"},
|
||||
{ name: "Playlists", iconName: "playlist-music"},
|
||||
];
|
||||
|
||||
export default Categories;
|
||||
@@ -7,4 +7,6 @@ export const cardDimensions = {
|
||||
width: 150,
|
||||
height: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const horizontalCardLimit = 20
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AvatarProps as TamaguiAvatarProps } from "tamagui";
|
||||
import { Avatar as TamaguiAvatar, YStack } from "tamagui"
|
||||
import { Text } from "../helpers/text"
|
||||
import { useItemImage } from "../../../api/queries/image";
|
||||
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
import { fetchItemImage } from "../../../api/queries/functions/images";
|
||||
|
||||
interface AvatarProps extends TamaguiAvatarProps {
|
||||
item: BaseItemDto,
|
||||
@@ -17,7 +19,19 @@ export default function Avatar({
|
||||
...props
|
||||
}: AvatarProps): React.JSX.Element {
|
||||
|
||||
const { data } = useItemImage(item.Id!, imageType, typeof (props.width) === 'number' ? props.width : 100)
|
||||
const { data } = useQuery({
|
||||
queryKey: [
|
||||
QueryKeys.ItemImage,
|
||||
item.Id!,
|
||||
imageType,
|
||||
Math.ceil(150 / 100) * 100, // Images are fetched at a higher, generic resolution
|
||||
Math.ceil(150 / 100) * 100 // So these keys need to match
|
||||
],
|
||||
queryFn: () => fetchItemImage(item.Id!, imageType ?? ImageType.Primary, 150, 150),
|
||||
retry: 2,
|
||||
gcTime: (1000 * 60), // 1 minute
|
||||
staleTime: (1000 * 60) // 1 minute,
|
||||
});
|
||||
|
||||
return (
|
||||
<YStack alignItems="center" marginHorizontal={10}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useItemImage } from "../../../api/queries/image";
|
||||
import { Blurhash } from "react-native-blurhash";
|
||||
import { View } from "tamagui";
|
||||
import { isEmpty } from "lodash";
|
||||
import { Image } from "react-native";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchItemImage } from "../../../api/queries/functions/images";
|
||||
|
||||
interface BlurhashLoadingProps {
|
||||
item: BaseItemDto;
|
||||
@@ -21,7 +23,16 @@ export default function BlurhashedImage({
|
||||
borderRadius
|
||||
} : BlurhashLoadingProps) : React.JSX.Element {
|
||||
|
||||
const { data: image, isSuccess } = useItemImage(item.Id!, type, width, height ?? width);
|
||||
const { data: image, isSuccess } = useQuery({
|
||||
queryKey: [
|
||||
QueryKeys.ItemImage,
|
||||
item.AlbumId ? item.AlbumId : item.Id!,
|
||||
type ?? ImageType.Primary,
|
||||
Math.ceil(width / 100) * 100, // Images are fetched at a higher, generic resolution
|
||||
Math.ceil(height ?? width / 100) * 100 // So these keys need to match
|
||||
],
|
||||
queryFn: () => fetchItemImage(item.AlbumId ? item.AlbumId : item.Id!, type ?? ImageType.Primary, width, height ?? width),
|
||||
});
|
||||
|
||||
const blurhash = !isEmpty(item.ImageBlurHashes)
|
||||
&& !isEmpty(type ? item.ImageBlurHashes[type] : item.ImageBlurHashes.Primary)
|
||||
|
||||
@@ -2,15 +2,15 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Icon from "../helpers/icon";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isUndefined } from "lodash";
|
||||
import { useUserData } from "../../../api/queries/favorites";
|
||||
import { getTokens, Spinner } from "tamagui";
|
||||
import Client from "../../../api/client";
|
||||
import { usePlayerContext } from "../../..//player/provider";
|
||||
import { queryClient } from "../../../constants/query-client";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
import { fetchUserData } from "../../../api/queries/functions/favorites";
|
||||
|
||||
interface SetFavoriteMutation {
|
||||
item: BaseItemDto,
|
||||
@@ -28,7 +28,10 @@ export default function FavoriteButton({
|
||||
|
||||
const [isFavorite, setIsFavorite] = useState<boolean>(isFavoriteItem(item));
|
||||
|
||||
const { data, isFetching, isFetched, refetch } = useUserData(item.Id!);
|
||||
const { data, isFetching, isFetched, refetch } = useQuery({
|
||||
queryKey: [QueryKeys.UserData, item.Id!],
|
||||
queryFn: () => fetchUserData(item.Id!)
|
||||
});;
|
||||
|
||||
const useSetFavorite = useMutation({
|
||||
mutationFn: async (mutation: SetFavoriteMutation) => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models/base-item-dto";
|
||||
import React from "react";
|
||||
import { FlatList, ListRenderItem } from "react-native";
|
||||
import { ItemCard } from "./item-card";
|
||||
import IconCard from "../helpers/icon-card";
|
||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { horizontalCardLimit } from "../component.config";
|
||||
|
||||
interface HorizontalCardListProps {
|
||||
items: BaseItemDto[] | undefined;
|
||||
renderItem: ListRenderItem<BaseItemDto> | null | undefined
|
||||
|
||||
squared?: boolean | undefined;
|
||||
/**
|
||||
* The number of items that will be displayed before
|
||||
* we cut it off and display a "Show More" card
|
||||
*/
|
||||
cutoff?: number | undefined;
|
||||
onSeeMore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a Horizontal FlatList of 20 ItemCards
|
||||
* then shows a "See More" button
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export default function HorizontalCardList({
|
||||
items,
|
||||
renderItem,
|
||||
cutoff = horizontalCardLimit,
|
||||
onSeeMore,
|
||||
squared = false,
|
||||
} : HorizontalCardListProps) : React.JSX.Element {
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
horizontal
|
||||
data={items?.slice(0, cutoff - 1) ?? undefined}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={() => {
|
||||
return items ? (
|
||||
<IconCard
|
||||
name={
|
||||
squared
|
||||
? "arrow-right-box"
|
||||
: "arrow-right-circle"
|
||||
}
|
||||
circular={!squared}
|
||||
caption="See More"
|
||||
onPress={onSeeMore}
|
||||
/>
|
||||
) : undefined}
|
||||
}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default function Item({
|
||||
usePlayNewQueue.mutate({
|
||||
track: item,
|
||||
tracklist: [item],
|
||||
queueName,
|
||||
queue: "Search",
|
||||
queuingType: QueuingType.FromSelection
|
||||
})
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ToastViewport } from '@tamagui/toast'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
|
||||
export default function SafeToastViewport() : React.JSX.Element {
|
||||
const { left, top, right } = useSafeAreaInsets()
|
||||
return (
|
||||
<ToastViewport flexDirection="column-reverse" top={top} left={left} right={right} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {Toast as TamaguiToast, useToastState} from "@tamagui/toast"
|
||||
import { YStack } from "tamagui"
|
||||
|
||||
export default function Toast() : React.JSX.Element | null {
|
||||
const currentToast = useToastState()
|
||||
|
||||
if (!currentToast || currentToast.isHandledNatively) return null
|
||||
return (
|
||||
<TamaguiToast
|
||||
key={currentToast.id}
|
||||
duration={currentToast.duration}
|
||||
enterStyle={{ opacity: 0, scale: 0.5, y: -25 }}
|
||||
exitStyle={{ opacity: 0, scale: 1, y: -20 }}
|
||||
y={0}
|
||||
opacity={1}
|
||||
scale={1}
|
||||
animation="200ms"
|
||||
viewportName={currentToast.viewportName}
|
||||
>
|
||||
<YStack>
|
||||
<TamaguiToast.Title>{currentToast.title}</TamaguiToast.Title>
|
||||
{!!currentToast.message && (
|
||||
<TamaguiToast.Description>{currentToast.message}</TamaguiToast.Description>
|
||||
)}
|
||||
</YStack>
|
||||
</TamaguiToast>
|
||||
)
|
||||
}
|
||||
@@ -10,19 +10,22 @@ import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { QueuingType } from "../../../enums/queuing-type";
|
||||
import BlurhashedImage from "./blurhashed-image";
|
||||
import { Queue } from "../../../player/types/queue-item";
|
||||
|
||||
interface TrackProps {
|
||||
track: BaseItemDto;
|
||||
navigation: NativeStackNavigationProp<StackParamList>;
|
||||
tracklist?: BaseItemDto[] | undefined;
|
||||
index?: number | undefined;
|
||||
queueName?: string | undefined;
|
||||
queue: Queue;
|
||||
showArtwork?: boolean | undefined;
|
||||
onPress?: () => void | undefined;
|
||||
onLongPress?: () => void | undefined;
|
||||
isNested?: boolean | undefined;
|
||||
invertedColors?: boolean | undefined;
|
||||
prependElement?: React.JSX.Element | undefined
|
||||
prependElement?: React.JSX.Element | undefined;
|
||||
showRemove?: boolean | undefined;
|
||||
onRemove?: () => void | undefined;
|
||||
}
|
||||
|
||||
export default function Track({
|
||||
@@ -30,18 +33,20 @@ export default function Track({
|
||||
tracklist,
|
||||
navigation,
|
||||
index,
|
||||
queueName,
|
||||
queue,
|
||||
showArtwork,
|
||||
onPress,
|
||||
onLongPress,
|
||||
isNested,
|
||||
invertedColors,
|
||||
prependElement
|
||||
prependElement,
|
||||
showRemove,
|
||||
onRemove
|
||||
} : TrackProps) : React.JSX.Element {
|
||||
|
||||
const theme = useTheme();
|
||||
const { width } = useSafeAreaFrame();
|
||||
const { nowPlaying, queue, usePlayNewQueue } = usePlayerContext();
|
||||
const { nowPlaying, playQueue, usePlayNewQueue } = usePlayerContext();
|
||||
|
||||
const isPlaying = nowPlaying?.item.Id === track.Id;
|
||||
|
||||
@@ -58,8 +63,8 @@ export default function Track({
|
||||
usePlayNewQueue.mutate({
|
||||
track,
|
||||
index,
|
||||
tracklist: tracklist ?? queue.map((track) => track.item),
|
||||
queueName: queueName ? queueName : track.Album ? track.Album! : "Queue",
|
||||
tracklist: tracklist ?? playQueue.map((track) => track.item),
|
||||
queue,
|
||||
queuingType: QueuingType.FromSelection
|
||||
});
|
||||
}
|
||||
@@ -162,12 +167,18 @@ export default function Track({
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon
|
||||
name="dots-vertical"
|
||||
name={showRemove ? "close" : "dots-vertical"}
|
||||
onPress={() => {
|
||||
navigation.navigate("Details", {
|
||||
item: track,
|
||||
isNested: isNested
|
||||
});
|
||||
if (!!showRemove) {
|
||||
if (onRemove)
|
||||
onRemove()
|
||||
}
|
||||
else {
|
||||
navigation.navigate("Details", {
|
||||
item: track,
|
||||
isNested: isNested
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function Button(props: ButtonProps): React.JSX.Element {
|
||||
return (
|
||||
<TamaguiButton
|
||||
disabled={props.disabled}
|
||||
bordered
|
||||
marginVertical={30}
|
||||
onPress={props.onPress}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Icon from "./icon";
|
||||
|
||||
interface IconCardProps {
|
||||
name: string;
|
||||
circular?: boolean | undefined;
|
||||
onPress: () => void;
|
||||
width?: number | undefined
|
||||
caption?: string | undefined;
|
||||
@@ -12,6 +13,7 @@ interface IconCardProps {
|
||||
|
||||
export default function IconCard({
|
||||
name,
|
||||
circular = false,
|
||||
onPress,
|
||||
width,
|
||||
caption,
|
||||
@@ -25,7 +27,7 @@ export default function IconCard({
|
||||
>
|
||||
<Card
|
||||
animation="bouncy"
|
||||
borderRadius={25}
|
||||
borderRadius={circular ? 300 : 25}
|
||||
hoverStyle={{ scale: 0.925 }}
|
||||
pressStyle={{ scale: 0.875 }}
|
||||
width={width ? width : 150}
|
||||
@@ -33,6 +35,7 @@ export default function IconCard({
|
||||
onPress={onPress}
|
||||
>
|
||||
<Card.Header>
|
||||
<H4 color={getTokens().color.purpleDark}>{ caption ?? "" }</H4>
|
||||
<Icon
|
||||
color={getTokens().color.purpleDark.val}
|
||||
name={name}
|
||||
@@ -41,9 +44,11 @@ export default function IconCard({
|
||||
/>
|
||||
</Card.Header>
|
||||
<Card.Footer padded>
|
||||
<H4 color={getTokens().color.purpleDark}>{ caption ?? "" }</H4>
|
||||
</Card.Footer>
|
||||
<Card.Background backgroundColor={getTokens().color.telemagenta}>
|
||||
<Card.Background
|
||||
backgroundColor={getTokens().color.telemagenta}
|
||||
borderRadius={circular ? 300 : 25}
|
||||
>
|
||||
|
||||
</Card.Background>
|
||||
</Card>
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Input as TamaguiInput, InputProps as TamaguiInputProps} from 'tamagui';
|
||||
import { Input as TamaguiInput, InputProps as TamaguiInputProps, XStack, YStack} from 'tamagui';
|
||||
|
||||
interface InputProps extends TamaguiInputProps {
|
||||
prependElement?: React.JSX.Element | undefined;
|
||||
}
|
||||
|
||||
export default function Input(props: InputProps): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<TamaguiInput
|
||||
{...props}
|
||||
clearButtonMode="always"
|
||||
/>
|
||||
<XStack>
|
||||
|
||||
|
||||
{ props.prependElement && (
|
||||
<YStack
|
||||
flex={1}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
>
|
||||
{ props.prependElement }
|
||||
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
<TamaguiInput
|
||||
flex={props.prependElement ? 8 : 1}
|
||||
{...props}
|
||||
clearButtonMode="always"
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ interface LabelProps {
|
||||
|
||||
export function Label(props: LabelProps): React.JSX.Element {
|
||||
return (
|
||||
<TamaguiLabel htmlFor={props.htmlFor} justifyContent="flex-end">{ props.children }</TamaguiLabel>
|
||||
<TamaguiLabel fontWeight={600} htmlFor={props.htmlFor} justifyContent="flex-end">{ props.children }</TamaguiLabel>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { StackParamList } from "../types";
|
||||
import { ScrollView, RefreshControl } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { YStack, XStack, Separator } from "tamagui";
|
||||
import Playlists from "./helpers/playlists";
|
||||
import RecentArtists from "./helpers/recent-artists";
|
||||
import RecentlyPlayed from "./helpers/recently-played";
|
||||
import { useHomeContext } from "./provider";
|
||||
import { H3 } from "../Global/helpers/text";
|
||||
import Avatar from "../Global/components/avatar";
|
||||
import Client from "../../api/client";
|
||||
import { usePlayerContext } from "../../player/provider";
|
||||
import { useEffect } from "react";
|
||||
@@ -30,15 +28,16 @@ export function ProvidedHome({
|
||||
])
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={["top", "right", "left"]}>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refetching}
|
||||
onRefresh={onRefetch}
|
||||
refreshing={refetching}
|
||||
onRefresh={onRefetch}
|
||||
/>
|
||||
}>
|
||||
}
|
||||
removeClippedSubviews // Save memory usage
|
||||
>
|
||||
<YStack alignContent='flex-start'>
|
||||
<XStack margin={"$2"}>
|
||||
<H3>{`Hi, ${Client.user!.name}`}</H3>
|
||||
@@ -57,6 +56,5 @@ export function ProvidedHome({
|
||||
<Playlists navigation={navigation}/>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useUserPlaylists } from "../../../api/queries/playlist";
|
||||
import { ItemCard } from "../../Global/components/item-card";
|
||||
import { H2 } from "../../../components/Global/helpers/text";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import React from "react";
|
||||
import { FlatList } from "react-native";
|
||||
import { getToken, View, XStack, YStack } from "tamagui";
|
||||
import Icon from "../../../components/Global/helpers/icon";
|
||||
import { View, XStack } from "tamagui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
import { fetchUserPlaylists } from "../../../api/queries/functions/playlists";
|
||||
|
||||
export default function Playlists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}) : React.JSX.Element {
|
||||
|
||||
const { data: playlists } = useUserPlaylists();
|
||||
const { data: playlists } = useQuery({
|
||||
queryKey: [QueryKeys.UserPlaylists],
|
||||
queryFn: () => fetchUserPlaylists()
|
||||
});
|
||||
|
||||
return (
|
||||
<View>
|
||||
<XStack alignContent="center" marginHorizontal={"$2"}>
|
||||
<H2 textAlign="left">Your Playlists</H2>
|
||||
|
||||
<YStack justifyContent="center" alignContent="center" marginTop={7} marginLeft={"$2"}>
|
||||
<Icon name="plus-circle-outline" color={getToken("$color.amethyst")} onPress={() => navigation.navigate('AddPlaylist')}/>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<FlatList horizontal
|
||||
data={playlists}
|
||||
@@ -35,7 +35,11 @@ export default function Playlists({ navigation }: { navigation: NativeStackNavig
|
||||
})
|
||||
}} />
|
||||
)
|
||||
}} />
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import { View } from "tamagui";
|
||||
import { useHomeContext } from "../provider";
|
||||
import { H2 } from "../../Global/helpers/text";
|
||||
import { StackParamList } from "../../types";
|
||||
import { FlatList } from "react-native";
|
||||
import { ItemCard } from "../../Global/components/item-card";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import HorizontalCardList from "../../../components/Global/components/horizontal-list";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
|
||||
export default function RecentArtists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
|
||||
|
||||
@@ -14,8 +15,14 @@ export default function RecentArtists({ navigation }: { navigation: NativeStackN
|
||||
return (
|
||||
<View>
|
||||
<H2 marginLeft={"$2"}>Recent Artists</H2>
|
||||
<FlatList horizontal
|
||||
data={recentArtists}
|
||||
|
||||
<HorizontalCardList
|
||||
items={recentArtists}
|
||||
onSeeMore={() => {
|
||||
navigation.navigate("Artists", {
|
||||
query: QueryKeys.RecentlyPlayedArtists
|
||||
})
|
||||
}}
|
||||
renderItem={({ item: recentArtist}) => {
|
||||
return (
|
||||
<ItemCard
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { getToken, ScrollView, View, XStack, YStack } from "tamagui";
|
||||
import { View } from "tamagui";
|
||||
import { useHomeContext } from "../provider";
|
||||
import { H2 } from "../../Global/helpers/text";
|
||||
import { ItemCard } from "../../Global/components/item-card";
|
||||
@@ -8,7 +8,8 @@ import { StackParamList } from "../../../components/types";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
import { QueuingType } from "../../../enums/queuing-type";
|
||||
import Icon from "../../../components/Global/helpers/icon";
|
||||
import HorizontalCardList from "../../../components/Global/components/horizontal-list";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
|
||||
export default function RecentlyPlayed({
|
||||
navigation
|
||||
@@ -21,25 +22,17 @@ export default function RecentlyPlayed({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<XStack alignContent="center" marginHorizontal="$2">
|
||||
<H2 textAlign="left">Play it again</H2>
|
||||
<H2 marginLeft={"$2"}>Play it again</H2>
|
||||
|
||||
{ recentTracks && (
|
||||
<YStack justifyContent="center" alignContent="center" marginTop={7} marginLeft={"$2"}>
|
||||
<Icon name="play-circle-outline" color={getToken("$color.amethyst")} onPress={() => {
|
||||
usePlayNewQueue.mutate({
|
||||
track: recentTracks[0],
|
||||
index: 0,
|
||||
tracklist: recentTracks,
|
||||
queueName: "Recently Played",
|
||||
queuingType: QueuingType.FromSelection
|
||||
});
|
||||
}}/>
|
||||
</YStack>
|
||||
)}
|
||||
</XStack>
|
||||
<ScrollView horizontal>
|
||||
{ recentTracks && recentTracks.map((recentlyPlayedTrack, index) => {
|
||||
<HorizontalCardList
|
||||
squared
|
||||
items={recentTracks}
|
||||
onSeeMore={() => {
|
||||
navigation.navigate("Tracks", {
|
||||
query: QueryKeys.RecentlyPlayed
|
||||
})
|
||||
}}
|
||||
renderItem={({ index, item: recentlyPlayedTrack }) => {
|
||||
return (
|
||||
<ItemCard
|
||||
caption={recentlyPlayedTrack.Name}
|
||||
@@ -51,8 +44,8 @@ export default function RecentlyPlayed({
|
||||
usePlayNewQueue.mutate({
|
||||
track: recentlyPlayedTrack,
|
||||
index: index,
|
||||
tracklist: recentTracks,
|
||||
queueName: "Recently Played",
|
||||
tracklist: recentTracks ?? [recentlyPlayedTrack],
|
||||
queue: "Recently Played",
|
||||
queuingType: QueuingType.FromSelection
|
||||
});
|
||||
}}
|
||||
@@ -65,8 +58,8 @@ export default function RecentlyPlayed({
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { createContext, ReactNode, useContext, useState } from "react";
|
||||
import { useRecentlyPlayed, useRecentlyPlayedArtists } from "../../api/queries/recently-played";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from "../../api/queries/functions/recents";
|
||||
|
||||
interface HomeContext {
|
||||
refreshing: boolean;
|
||||
@@ -12,8 +14,14 @@ interface HomeContext {
|
||||
const HomeContextInitializer = () => {
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
|
||||
const { data : recentTracks, refetch : refetchRecentTracks } = useRecentlyPlayed();
|
||||
const { data : recentArtists, refetch : refetchRecentArtists } = useRecentlyPlayedArtists();
|
||||
const { data : recentTracks, refetch : refetchRecentTracks } = useQuery({
|
||||
queryKey: [QueryKeys.RecentlyPlayed],
|
||||
queryFn: () => fetchRecentlyPlayed()
|
||||
});
|
||||
const { data : recentArtists, refetch : refetchRecentArtists } = useQuery({
|
||||
queryKey: [QueryKeys.RecentlyPlayedArtists],
|
||||
queryFn: () => fetchRecentlyPlayedArtists()
|
||||
});
|
||||
|
||||
const onRefresh = async () => {
|
||||
await Promise.all([
|
||||
|
||||
@@ -20,6 +20,9 @@ export default function RecentArtistsScreen({
|
||||
renderItem={({ index, item: artist }) => {
|
||||
return <Item item={artist} queueName="" navigation={navigation} />
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export default function RecentArtistsScreen({
|
||||
renderItem={({ index, item: artist }) => {
|
||||
return <Item item={artist} queueName="" navigation={navigation} />
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export default function RecentArtistsScreen({
|
||||
renderItem={({ index, item: track }) => {
|
||||
return <Item item={track} queueName="Recently Played" navigation={navigation} />
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import { HomeProvider } from "./provider";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../types";
|
||||
import { ArtistScreen } from "../Artist/screens";
|
||||
import { AlbumScreen } from "../Album/screens";
|
||||
import { AlbumScreen } from "../Album";
|
||||
import { PlaylistScreen } from "../Playlist/screens";
|
||||
import { ProvidedHome } from "./component";
|
||||
import DetailsScreen from "../ItemDetail/screen";
|
||||
import Player from "../Player/stack";
|
||||
import AddPlaylist from "./screens/add-playlist";
|
||||
import AddPlaylist from "../Library/components/add-playlist";
|
||||
import ArtistsScreen from "../Artists/screen";
|
||||
import TracksScreen from "../Tracks/screen";
|
||||
|
||||
const HomeStack = createNativeStackNavigator<StackParamList>();
|
||||
|
||||
@@ -26,10 +27,10 @@ export default function Home(): React.JSX.Element {
|
||||
name="Home"
|
||||
component={ProvidedHome}
|
||||
options={{
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
}
|
||||
// headerLargeTitle: true,
|
||||
// headerLargeTitleStyle: {
|
||||
// fontFamily: 'Aileron-Bold'
|
||||
// }
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -45,6 +46,19 @@ export default function Home(): React.JSX.Element {
|
||||
})}
|
||||
/>
|
||||
|
||||
<HomeStack.Screen
|
||||
name="Artists"
|
||||
component={ArtistsScreen}
|
||||
/>
|
||||
|
||||
<HomeStack.Screen
|
||||
name="Tracks"
|
||||
component={TracksScreen}
|
||||
options={{
|
||||
title: "Recent Tracks"
|
||||
}}
|
||||
/>
|
||||
|
||||
<HomeStack.Screen
|
||||
name="Album"
|
||||
component={AlbumScreen}
|
||||
@@ -65,21 +79,6 @@ export default function Home(): React.JSX.Element {
|
||||
|
||||
</HomeStack.Group>
|
||||
|
||||
{/* https://www.reddit.com/r/reactnative/comments/1dgktbn/comment/lxd23sj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button */}
|
||||
<HomeStack.Group screenOptions={{
|
||||
presentation: 'formSheet',
|
||||
sheetInitialDetentIndex: 0,
|
||||
sheetAllowedDetents: [0.25]
|
||||
}}>
|
||||
<HomeStack.Screen
|
||||
name="AddPlaylist"
|
||||
component={AddPlaylist}
|
||||
options={{
|
||||
title: "Add Playlist",
|
||||
}}
|
||||
/>
|
||||
</HomeStack.Group>
|
||||
|
||||
<HomeStack.Group screenOptions={{ presentation: 'modal' }}>
|
||||
<HomeStack.Screen
|
||||
name="Details"
|
||||
|
||||
@@ -59,7 +59,10 @@ export default function ItemDetail({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
removeClippedSubviews
|
||||
>
|
||||
<YStack
|
||||
alignItems="center"
|
||||
flex={1}
|
||||
@@ -126,7 +129,7 @@ export default function ItemDetail({
|
||||
|
||||
<YStack
|
||||
flex={1}
|
||||
alignContent="center"
|
||||
alignItems="flex-end"
|
||||
justifyContent="center"
|
||||
>
|
||||
<FavoriteButton item={item} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { usePlayerContext } from "../../../player/provider";
|
||||
import { useItem } from "../../../api/queries/item";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
@@ -8,15 +7,16 @@ import { QueuingType } from "../../../enums/queuing-type";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import IconButton from "../../../components/Global/helpers/icon-button";
|
||||
import { Text } from "../../../components/Global/helpers/text";
|
||||
import { useUserPlaylists } from "../../../api/queries/playlist";
|
||||
import React from "react";
|
||||
import BlurhashedImage from "../../../components/Global/components/blurhashed-image";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AddToPlaylistMutation } from "../types";
|
||||
import { addToPlaylist } from "../../../api/mutations/functions/playlists";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
import { queryClient } from "../../../constants/query-client";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
import { fetchItem } from "../../../api/queries/functions/item";
|
||||
import { fetchUserPlaylists } from "../../../api/queries/functions/playlists";
|
||||
|
||||
interface TrackOptionsProps {
|
||||
track: BaseItemDto;
|
||||
@@ -34,31 +34,70 @@ export default function TrackOptions({
|
||||
isNested
|
||||
} : TrackOptionsProps) : React.JSX.Element {
|
||||
|
||||
const { data: album, isSuccess: albumFetchSuccess } = useItem(track.AlbumId ?? "");
|
||||
const { data: album, isSuccess: albumFetchSuccess } = useQuery({
|
||||
queryKey: [QueryKeys.Item, track.AlbumId!],
|
||||
queryFn: () => fetchItem(track.AlbumId!)
|
||||
});;
|
||||
|
||||
const { data: playlists, isPending : playlistsFetchPending, isSuccess: playlistsFetchSuccess, refetch } = useUserPlaylists();
|
||||
const { data: playlists, isPending : playlistsFetchPending, isSuccess: playlistsFetchSuccess, refetch } = useQuery({
|
||||
queryKey: [QueryKeys.UserPlaylists],
|
||||
queryFn: () => fetchUserPlaylists()
|
||||
});
|
||||
;
|
||||
|
||||
const { useAddToQueue } = usePlayerContext();
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
const useAddToPlaylist = useMutation({
|
||||
mutationFn: ({ track, playlist }: AddToPlaylistMutation) => {
|
||||
return addToPlaylist(track, playlist)
|
||||
},
|
||||
onSuccess: (data, { playlist }) => {
|
||||
trigger("notificationSuccess");
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.UserPlaylists]
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.ItemTracks, playlist.Id!, false],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
trigger("notificationError")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<YStack width={width}>
|
||||
|
||||
<XStack justifyContent="space-evenly">
|
||||
{ albumFetchSuccess ? (
|
||||
{ albumFetchSuccess && album ? (
|
||||
<IconButton
|
||||
name="music-box"
|
||||
title="Go to Album"
|
||||
onPress={() => {
|
||||
|
||||
if (isNested)
|
||||
navigation.getParent()!.goBack();
|
||||
navigation.goBack();
|
||||
|
||||
navigation.goBack();
|
||||
navigation.navigate("Album", {
|
||||
album
|
||||
});
|
||||
|
||||
if (isNested)
|
||||
navigation.navigate('Tabs', {
|
||||
screen: 'Home',
|
||||
params: {
|
||||
screen: 'Album',
|
||||
params: {
|
||||
album
|
||||
}
|
||||
}
|
||||
});
|
||||
else
|
||||
navigation.navigate('Album', {
|
||||
album
|
||||
});
|
||||
}}
|
||||
size={width / 6}
|
||||
/>
|
||||
@@ -98,7 +137,7 @@ export default function TrackOptions({
|
||||
<Spinner />
|
||||
)}
|
||||
|
||||
{ playlistsFetchSuccess && (
|
||||
{ !playlistsFetchPending && playlistsFetchSuccess && (
|
||||
<>
|
||||
<Text
|
||||
bold
|
||||
@@ -110,26 +149,6 @@ export default function TrackOptions({
|
||||
<YGroup separator={(<Separator />)}>
|
||||
{ playlists.map(playlist => {
|
||||
|
||||
const useAddToPlaylist = useMutation({
|
||||
mutationFn: ({ track, playlist }: AddToPlaylistMutation) => {
|
||||
return addToPlaylist(track, playlist)
|
||||
},
|
||||
onSuccess: (data, { playlist }) => {
|
||||
trigger("notificationSuccess");
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.UserPlaylists]
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.ItemTracks, playlist.Id!, false],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
trigger("notificationError")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<YGroup.Item>
|
||||
<ListItem hoverTheme onPress={() => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
|
||||
interface CategoryRoute {
|
||||
name: any; // ¯\_(ツ)_/¯
|
||||
iconName: string;
|
||||
params?: {
|
||||
query: QueryKeys
|
||||
};
|
||||
};
|
||||
|
||||
const Categories : CategoryRoute[] = [
|
||||
{ name: "Artists", iconName: "microphone-variant", params: { query: QueryKeys.FavoriteArtists } },
|
||||
{ name: "Albums", iconName: "music-box-multiple" },
|
||||
{ name: "Tracks", iconName: "music-note", params: { query: QueryKeys.FavoriteTracks } },
|
||||
{ name: "Playlists", iconName: "playlist-music" },
|
||||
];
|
||||
|
||||
export default Categories;
|
||||
@@ -6,11 +6,11 @@ import { StackParamList } from "../../components/types";
|
||||
import { RouteProp } from "@react-navigation/native";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
|
||||
export default function FavoritesScreen({
|
||||
export default function Library({
|
||||
route,
|
||||
navigation
|
||||
} : {
|
||||
route: RouteProp<StackParamList, "Favorites">,
|
||||
route: RouteProp<StackParamList, "Library">,
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}): React.JSX.Element {
|
||||
|
||||
@@ -29,12 +29,15 @@ export default function FavoritesScreen({
|
||||
caption={item.name}
|
||||
width={width / 2.1}
|
||||
onPress={() => {
|
||||
navigation.navigate(item.name)
|
||||
navigation.navigate(item.name, item.params)
|
||||
}}
|
||||
largeIcon
|
||||
/>
|
||||
)
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Label } from "../../../components/Global/helpers/text";
|
||||
import Input from "../../../components/Global/helpers/input";
|
||||
import { Label } from "../../Global/helpers/text";
|
||||
import Input from "../../Global/helpers/input";
|
||||
import React, { useState } from "react";
|
||||
import { View, XStack } from "tamagui";
|
||||
import Button from "../../../components/Global/helpers/button";
|
||||
import Button from "../../Global/helpers/button";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { StackParamList } from "../../types";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createPlaylist } from "../../../api/mutations/functions/playlists";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
@@ -35,9 +35,9 @@ export default function AddPlaylist({
|
||||
|
||||
navigation.goBack();
|
||||
|
||||
// Refresh user playlists component on home screen
|
||||
// Refresh user playlists component in library
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.UserPlaylists]
|
||||
queryKey: [QueryKeys.FavoritePlaylists]
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
@@ -0,0 +1,57 @@
|
||||
import { View, XStack } from "tamagui";
|
||||
import { DeletePlaylistProps } from "../../../components/types";
|
||||
import Button from "../../../components/Global/helpers/button";
|
||||
import { Text } from "../../../components/Global/helpers/text";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { deletePlaylist } from "../../../api/mutations/functions/playlists";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
import { queryClient } from "../../../constants/query-client";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
|
||||
import * as Burnt from "burnt";
|
||||
|
||||
export default function DeletePlaylist(
|
||||
{
|
||||
navigation,
|
||||
route
|
||||
}: DeletePlaylistProps) : React.JSX.Element {
|
||||
|
||||
|
||||
const useDeletePlaylist = useMutation({
|
||||
mutationFn: (playlist: BaseItemDto) => deletePlaylist(playlist.Id!),
|
||||
onSuccess: (data, playlist) => {
|
||||
trigger("notificationSuccess");
|
||||
|
||||
navigation.goBack();
|
||||
navigation.goBack();
|
||||
Burnt.alert({
|
||||
title: `Playlist deleted`,
|
||||
message: `Deleted ${playlist.Name ?? "Untitled Playlist"}`,
|
||||
duration: 1,
|
||||
preset: 'done'
|
||||
});
|
||||
|
||||
// Refresh user playlists component in library
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.FavoritePlaylists]
|
||||
});
|
||||
|
||||
|
||||
},
|
||||
onError: () => {
|
||||
trigger("notificationError");
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<View marginHorizontal={"$2"}>
|
||||
<Text bold textAlign="center">{`Delete playlist ${route.params.playlist.Name ?? "Untitled Playlist"}?`}</Text>
|
||||
<XStack justifyContent="space-evenly">
|
||||
<Button onPress={() => navigation.goBack()}>Cancel</Button>
|
||||
<Button danger onPress={() => useDeletePlaylist.mutate(route.params.playlist)}>Delete</Button>
|
||||
</XStack>
|
||||
</View>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import React from "react";
|
||||
import { StackParamList } from "../types";
|
||||
import FavoritesScreen from "./component";
|
||||
import Library from "./component";
|
||||
import { ArtistScreen } from "../Artist/screens";
|
||||
import { AlbumScreen } from "../Album/screens";
|
||||
import { AlbumScreen } from "../Album";
|
||||
import { PlaylistScreen } from "../Playlist/screens";
|
||||
import ArtistsScreen from "../Artists/screen";
|
||||
import AlbumsScreen from "../Albums/screen";
|
||||
import TracksScreen from "../Tracks/screen";
|
||||
import DetailsScreen from "../ItemDetail/screen";
|
||||
import PlaylistsScreen from "../Playlists/screen";
|
||||
import AddPlaylist from "./components/add-playlist";
|
||||
import DeletePlaylist from "./components/delete-playlist";
|
||||
|
||||
const FavoritesStack = createNativeStackNavigator<StackParamList>();
|
||||
const Stack = createNativeStackNavigator<StackParamList>();
|
||||
|
||||
export default function Favorites(): React.JSX.Element {
|
||||
export default function LibraryStack(): React.JSX.Element {
|
||||
return (
|
||||
<FavoritesStack.Navigator
|
||||
initialRouteName="Favorites"
|
||||
<Stack.Navigator
|
||||
initialRouteName="Library"
|
||||
>
|
||||
<FavoritesStack.Screen
|
||||
name="Favorites"
|
||||
component={FavoritesScreen}
|
||||
<Stack.Screen
|
||||
name="Library"
|
||||
component={Library}
|
||||
options={{
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
@@ -29,7 +31,7 @@ export default function Favorites(): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
|
||||
<FavoritesStack.Screen
|
||||
<Stack.Screen
|
||||
name="Artist"
|
||||
component={ArtistScreen}
|
||||
options={({ route }) => ({
|
||||
@@ -41,18 +43,14 @@ export default function Favorites(): React.JSX.Element {
|
||||
})}
|
||||
/>
|
||||
|
||||
<FavoritesStack.Screen
|
||||
<Stack.Screen
|
||||
name="Artists"
|
||||
component={ArtistsScreen}
|
||||
options={({ route }) => ({
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
<FavoritesStack.Screen
|
||||
<Stack.Screen
|
||||
name="Album"
|
||||
component={AlbumScreen}
|
||||
options={({ route }) => ({
|
||||
@@ -61,40 +59,28 @@ export default function Favorites(): React.JSX.Element {
|
||||
})}
|
||||
/>
|
||||
|
||||
<FavoritesStack.Screen
|
||||
<Stack.Screen
|
||||
name="Albums"
|
||||
component={AlbumsScreen}
|
||||
options={{
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FavoritesStack.Screen
|
||||
<Stack.Screen
|
||||
name="Tracks"
|
||||
component={TracksScreen}
|
||||
options={{
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FavoritesStack.Screen
|
||||
<Stack.Screen
|
||||
name="Playlists"
|
||||
component={PlaylistsScreen}
|
||||
options={{
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FavoritesStack.Screen
|
||||
<Stack.Screen
|
||||
name="Playlist"
|
||||
component={PlaylistScreen}
|
||||
options={({ route }) => ({
|
||||
@@ -103,15 +89,39 @@ export default function Favorites(): React.JSX.Element {
|
||||
})}
|
||||
/>
|
||||
|
||||
<FavoritesStack.Group screenOptions={{ presentation: 'modal' }}>
|
||||
<FavoritesStack.Screen
|
||||
<Stack.Group screenOptions={{ presentation: 'modal' }}>
|
||||
<Stack.Screen
|
||||
name="Details"
|
||||
component={DetailsScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</FavoritesStack.Group>
|
||||
</FavoritesStack.Navigator>
|
||||
</Stack.Group>
|
||||
|
||||
{/* https://www.reddit.com/r/reactnative/comments/1dgktbn/comment/lxd23sj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button */}
|
||||
<Stack.Group screenOptions={{
|
||||
presentation: 'formSheet',
|
||||
sheetInitialDetentIndex: 0,
|
||||
sheetAllowedDetents: [0.35]
|
||||
}}>
|
||||
<Stack.Screen
|
||||
name="AddPlaylist"
|
||||
component={AddPlaylist}
|
||||
options={{
|
||||
title: "Add Playlist",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="DeletePlaylist"
|
||||
component={DeletePlaylist}
|
||||
options={{
|
||||
title: "Delete Playlist"
|
||||
}}
|
||||
/>
|
||||
</Stack.Group>
|
||||
|
||||
</Stack.Navigator>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import _ from "lodash"
|
||||
import ServerAuthentication from "./helpers/server-authentication";
|
||||
import ServerAddress from "./helpers/server-address";
|
||||
import _, { isUndefined } from "lodash"
|
||||
import ServerAuthentication from "./screens/server-authentication";
|
||||
import ServerAddress from "./screens/server-address";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
import ServerLibrary from "./helpers/server-library";
|
||||
import ServerLibrary from "./screens/server-library";
|
||||
import { useAuthenticationContext } from "./provider";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Login(): React.JSX.Element {
|
||||
|
||||
const { user, server, triggerAuth, setTriggerAuth } = useAuthenticationContext();
|
||||
const { user, server, setTriggerAuth } = useAuthenticationContext();
|
||||
|
||||
const Stack = createStackNavigator();
|
||||
|
||||
@@ -17,40 +17,40 @@ export default function Login(): React.JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
{
|
||||
(_.isUndefined(server)) ? (
|
||||
<Stack.Navigator
|
||||
initialRouteName={
|
||||
isUndefined(server)
|
||||
? "ServerAddress"
|
||||
: isUndefined(user)
|
||||
? "ServerAuthentication"
|
||||
: "LibrarySelection"
|
||||
}
|
||||
screenOptions={{ headerShown: false }}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="ServerAddress"
|
||||
options={{
|
||||
headerShown: false,
|
||||
animationTypeForReplace: triggerAuth ? 'push' : 'pop'
|
||||
}}
|
||||
component={ServerAddress}
|
||||
/>
|
||||
) : (
|
||||
|
||||
(_.isUndefined(user)) ? (
|
||||
<Stack.Screen
|
||||
name="ServerAuthentication"
|
||||
options={{
|
||||
headerShown: false,
|
||||
animationTypeForReplace: 'push'
|
||||
}}
|
||||
initialParams={{ server }}
|
||||
//@ts-ignore
|
||||
component={ServerAuthentication}
|
||||
/>
|
||||
) : (
|
||||
<Stack.Screen
|
||||
name="LibrarySelection"
|
||||
options={{
|
||||
headerShown: false,
|
||||
animationTypeForReplace: 'push'
|
||||
}}
|
||||
component={ServerLibrary}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { JellifyServer } from "../../../types/JellifyServer";
|
||||
import { Input, Spacer, Spinner, XStack, ZStack } from "tamagui";
|
||||
import { SwitchWithLabel } from "../../Global/helpers/switch-with-label";
|
||||
import { H1 } from "../../Global/helpers/text";
|
||||
import { H2 } from "../../Global/helpers/text";
|
||||
import Button from "../../Global/helpers/button";
|
||||
import { http, https } from "../utils/constants";
|
||||
import { JellyfinInfo } from "../../../api/info";
|
||||
@@ -13,8 +13,20 @@ import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import Client from "../../../api/client";
|
||||
import { useAuthenticationContext } from "../provider";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
|
||||
export default function ServerAddress(): React.JSX.Element {
|
||||
import * as Burnt from "burnt";
|
||||
|
||||
export default function ServerAddress({
|
||||
navigation
|
||||
}: {
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}): React.JSX.Element {
|
||||
|
||||
navigation.setOptions({
|
||||
animationTypeForReplace: 'push'
|
||||
})
|
||||
|
||||
const [useHttps, setUseHttps] = useState<boolean>(true);
|
||||
const [serverAddress, setServerAddress] = useState<string | undefined>(undefined);
|
||||
@@ -48,18 +60,26 @@ export default function ServerAddress(): React.JSX.Element {
|
||||
|
||||
Client.setPublicApiClient(server);
|
||||
setServer(server);
|
||||
|
||||
navigation.navigate("ServerAuthentication", { server });
|
||||
},
|
||||
onError: async (error: Error) => {
|
||||
console.error("An error occurred connecting to the Jellyfin instance", error);
|
||||
Client.signOut();
|
||||
setServer(undefined);
|
||||
|
||||
Burnt.toast({
|
||||
title: "Unable to connect",
|
||||
preset: "error",
|
||||
// message: `Unable to connect to Jellyfin at ${useHttps ? https : http}${serverAddress}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<H1>Connect to Jellyfin</H1>
|
||||
<XStack>
|
||||
<H2 marginVertical={"$7"} marginHorizontal={"$2"}>Connect to Jellyfin</H2>
|
||||
<XStack marginBottom={"$3"}>
|
||||
<SwitchWithLabel
|
||||
checked={useHttps}
|
||||
onCheckedChange={(checked) => setUseHttps(checked)}
|
||||
@@ -76,6 +96,7 @@ export default function ServerAddress(): React.JSX.Element {
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
flexGrow={1}
|
||||
placeholder="jellyfin.org"
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
@@ -2,20 +2,30 @@ import React, { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import _ from "lodash";
|
||||
import { JellyfinCredentials } from "../../../api/types/jellyfin-credentials";
|
||||
import { Input, Spinner, YStack, ZStack } from "tamagui";
|
||||
import { getToken, Spacer, Spinner, YStack, ZStack } from "tamagui";
|
||||
import { useAuthenticationContext } from "../provider";
|
||||
import { H1 } from "../../Global/helpers/text";
|
||||
import { H2 } from "../../Global/helpers/text";
|
||||
import Button from "../../Global/helpers/button";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import Client from "../../../api/client";
|
||||
import { JellifyUser } from "../../../types/JellifyUser";
|
||||
import { ServerAuthenticationProps } from "../../../components/types";
|
||||
import Input from "../../../components/Global/helpers/input";
|
||||
import Icon from "../../../components/Global/helpers/icon";
|
||||
import { useToastController } from "@tamagui/toast";
|
||||
import Toast from "../../../components/Global/components/toast";
|
||||
|
||||
export default function ServerAuthentication(): React.JSX.Element {
|
||||
export default function ServerAuthentication({
|
||||
route,
|
||||
navigation,
|
||||
}: ServerAuthenticationProps): React.JSX.Element {
|
||||
|
||||
const toast = useToastController()
|
||||
|
||||
const [username, setUsername] = useState<string | undefined>(undefined);
|
||||
const [password, setPassword] = React.useState<string | undefined>(undefined);
|
||||
|
||||
const { setUser, server, setServer } = useAuthenticationContext();
|
||||
const { setUser, setServer } = useAuthenticationContext();
|
||||
|
||||
const useApiMutation = useMutation({
|
||||
mutationFn: async (credentials: JellyfinCredentials) => {
|
||||
@@ -42,42 +52,53 @@ export default function ServerAuthentication(): React.JSX.Element {
|
||||
}
|
||||
|
||||
Client.setUser(user);
|
||||
return setUser(user);
|
||||
setUser(user);
|
||||
|
||||
navigation.navigate("LibrarySelection", { user });
|
||||
},
|
||||
onError: async (error: Error) => {
|
||||
console.error("An error occurred connecting to the Jellyfin instance", error);
|
||||
|
||||
toast.show("Sign in failed", {
|
||||
|
||||
});
|
||||
return Promise.reject(`An error occured signing into ${Client.server!.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<H1>
|
||||
{ `Sign in to ${server?.name ?? "Jellyfin"}`}
|
||||
</H1>
|
||||
<H2 marginHorizontal={"$2"} marginVertical={"$7"}>
|
||||
{ `Sign in to ${route.params.server.name}`}
|
||||
</H2>
|
||||
<Button onPress={() => {
|
||||
Client.switchServer()
|
||||
setServer(undefined);
|
||||
navigation.push("ServerAddress");
|
||||
}}>
|
||||
Switch Server
|
||||
</Button>
|
||||
|
||||
<YStack>
|
||||
<YStack marginHorizontal={"$2"} alignContent="space-between">
|
||||
<Input
|
||||
prependElement={(<Icon small name="human-greeting-variant" color={getToken("$color.amethyst")} />)}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChangeText={(value : string | undefined) => setUsername(value)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
/>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<Input
|
||||
prependElement={(<Icon small name="lock-outline" color={getToken("$color.amethyst")} />)}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChangeText={(value : string | undefined) => setPassword(value)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
<ZStack>
|
||||
@@ -90,7 +111,7 @@ export default function ServerAuthentication(): React.JSX.Element {
|
||||
onPress={() => {
|
||||
|
||||
if (!_.isUndefined(username)) {
|
||||
console.log(`Signing in to ${server!.name}`);
|
||||
console.log(`Signing in...`);
|
||||
useApiMutation.mutate({ username, password });
|
||||
}
|
||||
}}
|
||||
@@ -98,6 +119,7 @@ export default function ServerAuthentication(): React.JSX.Element {
|
||||
Sign in
|
||||
</Button>
|
||||
</ZStack>
|
||||
<Toast />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Spinner, ToggleGroup } from "tamagui";
|
||||
import { useAuthenticationContext } from "../provider";
|
||||
import { H1, Label, Text } from "../../Global/helpers/text";
|
||||
import { H1, H2, Label, Text } from "../../Global/helpers/text";
|
||||
import Button from "../../Global/helpers/button";
|
||||
import _ from "lodash";
|
||||
import { useUserViews } from "../../../api/queries/libraries";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import Client from "../../../api/client";
|
||||
import { useJellifyContext } from "../../../components/provider";
|
||||
import { useJellifyContext } from "../../provider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { QueryKeys } from "../../../enums/query-keys";
|
||||
import { fetchUserViews } from "../../../api/queries/functions/libraries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function ServerLibrary(): React.JSX.Element {
|
||||
|
||||
@@ -19,7 +21,10 @@ export default function ServerLibrary(): React.JSX.Element {
|
||||
const [libraryId, setLibraryId] = useState<string | undefined>(undefined);
|
||||
const [playlistLibrary, setPlaylistLibrary] = useState<BaseItemDto | undefined>(undefined);
|
||||
|
||||
const { data : libraries, isError, isPending, isSuccess, refetch } = useUserViews();
|
||||
const { data : libraries, isError, isPending, isSuccess, refetch } = useQuery({
|
||||
queryKey: [QueryKeys.UserViews],
|
||||
queryFn: () => fetchUserViews()
|
||||
});;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isSuccess)
|
||||
@@ -31,7 +36,7 @@ export default function ServerLibrary(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<H1>Select Music Library</H1>
|
||||
<H2>Select Music Library</H2>
|
||||
|
||||
{ isPending ? (
|
||||
<Spinner size="large" />
|
||||
@@ -52,12 +57,7 @@ export default function ServerLibrary(): React.JSX.Element {
|
||||
value={library.Id!}
|
||||
aria-label={library.Name!}
|
||||
>
|
||||
<Label
|
||||
htmlFor={library.Id!}
|
||||
size="$2"
|
||||
>
|
||||
{library.Name ?? "Unnamed Library"}
|
||||
</Label>
|
||||
<Text>{library.Name ?? "Unnamed Library"}</Text>
|
||||
</ToggleGroup.Item>
|
||||
)
|
||||
})
|
||||
@@ -75,8 +75,8 @@ export default function ServerLibrary(): React.JSX.Element {
|
||||
musicLibraryId: libraryId!,
|
||||
musicLibraryName: libraries?.filter((library) => library.Id == libraryId)[0].Name ?? "No library name",
|
||||
musicLibraryPrimaryImageId: libraries?.filter((library) => library.Id == libraryId)[0].ImageTags!.Primary,
|
||||
playlistLibraryId: playlistLibrary!.Id!,
|
||||
playlistLibraryPrimaryImageId: playlistLibrary!.ImageTags!.Primary,
|
||||
playlistLibraryId: playlistLibrary?.Id!,
|
||||
playlistLibraryPrimaryImageId: playlistLibrary?.ImageTags!.Primary,
|
||||
});
|
||||
setLoggedIn(true);
|
||||
}}>
|
||||
@@ -73,7 +73,7 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
|
||||
color={theme.borderColor.val}
|
||||
name="skip-next"
|
||||
onPress={() => useSkip.mutate(undefined)}
|
||||
/>
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ProgressMultiplier, TextTickerConfig } from "../component.config";
|
||||
import { toUpper } from "lodash";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
const scrubGesture = Gesture.Pan();
|
||||
|
||||
@@ -34,7 +35,8 @@ export default function PlayerScreen({
|
||||
useSeekTo,
|
||||
useSkip,
|
||||
usePrevious,
|
||||
queueName,
|
||||
playQueue,
|
||||
queue
|
||||
} = usePlayerContext();
|
||||
|
||||
const [seeking, setSeeking] = useState<boolean>(false);
|
||||
@@ -69,7 +71,10 @@ export default function PlayerScreen({
|
||||
<>
|
||||
<YStack>
|
||||
|
||||
<XStack marginHorizontal={"$2"}>
|
||||
<XStack
|
||||
marginBottom={"$2"}
|
||||
marginHorizontal={"$2"}
|
||||
>
|
||||
|
||||
<YStack
|
||||
alignContent="flex-end"
|
||||
@@ -86,14 +91,20 @@ export default function PlayerScreen({
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
flex={3}
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
flex={3}
|
||||
>
|
||||
|
||||
<Text>Playing from</Text>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text bold>{ queueName ?? "Queue"}</Text>
|
||||
</TextTicker>
|
||||
<Text bold>
|
||||
{
|
||||
// If the Queue is a BaseItemDto, display the name of it
|
||||
typeof(queue) === 'object'
|
||||
? (queue as BaseItemDto).Name ?? "Untitled"
|
||||
: queue
|
||||
}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<Spacer flex={1} />
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FadeIn, FadeOut, ReduceMotion, SequencedTransition } from "react-native
|
||||
export default function Queue({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
const { queue, useClearQueue, useRemoveFromQueue, useReorderQueue, useSkip, nowPlaying } = usePlayerContext();
|
||||
const { playQueue, queue, useClearQueue, useRemoveFromQueue, useReorderQueue, useSkip, nowPlaying } = usePlayerContext();
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () => {
|
||||
@@ -24,12 +24,12 @@ export default function Queue({ navigation }: { navigation: NativeStackNavigatio
|
||||
}
|
||||
})
|
||||
|
||||
const scrollIndex = queue.findIndex(queueItem => queueItem.item.Id! === nowPlaying!.item.Id!)
|
||||
const scrollIndex = playQueue.findIndex(queueItem => queueItem.item.Id! === nowPlaying!.item.Id!)
|
||||
|
||||
return (
|
||||
<DraggableFlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={queue}
|
||||
data={playQueue}
|
||||
dragHitSlop={{ left: -50 }} // https://github.com/computerjazz/react-native-draggable-flatlist/issues/336
|
||||
extraData={nowPlaying}
|
||||
// enableLayoutAnimationExperimental
|
||||
@@ -54,6 +54,7 @@ export default function Queue({ navigation }: { navigation: NativeStackNavigatio
|
||||
|
||||
return (
|
||||
<Track
|
||||
queue={queue}
|
||||
navigation={navigation}
|
||||
track={queueItem.item}
|
||||
index={getIndex()}
|
||||
@@ -66,14 +67,11 @@ export default function Queue({ navigation }: { navigation: NativeStackNavigatio
|
||||
drag();
|
||||
}}
|
||||
isNested
|
||||
prependElement={(
|
||||
<Icon
|
||||
small
|
||||
color={getTokens().color.amethyst.val}
|
||||
name="close-circle-outline"
|
||||
onPress={() => useRemoveFromQueue.mutate(index!)}
|
||||
/>
|
||||
)}
|
||||
showRemove
|
||||
onRemove={() => {
|
||||
if (index)
|
||||
useRemoveFromQueue.mutate(index)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StackParamList } from "../types";
|
||||
import PlayerScreen from "./screens";
|
||||
import Queue from "./screens/queue";
|
||||
import DetailsScreen from "../ItemDetail/screen";
|
||||
import { AlbumScreen } from "../Album";
|
||||
|
||||
export const PlayerStack = createNativeStackNavigator<StackParamList>();
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../types";
|
||||
import { getTokens, Separator, XStack, YStack } from "tamagui";
|
||||
import { useItemTracks } from "../../api/queries/tracks";
|
||||
import { getToken, Separator, Spacer, XStack, YStack } from "tamagui";
|
||||
import { RunTimeTicks } from "../Global/helpers/time-codes";
|
||||
import { H4, H5, Text } from "../Global/helpers/text";
|
||||
import Track from "../Global/components/track";
|
||||
import BlurhashedImage from "../Global/components/blurhashed-image";
|
||||
import DraggableFlatList from "react-native-draggable-flatlist";
|
||||
import { reorderPlaylist, updatePlaylist } from "../../api/mutations/functions/playlists";
|
||||
import { removeFromPlaylist, reorderPlaylist, updatePlaylist } from "../../api/mutations/functions/playlists";
|
||||
import { useEffect, useState } from "react";
|
||||
import Icon from "../Global/helpers/icon";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
import { queryClient } from "../../constants/query-client";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import Client from "../../api/client";
|
||||
|
||||
interface PlaylistProps {
|
||||
playlist: BaseItemDto;
|
||||
@@ -27,6 +28,12 @@ interface PlaylistOrderMutation {
|
||||
to: number
|
||||
}
|
||||
|
||||
interface RemoveFromPlaylistMutation {
|
||||
playlist: BaseItemDto;
|
||||
track: BaseItemDto;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function Playlist({
|
||||
playlist,
|
||||
navigation
|
||||
@@ -34,19 +41,43 @@ export default function Playlist({
|
||||
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[]>([]);
|
||||
const { data: tracks, isPending, isSuccess, refetch } = useItemTracks(playlist.Id!);
|
||||
const { data: tracks, isPending, isSuccess, refetch } = useQuery({
|
||||
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
|
||||
queryFn: () => {
|
||||
|
||||
return getItemsApi(Client.api!).getItems({
|
||||
parentId: playlist.Id!,
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data.Items ? response.data.Items! : [];
|
||||
})
|
||||
},
|
||||
staleTime: (1000 * 60 * 1 * 1) * 1 // 1 minute, since these are mutable by nature
|
||||
});
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () => {
|
||||
return (
|
||||
<Icon
|
||||
color={editing
|
||||
? getTokens().color.telemagenta.val
|
||||
: getTokens().color.white.val
|
||||
}
|
||||
name={editing ? 'check' : 'pencil'}
|
||||
onPress={() => setEditing(!editing)}
|
||||
/>
|
||||
|
||||
<XStack justifyContent="space-between">
|
||||
|
||||
{ editing && (
|
||||
<Icon
|
||||
color={getToken("$color.danger")}
|
||||
name="delete-sweep-outline" // otherwise use "delete-circle"
|
||||
onPress={() => navigation.navigate("DeletePlaylist", { playlist })}
|
||||
/>
|
||||
|
||||
)}
|
||||
|
||||
<Spacer />
|
||||
|
||||
<Icon
|
||||
color={getToken("$color.amethyst")}
|
||||
name={editing ? 'content-save-outline' : 'pencil'}
|
||||
onPress={() => setEditing(!editing)}
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
});
|
||||
@@ -85,6 +116,20 @@ export default function Playlist({
|
||||
|
||||
setPlaylistTracks(tracks ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
const useRemoveFromPlaylist = useMutation({
|
||||
mutationFn: ({ playlist, track, index } : RemoveFromPlaylistMutation) => {
|
||||
return removeFromPlaylist(track, playlist);
|
||||
},
|
||||
onSuccess: (data, { index }) => {
|
||||
trigger("notificationSuccess");
|
||||
|
||||
setPlaylistTracks(playlistTracks.slice(0, index).concat(playlistTracks.slice(index + 1, playlistTracks.length -1)))
|
||||
},
|
||||
onError: () => {
|
||||
trigger("notificationError")
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -154,9 +199,11 @@ export default function Playlist({
|
||||
track={track}
|
||||
tracklist={tracks!}
|
||||
index={index}
|
||||
queueName={playlist.Name ?? "Untitled Playlist"}
|
||||
queue={playlist}
|
||||
showArtwork
|
||||
onLongPress={editing ? drag : undefined}
|
||||
showRemove={editing}
|
||||
onRemove={() => useRemoveFromPlaylist.mutate({ playlist, track, index: index! })}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
@@ -166,7 +213,7 @@ export default function Playlist({
|
||||
color={"$borderColor"}
|
||||
style={{ display: "block"}}
|
||||
>
|
||||
Total Runtime:
|
||||
Total Runtime:
|
||||
</Text>
|
||||
<RunTimeTicks>{ playlist.RunTimeTicks }</RunTimeTicks>
|
||||
</XStack>
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { useFavoritePlaylists } from "../../api/queries/favorites";
|
||||
import { FlatList, RefreshControl } from "react-native-gesture-handler";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import { ItemCard } from "../Global/components/item-card";
|
||||
import { FavoritePlaylistsProps } from "../types";
|
||||
import Icon from "../Global/helpers/icon";
|
||||
import { getToken } from "tamagui";
|
||||
import { fetchFavoritePlaylists } from "../../api/queries/functions/favorites";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function FavoritePlaylists({ navigation }: FavoritePlaylistsProps) : React.JSX.Element {
|
||||
|
||||
const { data: playlists, isPending, refetch } = useFavoritePlaylists();
|
||||
navigation.setOptions({
|
||||
headerRight: () => {
|
||||
return <Icon
|
||||
name="plus-circle-outline"
|
||||
color={getToken("$color.telemagenta")}
|
||||
onPress={() => navigation.navigate('AddPlaylist')}
|
||||
/>
|
||||
}
|
||||
});
|
||||
|
||||
const { data: playlists, isPending, refetch } = useQuery({
|
||||
queryKey: [QueryKeys.UserPlaylists],
|
||||
queryFn: () => fetchFavoritePlaylists()
|
||||
});
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
@@ -34,6 +51,9 @@ export default function FavoritePlaylists({ navigation }: FavoritePlaylistsProps
|
||||
/>
|
||||
)
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { fetchSearchResults } from "../../api/queries/functions/search";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlatList, useColorScheme } from "react-native";
|
||||
import { Text } from "../Global/helpers/text";
|
||||
import { fetchSearchSuggestions } from "../../api/queries/functions/suggestions";
|
||||
|
||||
export default function Search({
|
||||
navigation
|
||||
@@ -15,13 +16,17 @@ export default function Search({
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}): React.JSX.Element {
|
||||
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
const [searchString, setSearchString] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data: items, refetch, isFetched, isFetching } = useQuery({
|
||||
const { data: items, refetch, isFetching } = useQuery({
|
||||
queryKey: [QueryKeys.Search, searchString],
|
||||
queryFn: () => fetchSearchResults(searchString)
|
||||
})
|
||||
});
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: [QueryKeys.SearchSuggestions],
|
||||
queryFn: () => fetchSearchSuggestions()
|
||||
});
|
||||
|
||||
const search = useCallback(() => {
|
||||
|
||||
@@ -54,11 +59,14 @@ export default function Search({
|
||||
)}
|
||||
data={items}
|
||||
refreshing={isFetching}
|
||||
renderItem={({ index, item }) => {
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<Item item={item} queueName={searchString ?? "Search"} navigation={navigation} />
|
||||
)
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack"
|
||||
import SearchScreen from "./screen";
|
||||
import { StackParamList } from "../types";
|
||||
import { ArtistScreen } from "../Artist/screens";
|
||||
import { AlbumScreen } from "../Album/screens";
|
||||
import { AlbumScreen } from "../Album";
|
||||
import { PlaylistScreen } from "../Playlist/screens";
|
||||
import DetailsScreen from "../ItemDetail/screen";
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ export default function Root({
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
removeClippedSubviews
|
||||
>
|
||||
<YGroup
|
||||
alignSelf="center"
|
||||
bordered
|
||||
|
||||
@@ -11,7 +11,10 @@ export default function DevTools() : React.JSX.Element {
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
removeClippedSubviews
|
||||
>
|
||||
<Button onPress={cleanImageDirectory.mutate}>Clean Image Cache</Button>
|
||||
</ScrollView>
|
||||
)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useFavoriteTracks } from "../../api/queries/favorites";
|
||||
import { StackParamList } from "../types";
|
||||
import { FlatList, RefreshControl } from "react-native";
|
||||
import Track from "../Global/components/track";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
|
||||
export default function Tracks({ navigation }: { navigation: NativeStackNavigationProp<StackParamList> }) : React.JSX.Element {
|
||||
|
||||
const { data: tracks, refetch, isPending } = useFavoriteTracks();
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
numColumns={1}
|
||||
data={tracks}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPending}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
}
|
||||
renderItem={({ index, item: track}) => {
|
||||
return (
|
||||
<Track
|
||||
navigation={navigation}
|
||||
showArtwork
|
||||
track={track}
|
||||
tracklist={tracks?.slice(index, index + 50) ?? []}
|
||||
queueName="Favorite Tracks"
|
||||
/>
|
||||
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,49 @@
|
||||
import { RouteProp } from "@react-navigation/native";
|
||||
import { StackParamList } from "../types";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { TracksProps } from "../types";
|
||||
import React from "react";
|
||||
import Tracks from "./component";
|
||||
import Track from "../Global/components/track";
|
||||
import { FlatList, RefreshControl } from "react-native";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchRecentlyPlayed } from "../../api/queries/functions/recents";
|
||||
import { fetchFavoriteTracks } from "../../api/queries/functions/favorites";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function TracksScreen({
|
||||
route,
|
||||
navigation
|
||||
} : {
|
||||
route: RouteProp<StackParamList, "Tracks">,
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}) : React.JSX.Element {
|
||||
} : TracksProps) : React.JSX.Element {
|
||||
const { data: tracks, refetch, isPending } = useQuery({
|
||||
queryKey: [route.params.query],
|
||||
queryFn: () => route.params.query === QueryKeys.RecentlyPlayed
|
||||
? fetchRecentlyPlayed()
|
||||
: fetchFavoriteTracks()
|
||||
});
|
||||
|
||||
return (
|
||||
<Tracks navigation={navigation} />
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
numColumns={1}
|
||||
data={tracks}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPending}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
}
|
||||
renderItem={({ index, item: track}) => {
|
||||
return (
|
||||
<Track
|
||||
navigation={navigation}
|
||||
showArtwork
|
||||
track={track}
|
||||
tracklist={tracks?.slice(index, index + 50) ?? []}
|
||||
queue="Favorite Tracks"
|
||||
/>
|
||||
|
||||
)
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden' // Prevent unnecessary memory usage
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import { useColorScheme } from "react-native";
|
||||
import { PortalProvider } from "@tamagui/portal";
|
||||
import { JellifyProvider, useJellifyContext } from "./provider";
|
||||
import { ToastProvider } from "@tamagui/toast";
|
||||
import SafeToastViewport from "./Global/components/toast-area-view-port";
|
||||
|
||||
export default function Jellify(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<PortalProvider shouldAddRootHost>
|
||||
<ToastProvider>
|
||||
<ToastProvider burntOptions={{ from: 'top'}}>
|
||||
<JellifyProvider>
|
||||
<App />
|
||||
</JellifyProvider>
|
||||
@@ -38,10 +39,11 @@ function App(): React.JSX.Element {
|
||||
<Navigation />
|
||||
</PlayerProvider>
|
||||
) : (
|
||||
<JellyfinAuthenticationProvider>
|
||||
<JellyfinAuthenticationProvider>
|
||||
<Login />
|
||||
</JellyfinAuthenticationProvider>
|
||||
)}
|
||||
<SafeToastViewport />
|
||||
</SafeAreaProvider>
|
||||
</NavigationContainer>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isUndefined } from "lodash";
|
||||
import { createContext, ReactNode, SetStateAction, useContext, useEffect, useState } from "react";
|
||||
import { CarPlay } from "react-native-carplay";
|
||||
import CarPlayNavigation from "./CarPlay/Navigation";
|
||||
import CarPlayNowPlaying from "./CarPlay/NowPlaying";
|
||||
|
||||
interface JellifyContext {
|
||||
loggedIn: boolean;
|
||||
@@ -30,7 +31,8 @@ const JellifyContextInitializer = () => {
|
||||
|
||||
if (loggedIn) {
|
||||
CarPlay.setRootTemplate(CarPlayNavigation, true);
|
||||
// CarPlay.enableNowPlaying(true); // https://github.com/birkir/react-native-carplay/issues/185
|
||||
CarPlay.pushTemplate(CarPlayNowPlaying, true);
|
||||
CarPlay.enableNowPlaying(true); // https://github.com/birkir/react-native-carplay/issues/185
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import React from "react";
|
||||
import { BottomTabBar, createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import Home from "./Home/stack";
|
||||
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import Favorites from "./Favorites/stack";
|
||||
import Library from "./Library/stack";
|
||||
import Settings from "./Settings/stack";
|
||||
import { Discover } from "./Discover/stack";
|
||||
import { Miniplayer } from "./Player/mini-player";
|
||||
import { getTokens, Separator } from "tamagui";
|
||||
import { usePlayerContext } from "../player/provider";
|
||||
import SearchStack from "./Search/stack";
|
||||
import LibraryStack from "./Library/stack";
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
@@ -31,7 +32,6 @@ export function Tabs() : React.JSX.Element {
|
||||
<>
|
||||
<Separator />
|
||||
<Miniplayer navigation={props.navigation} />
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<BottomTabBar {...props} />
|
||||
@@ -50,12 +50,12 @@ export function Tabs() : React.JSX.Element {
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="Favorites"
|
||||
component={Favorites}
|
||||
name="Library"
|
||||
component={LibraryStack}
|
||||
options={{
|
||||
headerShown: false,
|
||||
tabBarIcon: ({color, size }) => (
|
||||
<MaterialCommunityIcons name="heart-multiple-outline" color={color} size={size} />
|
||||
<MaterialCommunityIcons name="book-music-outline" color={color} size={size} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { QueryKeys } from "../enums/query-keys";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
|
||||
import { JellifyServer } from "../types/JellifyServer";
|
||||
import { JellifyUser } from "../types/JellifyUser";
|
||||
|
||||
export type StackParamList = {
|
||||
ServerAddress: undefined;
|
||||
ServerAuthentication: {
|
||||
server: JellifyServer
|
||||
}
|
||||
|
||||
LibrarySelection: {
|
||||
user: JellifyUser
|
||||
}
|
||||
|
||||
Home: undefined;
|
||||
AddPlaylist: undefined;
|
||||
RecentArtists: {
|
||||
@@ -17,12 +28,19 @@ export type StackParamList = {
|
||||
|
||||
Discover: undefined;
|
||||
|
||||
Favorites: undefined;
|
||||
Artists: undefined;
|
||||
Library: undefined;
|
||||
Artists: {
|
||||
query: QueryKeys.FavoriteArtists | QueryKeys.RecentlyPlayedArtists
|
||||
};
|
||||
Albums: undefined;
|
||||
Tracks: undefined;
|
||||
Tracks: {
|
||||
query: QueryKeys.FavoriteTracks | QueryKeys.RecentlyPlayed
|
||||
};
|
||||
Genres: undefined;
|
||||
Playlists: undefined;
|
||||
DeletePlaylist: {
|
||||
playlist: BaseItemDto
|
||||
}
|
||||
|
||||
Search: undefined;
|
||||
|
||||
@@ -53,6 +71,10 @@ export type StackParamList = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerAddressProps = NativeStackScreenProps<StackParamList, "ServerAddress">;
|
||||
export type ServerAuthenticationProps = NativeStackScreenProps<StackParamList, "ServerAuthentication">;
|
||||
export type LibrarySelectionProps = NativeStackScreenProps<StackParamList, "LibrarySelection">;
|
||||
|
||||
export type TabProps = NativeStackScreenProps<StackParamList, 'Tabs'>;
|
||||
export type PlayerProps = NativeStackScreenProps<StackParamList, 'Player'>;
|
||||
|
||||
@@ -74,15 +96,16 @@ export type HomePlaylistProps = NativeStackScreenProps<StackParamList, "Playlist
|
||||
|
||||
export type QueueProps = NativeStackScreenProps<StackParamList, "Queue">;
|
||||
|
||||
export type LibraryProps = NativeStackScreenProps<StackParamList, "Favorites">;
|
||||
export type LibraryProps = NativeStackScreenProps<StackParamList, "Library">;
|
||||
|
||||
export type ArtistsProps = NativeStackScreenProps<StackParamList, "Artists">;
|
||||
|
||||
export type AlbumsProps = NativeStackScreenProps<StackParamList, "Albums">;
|
||||
|
||||
export type FavoritePlaylistsProps = NativeStackScreenProps<StackParamList, "Playlists">;
|
||||
export type DeletePlaylistProps = NativeStackScreenProps<StackParamList, "DeletePlaylist">;
|
||||
|
||||
export type FavoriteTracksProps = NativeStackScreenProps<StackParamList, "Tracks">;
|
||||
export type TracksProps = NativeStackScreenProps<StackParamList, "Tracks">;
|
||||
|
||||
export type GenresProps = NativeStackScreenProps<StackParamList, "Genres">;
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: (1000 * 60 * 15), // 15 minutes
|
||||
staleTime: (1000 * 60 * 10) // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: (1000 * 60 * 60 * 24) * 5, // 5 days, for maximum cache-age
|
||||
staleTime: (1000 * 60 * 60 * 1), // 1 hour, this can be refreshed manually anyways
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -4,16 +4,18 @@ import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persist
|
||||
export const storage = new MMKV();
|
||||
|
||||
const clientStorage = {
|
||||
setItem: (key: string, value: string | number | boolean | Uint8Array) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
getItem: (key: string) => {
|
||||
const value = storage.getString(key);
|
||||
return value === undefined ? null : value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
};
|
||||
setItem: (key: string, value: string | number | boolean | Uint8Array) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
getItem: (key: string) => {
|
||||
const value = storage.getString(key);
|
||||
return value === undefined ? null : value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
export const clientPersister = createSyncStoragePersister({ storage: clientStorage });
|
||||
export const clientPersister = createSyncStoragePersister({
|
||||
storage: clientStorage,
|
||||
});
|
||||
@@ -3,5 +3,6 @@ export enum MMKVStorageKeys {
|
||||
Server = "SERVER",
|
||||
User = "USER",
|
||||
Library = "LIBRARY",
|
||||
NowPlaying = "NowPlaying"
|
||||
NowPlaying = "NowPlaying",
|
||||
Queue = "Queue"
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function mapDtoToTrack(item: BaseItemDto, queuingType?: QueuingType) : Je
|
||||
album: item.Album,
|
||||
artist: item.Artists?.join(", "),
|
||||
duration: item.RunTimeTicks,
|
||||
artwork: getImageApi(Client.api!).getItemImageUrlById(item.Id!, ImageType.Primary, { width: 300, height: 300 }),
|
||||
artwork: item.AlbumId ? getImageApi(Client.api!).getItemImageUrlById(item.AlbumId, ImageType.Primary, { width: 300, height: 300 }) : undefined,
|
||||
|
||||
rating: isFavorite ? RatingType.Heart : undefined,
|
||||
item,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 55;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -717,7 +717,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development: Jack Caulfield (66Z9J9NX2X)";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development: Jack Caulfield (66Z9J9NX2X)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
DEVELOPMENT_TEAM = WAH9CZ8BPG;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -756,7 +756,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
DEVELOPMENT_TEAM = WAH9CZ8BPG;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
INFOPLIST_FILE = Jellify/Info.plist;
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
@@ -25,9 +17,9 @@
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
<string>3B52.1</string>
|
||||
<string>0A2A.1</string>
|
||||
<string>3B52.1</string>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
@@ -35,8 +27,16 @@
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>85F4.1</string>
|
||||
<string>E174.1</string>
|
||||
<string>85F4.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
@@ -54,7 +54,14 @@ platform :ios do
|
||||
# http://docs.fastlane.tools/actions/upload_to_testflight/#upload_to_testflight
|
||||
upload_to_testflight(
|
||||
api_key_path: "fastlane/appstore_connect_api_key.json",
|
||||
expire_previous_builds: true
|
||||
beta_app_feedback_email: "violet@cosmonautical.cloud",
|
||||
beta_app_description: "A music app for Jellyfin",
|
||||
expire_previous_builds: true,
|
||||
distribute_external: true,
|
||||
changelog: "General Functionality, User Experience, updated Sign in",
|
||||
groups: [
|
||||
"Selfhosters"
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellify",
|
||||
"version": "0.10.14",
|
||||
"version": "0.10.33",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"init": "npm i && npm run pod:install",
|
||||
@@ -25,11 +25,11 @@
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@react-navigation/native-stack": "^7.1.1",
|
||||
"@react-navigation/stack": "^7.1.0",
|
||||
"@tamagui/config": "^1.124.13",
|
||||
"@tamagui/toast": "^1.124.13",
|
||||
"@tanstack/query-sync-storage-persister": "^5.62.0",
|
||||
"@tanstack/react-query": "^5.52.1",
|
||||
"@tanstack/react-query-persist-client": "^5.62.0",
|
||||
"@tamagui/config": "^1.124.17",
|
||||
"@tamagui/toast": "^1.124.17",
|
||||
"@tanstack/query-sync-storage-persister": "^5.66.0",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-persist-client": "^5.66.0",
|
||||
"axios": "^1.7.9",
|
||||
"burnt": "^0.12.2",
|
||||
"invert-color": "^2.0.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-vector-icons": "^10.2.0",
|
||||
"tamagui": "^1.124.13",
|
||||
"tamagui": "^1.124.17",
|
||||
"expo": "^52.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Capability } from "react-native-track-player";
|
||||
|
||||
export const CAPABILITIES: Capability[] = [
|
||||
Capability.Pause,
|
||||
Capability.Play,
|
||||
Capability.PlayFromId,
|
||||
Capability.SeekTo,
|
||||
// Capability.JumpForward,
|
||||
// Capability.JumpBackward,
|
||||
Capability.SkipToNext,
|
||||
Capability.SkipToPrevious,
|
||||
// Capability.Like,
|
||||
// Capability.Dislike
|
||||
]
|
||||
@@ -1,49 +1,5 @@
|
||||
import { QueryKeys } from "../../enums/query-keys"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import TrackPlayer, { Capability, IOSCategory, IOSCategoryOptions, RatingType } from "react-native-track-player"
|
||||
|
||||
const CAPABILITIES: Capability[] = [
|
||||
Capability.Pause,
|
||||
Capability.Play,
|
||||
Capability.PlayFromId,
|
||||
Capability.SeekTo,
|
||||
// Capability.JumpForward,
|
||||
// Capability.JumpBackward,
|
||||
Capability.SkipToNext,
|
||||
Capability.SkipToPrevious,
|
||||
// Capability.Like,
|
||||
// Capability.Dislike
|
||||
]
|
||||
|
||||
export const useSetupPlayer = () => useQuery({
|
||||
queryKey: [QueryKeys.Player],
|
||||
queryFn: () => {
|
||||
return TrackPlayer.setupPlayer({
|
||||
autoHandleInterruptions: true,
|
||||
iosCategory: IOSCategory.Playback,
|
||||
iosCategoryOptions: [
|
||||
IOSCategoryOptions.AllowAirPlay,
|
||||
IOSCategoryOptions.AllowBluetooth,
|
||||
]
|
||||
}).then(() => {
|
||||
return TrackPlayer.updateOptions({
|
||||
progressUpdateEventInterval: 1,
|
||||
capabilities: CAPABILITIES,
|
||||
notificationCapabilities: CAPABILITIES,
|
||||
compactCapabilities: CAPABILITIES,
|
||||
// ratingType: RatingType.Heart,
|
||||
// likeOptions: {
|
||||
// isActive: false,
|
||||
// title: "Favorite"
|
||||
// },
|
||||
// dislikeOptions: {
|
||||
// isActive: true,
|
||||
// title: "Unfavorite"
|
||||
// }
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
import TrackPlayer, { RatingType } from "react-native-track-player"
|
||||
import { CAPABILITIES } from "../constants";
|
||||
|
||||
export const useUpdateOptions = async (isFavorite: boolean) => {
|
||||
return await TrackPlayer.updateOptions({
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { JellifyTrack } from "../types/JellifyTrack";
|
||||
import { QueuingType } from "../enums/queuing-type";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Queue } from "./types/queue-item";
|
||||
|
||||
export interface QueueMutation {
|
||||
track: BaseItemDto;
|
||||
index?: number | undefined;
|
||||
tracklist: BaseItemDto[];
|
||||
queueName: string;
|
||||
queue: Queue;
|
||||
queuingType?: QueuingType | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { JellifyTrack } from "../types/JellifyTrack";
|
||||
import { storage } from "../constants/storage";
|
||||
import { MMKVStorageKeys } from "../enums/mmkv-storage-keys";
|
||||
import { findPlayNextIndexStart, findPlayQueueIndexStart } from "./helpers/index";
|
||||
import TrackPlayer, { Event, Progress, State, usePlaybackState, useProgress, useTrackPlayerEvents } from "react-native-track-player";
|
||||
import TrackPlayer, { Event, IOSCategory, IOSCategoryOptions, Progress, State, usePlaybackState, useProgress, useTrackPlayerEvents } from "react-native-track-player";
|
||||
import { isEqual, isUndefined } from "lodash";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { handlePlaybackProgressUpdated, handlePlaybackState } from "./handlers";
|
||||
import { useSetupPlayer, useUpdateOptions } from "../player/hooks";
|
||||
import { useUpdateOptions } from "../player/hooks";
|
||||
import { UPDATE_INTERVAL } from "./config";
|
||||
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||
import { useMutation, UseMutationResult, useQuery } from "@tanstack/react-query";
|
||||
import { mapDtoToTrack } from "../helpers/mappings";
|
||||
import { QueuingType } from "../enums/queuing-type";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
@@ -18,14 +18,19 @@ import { convertRunTimeTicksToSeconds } from "../helpers/runtimeticks";
|
||||
import Client from "../api/client";
|
||||
import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from "./interfaces";
|
||||
import { Section } from "../components/Player/types";
|
||||
import { Queue } from "./types/queue-item";
|
||||
import { markItemPlayed } from "../api/mutations/functions/item";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { QueryKeys } from "../enums/query-keys";
|
||||
import { CAPABILITIES } from "./constants";
|
||||
|
||||
interface PlayerContext {
|
||||
initialized: boolean;
|
||||
nowPlayingIsFavorite: boolean;
|
||||
setNowPlayingIsFavorite: React.Dispatch<SetStateAction<boolean>>;
|
||||
nowPlaying: JellifyTrack | undefined;
|
||||
queue: JellifyTrack[];
|
||||
queueName: string | undefined;
|
||||
playQueue: JellifyTrack[];
|
||||
queue: Queue;
|
||||
getQueueSectionData: () => Section[];
|
||||
useAddToQueue: UseMutationResult<void, Error, AddToQueueMutation, unknown>;
|
||||
useClearQueue: UseMutationResult<void, Error, void, unknown>;
|
||||
@@ -43,7 +48,8 @@ interface PlayerContext {
|
||||
const PlayerContextInitializer = () => {
|
||||
|
||||
const nowPlayingJson = storage.getString(MMKVStorageKeys.NowPlaying)
|
||||
const queueJson = storage.getString(MMKVStorageKeys.PlayQueue);
|
||||
const playQueueJson = storage.getString(MMKVStorageKeys.PlayQueue);
|
||||
const queueJson = storage.getString(MMKVStorageKeys.Queue)
|
||||
|
||||
const playStateApi = getPlaystateApi(Client.api!)
|
||||
|
||||
@@ -54,9 +60,9 @@ const PlayerContextInitializer = () => {
|
||||
const [nowPlaying, setNowPlaying] = useState<JellifyTrack | undefined>(nowPlayingJson ? JSON.parse(nowPlayingJson) : undefined);
|
||||
const [isSkipping, setIsSkipping] = useState<boolean>(false);
|
||||
|
||||
const [queue, setQueue] = useState<JellifyTrack[]>(queueJson ? JSON.parse(queueJson) : []);
|
||||
const [playQueue, setPlayQueue] = useState<JellifyTrack[]>(playQueueJson ? JSON.parse(playQueueJson) : []);
|
||||
|
||||
const [queueName, setQueueName] = useState<string | undefined>(undefined);
|
||||
const [queue, setQueue] = useState<Queue>(queueJson ? JSON.parse(queueJson) : 'Queue');
|
||||
//#endregion State
|
||||
|
||||
|
||||
@@ -74,7 +80,7 @@ const PlayerContextInitializer = () => {
|
||||
return Object.keys(QueuingType).map((type) => {
|
||||
return {
|
||||
title: type,
|
||||
data: queue.filter(track => track.QueuingType === type)
|
||||
data: playQueue.filter(track => track.QueuingType === type)
|
||||
} as Section
|
||||
});
|
||||
}
|
||||
@@ -82,26 +88,26 @@ const PlayerContextInitializer = () => {
|
||||
const resetQueue = async (hideMiniplayer?: boolean | undefined) => {
|
||||
console.debug("Clearing queue")
|
||||
await TrackPlayer.reset();
|
||||
setQueue([]);
|
||||
setPlayQueue([]);
|
||||
}
|
||||
|
||||
const addToQueue = async (tracks: JellifyTrack[]) => {
|
||||
const insertIndex = await findPlayQueueIndexStart(queue);
|
||||
const insertIndex = await findPlayQueueIndexStart(playQueue);
|
||||
console.debug(`Adding ${tracks.length} to queue at index ${insertIndex}`)
|
||||
|
||||
await TrackPlayer.add(tracks, insertIndex);
|
||||
|
||||
setQueue(await getQueue() as JellifyTrack[])
|
||||
setPlayQueue(await getQueue() as JellifyTrack[])
|
||||
}
|
||||
|
||||
const addToNext = async (tracks: JellifyTrack[]) => {
|
||||
const insertIndex = await findPlayNextIndexStart(queue);
|
||||
const insertIndex = await findPlayNextIndexStart(playQueue);
|
||||
|
||||
console.debug(`Adding ${tracks.length} to queue at index ${insertIndex}`);
|
||||
|
||||
await TrackPlayer.add(tracks, insertIndex);
|
||||
|
||||
setQueue(await getQueue() as JellifyTrack[]);
|
||||
setPlayQueue(await getQueue() as JellifyTrack[]);
|
||||
}
|
||||
//#endregion Functions
|
||||
|
||||
@@ -124,7 +130,7 @@ const PlayerContextInitializer = () => {
|
||||
|
||||
await TrackPlayer.remove([index]);
|
||||
|
||||
setQueue(await TrackPlayer.getQueue() as JellifyTrack[])
|
||||
setPlayQueue(await TrackPlayer.getQueue() as JellifyTrack[])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -134,13 +140,13 @@ const PlayerContextInitializer = () => {
|
||||
|
||||
await TrackPlayer.removeUpcomingTracks();
|
||||
|
||||
setQueue(await getQueue() as JellifyTrack[]);
|
||||
setPlayQueue(await getQueue() as JellifyTrack[]);
|
||||
}
|
||||
});
|
||||
|
||||
const useReorderQueue = useMutation({
|
||||
mutationFn: async (mutation : QueueOrderMutation) => {
|
||||
setQueue(mutation.newOrder);
|
||||
setPlayQueue(mutation.newOrder);
|
||||
await TrackPlayer.move(mutation.from, mutation.to);
|
||||
}
|
||||
})
|
||||
@@ -173,13 +179,13 @@ const PlayerContextInitializer = () => {
|
||||
trigger("impactMedium")
|
||||
if (!isUndefined(index)) {
|
||||
setIsSkipping(true);
|
||||
setNowPlaying(queue[index]);
|
||||
setNowPlaying(playQueue[index]);
|
||||
await skip(index);
|
||||
setIsSkipping(false);
|
||||
}
|
||||
else {
|
||||
const nowPlayingIndex = queue.findIndex((track) => track.item.Id === nowPlaying!.item.Id);
|
||||
setNowPlaying(queue[nowPlayingIndex + 1])
|
||||
const nowPlayingIndex = playQueue.findIndex((track) => track.item.Id === nowPlaying!.item.Id);
|
||||
setNowPlaying(playQueue[nowPlayingIndex + 1])
|
||||
await skipToNext();
|
||||
}
|
||||
}
|
||||
@@ -189,10 +195,10 @@ const PlayerContextInitializer = () => {
|
||||
mutationFn: async () => {
|
||||
trigger("impactMedium");
|
||||
|
||||
const nowPlayingIndex = queue.findIndex((track) => track.item.Id === nowPlaying!.item.Id);
|
||||
const nowPlayingIndex = playQueue.findIndex((track) => track.item.Id === nowPlaying!.item.Id);
|
||||
|
||||
if (nowPlayingIndex > 0) {
|
||||
setNowPlaying(queue[nowPlayingIndex - 1])
|
||||
setNowPlaying(playQueue[nowPlayingIndex - 1])
|
||||
await skipToPrevious();
|
||||
}
|
||||
}
|
||||
@@ -212,7 +218,7 @@ const PlayerContextInitializer = () => {
|
||||
return mapDtoToTrack(track, QueuingType.FromSelection)
|
||||
}));
|
||||
|
||||
setQueueName(mutation.queueName);
|
||||
setQueue(mutation.queue);
|
||||
},
|
||||
onSuccess: async (data, mutation: QueueMutation) => {
|
||||
setIsSkipping(false);
|
||||
@@ -227,7 +233,7 @@ const PlayerContextInitializer = () => {
|
||||
//#endregion
|
||||
|
||||
//#region RNTP Setup
|
||||
const isPlayerReady = useSetupPlayer().isSuccess;
|
||||
|
||||
const { state: playbackState } = usePlaybackState();
|
||||
const progress = useProgress(UPDATE_INTERVAL);
|
||||
|
||||
@@ -291,24 +297,22 @@ const PlayerContextInitializer = () => {
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlayerReady)
|
||||
console.debug("Player is ready")
|
||||
else
|
||||
console.warn("Player could not be setup")
|
||||
}, [
|
||||
isPlayerReady
|
||||
])
|
||||
//#endregion RNTP Setup
|
||||
|
||||
//#region useEffects
|
||||
useEffect(() => {
|
||||
if (initialized && queue)
|
||||
storage.set(MMKVStorageKeys.PlayQueue, JSON.stringify(queue))
|
||||
storage.set(MMKVStorageKeys.Queue, JSON.stringify(queue))
|
||||
}, [
|
||||
queue
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized && playQueue)
|
||||
storage.set(MMKVStorageKeys.PlayQueue, JSON.stringify(playQueue))
|
||||
}, [
|
||||
playQueue
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized && nowPlaying)
|
||||
storage.set(MMKVStorageKeys.NowPlaying, JSON.stringify(nowPlaying))
|
||||
@@ -317,16 +321,16 @@ const PlayerContextInitializer = () => {
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized && queue.length > 0 && nowPlaying) {
|
||||
TrackPlayer.setQueue(queue)
|
||||
if (!initialized && playQueue.length > 0 && nowPlaying) {
|
||||
TrackPlayer.setQueue(playQueue)
|
||||
.then(() => {
|
||||
TrackPlayer.skip(queue.findIndex(track => track.item.Id! === nowPlaying.item.Id!));
|
||||
TrackPlayer.skip(playQueue.findIndex(track => track.item.Id! === nowPlaying.item.Id!));
|
||||
});
|
||||
}
|
||||
|
||||
setInitialized(true);
|
||||
}, [
|
||||
queue,
|
||||
playQueue,
|
||||
nowPlaying
|
||||
])
|
||||
//#endregion useEffects
|
||||
@@ -337,8 +341,8 @@ const PlayerContextInitializer = () => {
|
||||
nowPlayingIsFavorite,
|
||||
setNowPlayingIsFavorite,
|
||||
nowPlaying,
|
||||
playQueue,
|
||||
queue,
|
||||
queueName,
|
||||
getQueueSectionData,
|
||||
useAddToQueue,
|
||||
useClearQueue,
|
||||
@@ -361,8 +365,8 @@ export const PlayerContext = createContext<PlayerContext>({
|
||||
nowPlayingIsFavorite: false,
|
||||
setNowPlayingIsFavorite: () => {},
|
||||
nowPlaying: undefined,
|
||||
queue: [],
|
||||
queueName: undefined,
|
||||
playQueue: [],
|
||||
queue: "Recently Played",
|
||||
getQueueSectionData: () => [],
|
||||
useAddToQueue: {
|
||||
mutate: () => {},
|
||||
@@ -537,8 +541,8 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
|
||||
nowPlayingIsFavorite,
|
||||
setNowPlayingIsFavorite,
|
||||
nowPlaying,
|
||||
queue,
|
||||
queueName,
|
||||
playQueue,
|
||||
queue,
|
||||
getQueueSectionData,
|
||||
useAddToQueue,
|
||||
useClearQueue,
|
||||
@@ -558,8 +562,8 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
|
||||
nowPlayingIsFavorite,
|
||||
setNowPlayingIsFavorite,
|
||||
nowPlaying,
|
||||
playQueue,
|
||||
queue,
|
||||
queueName,
|
||||
getQueueSectionData,
|
||||
useAddToQueue,
|
||||
useClearQueue,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
export type Queue = BaseItemDto | "Recently Played" | "Search" | "Favorite Tracks";
|
||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 3.6 MiB |