Merge branch 'main' into renovate/xcodeproj-1.x

This commit is contained in:
Violet Caulfield
2025-02-16 05:09:39 -06:00
committed by GitHub
106 changed files with 1941 additions and 1491 deletions
+34 -2
View File
@@ -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>
+40 -10
View File
@@ -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
![Jellify Home](screenshots/home.png)
Home
### Favorites / Library
![Favorites](screenshots/favorites.png)
<img src="screenshots/playlist.png" alt="Jellify Home" width="275" height="600">
![Favorite Artists](screenshots/favorite_artists.png)
### Library
Library
![Album](screenshots/album.png)
<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
![Search](screenshots/search.png)
<img src="screenshots/search.png" alt="Search" width="275" height="600">
### Player
![Player](screenshots/player.png)
<img src="screenshots/player.png" alt="Player" width="275" height="600">
![Queue](screenshots/player_queue.png)
<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*. Youve been instrumental in shaping its user experience
+30
View File
@@ -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);
})
})
}
+11 -2
View File
@@ -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.
*
-2
View File
@@ -13,6 +13,4 @@ export const useApi = (serverUrl?: string, username?: string, password?: string,
return createApi(serverUrl, username, password, accessToken)
},
gcTime: 1000,
refetchInterval: false
})
-20
View File
@@ -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({
-28
View File
@@ -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)
});
+3 -1
View File
@@ -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`
}
+5
View File
@@ -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: [
+10 -6
View File
@@ -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
]
+4 -3
View File
@@ -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({
-18
View File
@@ -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,
});
-8
View File
@@ -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)
});
-30
View File
@@ -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()
});
-15
View File
@@ -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()
});
-13
View File
@@ -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()
});
-8
View File
@@ -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()
})
-31
View File
@@ -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
// }}
/>
)
}
-14
View File
@@ -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}
/>
)
}
+10 -2
View File
@@ -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
}}
/>
)
}
+31 -4
View File
@@ -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>
)
+27 -3
View File
@@ -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
}}
/>
)
}
+13 -4
View File
@@ -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;
}
}
});
+9 -10
View File
@@ -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)}`
// }
}
})
})
+1 -1
View File
@@ -5,7 +5,7 @@ import { ScrollView } from "tamagui";
export default function Index() : React.JSX.Element {
return (
<SafeAreaView>
<ScrollView>
<ScrollView removeClippedSubviews>
</ScrollView>
</SafeAreaView>
-14
View File
@@ -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;
+3 -1
View File
@@ -7,4 +7,6 @@ export const cardDimensions = {
width: 150,
height: 150
}
}
}
export const horizontalCardLimit = 20
+16 -2
View File
@@ -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
}}
/>
)
}
+1 -1
View File
@@ -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} />
)
}
+28
View File
@@ -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>
)
}
+23 -12
View File
@@ -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
});
}
}}
/>
+1
View File
@@ -12,6 +12,7 @@ export default function Button(props: ButtonProps): React.JSX.Element {
return (
<TamaguiButton
disabled={props.disabled}
bordered
marginVertical={30}
onPress={props.onPress}
>
+8 -3
View File
@@ -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>
+22 -5
View File
@@ -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>
)
}
+1 -1
View File
@@ -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>
)
}
+5 -7
View File
@@ -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>
);
}
+13 -9
View File
@@ -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>
)
}
+10 -3
View File
@@ -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
+17 -24
View File
@@ -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>
)
}
+11 -3
View File
@@ -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([
+3
View File
@@ -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
}}
/>
)
+21 -22
View File
@@ -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"
+5 -2
View File
@@ -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} />
+50 -31
View File
@@ -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={() => {
+18
View File
@@ -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>
)
}
+17 -17
View File
@@ -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);
}}>
+1 -1
View File
@@ -73,7 +73,7 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
color={theme.borderColor.val}
name="skip-next"
onPress={() => useSkip.mutate(undefined)}
/>
/>
</XStack>
</XStack>
)
+19 -8
View File
@@ -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} />
+9 -11
View File
@@ -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)
}}
/>
)
}}
+1
View File
@@ -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>();
+62 -15
View File
@@ -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>
+22 -2
View File
@@ -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
}}
/>
)
}
+12 -4
View File
@@ -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
}}
/>
)
}
+1 -1
View File
@@ -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";
+4 -1
View File
@@ -17,7 +17,10 @@ export default function Root({
const { width } = useSafeAreaFrame();
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<ScrollView
contentInsetAdjustmentBehavior="automatic"
removeClippedSubviews
>
<YGroup
alignSelf="center"
bordered
+4 -1
View File
@@ -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>
)
-36
View File
@@ -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"
/>
)
}}
/>
)
}
+41 -9
View File
@@ -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
}}
/>
)
}
+4 -2
View File
@@ -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 -1
View File
@@ -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
}
}
+5 -5
View File
@@ -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} />
)
}}
/>
+29 -6
View File
@@ -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 -2
View File
@@ -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
}
}
});
+14 -12
View File
@@ -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,
});
+2 -1
View File
@@ -3,5 +3,6 @@ export enum MMKVStorageKeys {
Server = "SERVER",
User = "USER",
Library = "LIBRARY",
NowPlaying = "NowPlaying"
NowPlaying = "NowPlaying",
Queue = "Queue"
}
+1 -1
View File
@@ -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 -3
View File
@@ -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;
+11 -11
View File
@@ -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>
+8 -1
View File
@@ -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
+753 -753
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -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": {
+14
View File
@@ -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
]
+2 -46
View File
@@ -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({
+2 -1
View File
@@ -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;
}
+48 -44
View File
@@ -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,
+3
View File
@@ -0,0 +1,3 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
export type Queue = BaseItemDto | "Recently Played" | "Search" | "Favorite Tracks";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Some files were not shown because too many files have changed in this diff Show More