mirror of
https://github.com/Jellify-Music/App.git
synced 2026-03-18 11:10:59 -05:00
Merge branch '7-implement-search-functionality' of git@github.com:anultravioletaurora/Jellify.git
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { ItemSortBy } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
11
api/queries/functions/downloads.ts
Normal file
11
api/queries/functions/downloads.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import Client from "../../../api/client";
|
||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
export async function downloadTrack(itemId: string) : Promise<void> {
|
||||
getLibraryApi(Client.api!)
|
||||
.getDownload({
|
||||
itemId
|
||||
}, {
|
||||
'responseType': 'blob'
|
||||
})
|
||||
}
|
||||
@@ -1,55 +1,22 @@
|
||||
import { Api } from "@jellyfin/sdk/lib/api"
|
||||
import { ImageFormat, ImageType } from "@jellyfin/sdk/lib/generated-client/models"
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api"
|
||||
import _ from "lodash"
|
||||
import { queryConfig } from "../query.config"
|
||||
import Client from "@/api/client"
|
||||
import Client from "../../../api/client"
|
||||
import { QueryConfig } from "../query.config";
|
||||
|
||||
|
||||
|
||||
|
||||
export function fetchImage(api: Api, itemId: string, imageType?: ImageType) : Promise<string> {
|
||||
return new Promise(async (resolve) => {
|
||||
let imageResponse = await api.axiosInstance
|
||||
.get(getImageApi(api).getItemImageUrlById(
|
||||
itemId,
|
||||
imageType,
|
||||
{
|
||||
format: queryConfig.images.format,
|
||||
fillHeight: queryConfig.images.fillHeight,
|
||||
fillWidth: queryConfig.images.fillWidth
|
||||
}
|
||||
))
|
||||
|
||||
console.debug(convertFileToBase64(imageResponse.data));
|
||||
console.debug(typeof imageResponse.data)
|
||||
resolve(convertFileToBase64(imageResponse.data));
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchItemImage(itemId: string, imageType?: ImageType, width?: number) {
|
||||
export function fetchItemImage(itemId: string, imageType?: ImageType, size?: number) {
|
||||
|
||||
return getImageApi(Client.api!)
|
||||
.getItemImage({
|
||||
itemId,
|
||||
imageType: imageType ? imageType : ImageType.Primary,
|
||||
format: ImageFormat.Jpg
|
||||
width: size ?? QueryConfig.playerArtwork.width,
|
||||
height: size ?? QueryConfig.playerArtwork.height
|
||||
}, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(convertFileToBase64(response.data))
|
||||
return convertFileToBase64(response.data);
|
||||
console.log(response)
|
||||
return URL.createObjectURL(response.data)
|
||||
});
|
||||
}
|
||||
|
||||
function base64toJpeg(encode: string) : string {
|
||||
return `data:image/jpeg;base64,${encode}`;
|
||||
}
|
||||
|
||||
function convertFileToBase64(file: any): string {
|
||||
console.debug("Converting file to base64", file)
|
||||
let encode = base64toJpeg(Buffer.from(file, 'binary').toString('base64'));
|
||||
|
||||
console.debug(encode);
|
||||
|
||||
return encode;
|
||||
}
|
||||
20
api/queries/functions/item.ts
Normal file
20
api/queries/functions/item.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import Client from "../../../api/client";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
export async function fetchItem(itemId: string) : Promise<BaseItemDto> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getItemsApi(Client.api!)
|
||||
.getItems({
|
||||
ids: [
|
||||
itemId
|
||||
]
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items && response.data.TotalRecordCount == 1)
|
||||
resolve(response.data.Items[0])
|
||||
else
|
||||
reject(`${response.data.TotalRecordCount} items returned for ID`);
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BaseItemDto, BaseItemKind, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
||||
import { queryConfig } from "../query.config";
|
||||
import { QueryConfig } from "../query.config";
|
||||
import Client from "../../client";
|
||||
|
||||
export function fetchRecentlyPlayed(): Promise<BaseItemDto[]> {
|
||||
@@ -13,7 +13,7 @@ export function fetchRecentlyPlayed(): Promise<BaseItemDto[]> {
|
||||
includeItemTypes: [
|
||||
BaseItemKind.Audio
|
||||
],
|
||||
limit: queryConfig.limits.recents,
|
||||
limit: QueryConfig.limits.recents,
|
||||
parentId: Client.library!.musicLibraryId,
|
||||
recursive: true,
|
||||
sortBy: [
|
||||
|
||||
43
api/queries/functions/search.ts
Normal file
43
api/queries/functions/search.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import Client from "../../../api/client";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import { QueryConfig } from "../query.config";
|
||||
|
||||
/**
|
||||
* Performs a search for items against the Jellyfin server, trimming whitespace
|
||||
* around the search term for the best possible results.
|
||||
* @param searchString The search term to look up against
|
||||
* @returns A promise of a BaseItemDto array, be it empty or not
|
||||
*/
|
||||
export async function fetchSearchResults(searchString: string | undefined) : Promise<BaseItemDto[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
console.debug("Searching Jellyfin for items")
|
||||
|
||||
if (isEmpty(searchString))
|
||||
resolve([]);
|
||||
|
||||
getItemsApi(Client.api!)
|
||||
.getItems({
|
||||
searchTerm: trim(searchString),
|
||||
recursive: true,
|
||||
includeItemTypes: [
|
||||
'Audio',
|
||||
'MusicAlbum',
|
||||
'MusicArtist',
|
||||
'Playlist'
|
||||
],
|
||||
limit: QueryConfig.limits.search
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items)
|
||||
resolve(response.data.Items)
|
||||
else
|
||||
resolve([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
});
|
||||
})
|
||||
}
|
||||
90
api/queries/functions/suggestions.ts
Normal file
90
api/queries/functions/suggestions.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import Client from "../../../api/client";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
export async function fetchSearchSuggestions() : Promise<BaseItemDto[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getSuggestionsApi(Client.api!)
|
||||
.getSuggestions({
|
||||
userId: Client.user!.id,
|
||||
type: [
|
||||
'MusicArtist',
|
||||
'MusicAlbum',
|
||||
'Audio',
|
||||
'Playlist'
|
||||
]
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items)
|
||||
resolve(response.data.Items)
|
||||
else
|
||||
resolve([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSuggestedArtists() : Promise<BaseItemDto[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getSuggestionsApi(Client.api!)
|
||||
.getSuggestions({
|
||||
userId: Client.user!.id,
|
||||
type: [
|
||||
'MusicArtist'
|
||||
]
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items)
|
||||
resolve(response.data.Items)
|
||||
else
|
||||
resolve([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSuggestedAlbums() : Promise<BaseItemDto[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getSuggestionsApi(Client.api!)
|
||||
.getSuggestions({
|
||||
userId: Client.user!.id,
|
||||
type: [
|
||||
'MusicAlbum'
|
||||
]
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items)
|
||||
resolve(response.data.Items)
|
||||
else
|
||||
resolve([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSuggestedTracks() : Promise<BaseItemDto[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getSuggestionsApi(Client.api!)
|
||||
.getSuggestions({
|
||||
userId: Client.user!.id,
|
||||
type: [
|
||||
'Audio'
|
||||
]
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items)
|
||||
resolve(response.data.Items)
|
||||
else
|
||||
resolve([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchItemImage } from "./functions/images";
|
||||
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
export const useItemImage = (itemId: string, imageType?: ImageType, width?: number) => useQuery({
|
||||
queryKey: [QueryKeys.ItemImage, itemId, imageType, width],
|
||||
queryFn: () => fetchItemImage(itemId, imageType, width)
|
||||
export const useItemImage = (itemId: string, imageType?: ImageType, size?: number) => useQuery({
|
||||
queryKey: [QueryKeys.ItemImage, itemId, imageType, size],
|
||||
queryFn: () => fetchItemImage(itemId, imageType, size)
|
||||
});
|
||||
8
api/queries/item.ts
Normal file
8
api/queries/item.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchItem } from "./functions/item";
|
||||
|
||||
export const useItem = (itemId: string) => useQuery({
|
||||
queryKey: [QueryKeys.Item, itemId],
|
||||
queryFn: () => fetchItem(itemId)
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ImageFormat } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
export const queryConfig = {
|
||||
export const QueryConfig = {
|
||||
limits: {
|
||||
recents: 50 // TODO: Adjust this when we add a list navigator to the end of the recents
|
||||
recents: 50, // TODO: Adjust this when we add a list navigator to the end of the recents
|
||||
search: 25,
|
||||
},
|
||||
images: {
|
||||
fillHeight: 300,
|
||||
fillWidth: 300,
|
||||
height: 300,
|
||||
width: 300,
|
||||
format: ImageFormat.Jpg
|
||||
},
|
||||
banners: {
|
||||
@@ -20,9 +21,13 @@ export const queryConfig = {
|
||||
format: ImageFormat.Png
|
||||
},
|
||||
playerArtwork: {
|
||||
fillHeight: 1000,
|
||||
fillWidth: 1000,
|
||||
height: 1000,
|
||||
width: 1000,
|
||||
format: ImageFormat.Jpg
|
||||
},
|
||||
staleTime: 1000 * 60
|
||||
staleTime: {
|
||||
oneDay: 1000 * 60 * 60 * 24, // 1 Day
|
||||
oneWeek: 1000 * 60 * 60 * 24 * 7, // 7 Days
|
||||
oneFortnight: 1000 * 60 * 60 * 24 * 7 * 14 // 14 Days
|
||||
}
|
||||
}
|
||||
8
api/queries/suggestions.ts
Normal file
8
api/queries/suggestions.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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()
|
||||
})
|
||||
@@ -2,7 +2,7 @@ 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 { queryConfig } from "./query.config";
|
||||
import { QueryConfig } from "./query.config";
|
||||
import Client from "../client";
|
||||
|
||||
export const useItemTracks = (itemId: string, sort: boolean = false) => useQuery({
|
||||
@@ -29,5 +29,5 @@ export const useItemTracks = (itemId: string, sort: boolean = false) => useQuery
|
||||
return response.data.Items ? response.data.Items! : [];
|
||||
})
|
||||
},
|
||||
staleTime: queryConfig.staleTime
|
||||
staleTime: QueryConfig.staleTime.oneDay
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { ScrollView, YStack, XStack } from "tamagui";
|
||||
import { CachedImage } from "@georstat/react-native-image-cache";
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { queryConfig } from "../../api/queries/query.config";
|
||||
import { QueryConfig } from "../../api/queries/query.config";
|
||||
import { H4, H5, Text } from "../Global/helpers/text";
|
||||
import { FlatList } from "react-native";
|
||||
import { usePlayerContext } from "../../player/provider";
|
||||
@@ -12,7 +12,7 @@ import { RunTimeTicks } from "../Global/helpers/time-codes";
|
||||
import Track from "../Global/components/track";
|
||||
import { useItemTracks } from "../../api/queries/tracks";
|
||||
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import FavoriteHeaderButton from "../Global/components/favorite-button";
|
||||
import FavoriteButton from "../Global/components/favorite-button";
|
||||
import { useEffect } from "react";
|
||||
import Client from "../../api/client";
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function Album(props: AlbumProps): React.JSX.Element {
|
||||
props.navigation.setOptions({
|
||||
headerRight: () => {
|
||||
return (
|
||||
<FavoriteHeaderButton item={props.album} />
|
||||
<FavoriteButton item={props.album} />
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -46,13 +46,17 @@ export default function Album(props: AlbumProps): React.JSX.Element {
|
||||
return (
|
||||
<SafeAreaView edges={["right", "left"]}>
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<YStack alignItems="center" minHeight={width / 1.1}>
|
||||
<YStack
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
minHeight={width / 1.1}
|
||||
>
|
||||
<CachedImage
|
||||
source={getImageApi(Client.api!)
|
||||
.getItemImageUrlById(
|
||||
props.album.Id!,
|
||||
ImageType.Primary,
|
||||
{ ...queryConfig.playerArtwork})}
|
||||
{ ...QueryConfig.playerArtwork})}
|
||||
imageStyle={{
|
||||
position: "relative",
|
||||
width: width / 1.1,
|
||||
@@ -81,10 +85,11 @@ export default function Album(props: AlbumProps): React.JSX.Element {
|
||||
|
||||
}}/>
|
||||
|
||||
<XStack justifyContent="flex-end">
|
||||
<XStack marginTop={"$3"} justifyContent="flex-end">
|
||||
<Text
|
||||
color={"$gray10"}
|
||||
color={"$purpleGray"}
|
||||
style={{ display: "block"}}
|
||||
marginRight={"$1"}
|
||||
>
|
||||
Total Runtime:
|
||||
</Text>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Albums({ navigation }: AlbumsProps) : React.JSX.Element
|
||||
subCaption={album.ProductionYear?.toString() ?? ""}
|
||||
cornered
|
||||
onPress={() => {
|
||||
navigation.navigate("Album", { album })
|
||||
navigation.push("Album", { album })
|
||||
}}
|
||||
width={width / 2.1}
|
||||
/>
|
||||
|
||||
@@ -8,10 +8,10 @@ import { H2 } from "../Global/helpers/text";
|
||||
import { useState } from "react";
|
||||
import { CachedImage } from "@georstat/react-native-image-cache";
|
||||
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { queryConfig } from "../../api/queries/query.config";
|
||||
import { QueryConfig } from "../../api/queries/query.config";
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import FavoriteHeaderButton from "../Global/components/favorite-button";
|
||||
import FavoriteButton from "../Global/components/favorite-button";
|
||||
import Client from "../../api/client";
|
||||
|
||||
interface ArtistProps {
|
||||
@@ -24,7 +24,7 @@ export default function Artist(props: ArtistProps): React.JSX.Element {
|
||||
props.navigation.setOptions({
|
||||
headerRight: () => {
|
||||
return (
|
||||
<FavoriteHeaderButton item={props.artist} />
|
||||
<FavoriteButton item={props.artist} />
|
||||
)
|
||||
}
|
||||
});
|
||||
@@ -48,7 +48,7 @@ export default function Artist(props: ArtistProps): React.JSX.Element {
|
||||
.getItemImageUrlById(
|
||||
props.artist.Id!,
|
||||
ImageType.Primary,
|
||||
{ ...queryConfig.banners})
|
||||
{ ...QueryConfig.banners})
|
||||
}
|
||||
imageStyle={{
|
||||
width: width,
|
||||
@@ -77,7 +77,7 @@ export default function Artist(props: ArtistProps): React.JSX.Element {
|
||||
cornered
|
||||
itemId={album.Id!}
|
||||
onPress={() => {
|
||||
props.navigation.navigate('Album', {
|
||||
props.navigation.push('Album', {
|
||||
album
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function Artists({ navigation }: ArtistsProps): React.JSX.Element
|
||||
itemId={artist.Id!}
|
||||
caption={artist.Name ?? "Unknown Artist"}
|
||||
onPress={() => {
|
||||
navigation.navigate("Artist", { artist })
|
||||
navigation.push("Artist", { artist })
|
||||
}}
|
||||
width={width / 2.1}
|
||||
/>
|
||||
|
||||
10
components/CarPlay/Home.tsx
Normal file
10
components/CarPlay/Home.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { View } from "tamagui";
|
||||
import { Text } from "../Global/helpers/text";
|
||||
|
||||
export default function CarPlayHome() : React.JSX.Element {
|
||||
return (
|
||||
<View>
|
||||
<Text>Yeet</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import {CarPlay, NowPlayingTemplate} from 'react-native-carplay';
|
||||
|
||||
export function NowPlaying() {
|
||||
useEffect(() => {
|
||||
const template = new NowPlayingTemplate({
|
||||
albumArtistButtonEnabled: true,
|
||||
upNextButtonEnabled: false,
|
||||
onUpNextButtonPressed() {
|
||||
console.log('up next was pressed');
|
||||
},
|
||||
onButtonPressed(e) {
|
||||
console.log(e);
|
||||
},
|
||||
});
|
||||
|
||||
CarPlay.enableNowPlaying(true);
|
||||
CarPlay.pushTemplate(template);
|
||||
|
||||
return () => {};
|
||||
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
|
||||
<Text>Now Playing</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
NowPlaying.navigationOptions = {
|
||||
headerTitle: 'Now Playing Template',
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export default function FavoritesScreen({
|
||||
caption={item.name}
|
||||
width={width / 2.1}
|
||||
onPress={() => {
|
||||
navigation.navigate(item.name)
|
||||
navigation.push(item.name)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ interface SetFavoriteMutation {
|
||||
item: BaseItemDto,
|
||||
}
|
||||
|
||||
export default function FavoriteHeaderButton({
|
||||
export default function FavoriteButton({
|
||||
item,
|
||||
onToggle
|
||||
}: {
|
||||
@@ -37,8 +37,7 @@ export default function FavoriteHeaderButton({
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsFavorite(true);
|
||||
if (onToggle)
|
||||
onToggle();
|
||||
onToggle ? onToggle() : {};
|
||||
}
|
||||
})
|
||||
|
||||
@@ -51,6 +50,7 @@ export default function FavoriteHeaderButton({
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsFavorite(false);
|
||||
onToggle ? onToggle(): {};
|
||||
}
|
||||
})
|
||||
|
||||
@@ -62,7 +62,10 @@ export default function FavoriteHeaderButton({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetched && data && data.IsFavorite)
|
||||
if (isFetched
|
||||
&& !isUndefined(data)
|
||||
&& !isUndefined(data.IsFavorite)
|
||||
)
|
||||
setIsFavorite(data.IsFavorite)
|
||||
}, [
|
||||
isFetched,
|
||||
@@ -71,22 +74,10 @@ export default function FavoriteHeaderButton({
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
setIsFavorite(
|
||||
isUndefined(item.UserData) ? false
|
||||
: item.UserData.IsFavorite ?? false
|
||||
);
|
||||
}, [
|
||||
item
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nowPlayingIsFavorite !== isFavorite && nowPlaying?.item.Id === item.Id) {
|
||||
setIsFavorite(nowPlayingIsFavorite);
|
||||
}
|
||||
}, [
|
||||
nowPlayingIsFavorite
|
||||
])
|
||||
|
||||
return (
|
||||
isFetching && isUndefined(item.UserData) ? (
|
||||
<Spinner />
|
||||
|
||||
112
components/Global/components/item.tsx
Normal file
112
components/Global/components/item.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { usePlayerContext } from "../../../player/provider";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { Separator, Spacer, View, XStack, YStack } from "tamagui";
|
||||
import { Text } from "../helpers/text";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import BlurhashedImage from "../helpers/blurhashed-image";
|
||||
import Icon from "../helpers/icon";
|
||||
import { Colors } from "../../../enums/colors";
|
||||
import { QueuingType } from "../../../enums/queuing-type";
|
||||
|
||||
export default function Item({
|
||||
item,
|
||||
queueName,
|
||||
navigation,
|
||||
} : {
|
||||
item: BaseItemDto,
|
||||
queueName: string,
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}) : React.JSX.Element {
|
||||
|
||||
const { usePlayNewQueue } = usePlayerContext();
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
return (
|
||||
<View flex={1}>
|
||||
<Separator />
|
||||
|
||||
<XStack
|
||||
alignContent="center"
|
||||
flex={1}
|
||||
minHeight={width / 9}
|
||||
onPress={() => {
|
||||
switch (item.Type) {
|
||||
case ("MusicArtist") : {
|
||||
navigation.push("Artist", {
|
||||
artist: item
|
||||
})
|
||||
break;
|
||||
}
|
||||
|
||||
case ("MusicAlbum") : {
|
||||
navigation.push("Album", {
|
||||
album: item
|
||||
})
|
||||
break;
|
||||
}
|
||||
|
||||
case ("Audio") : {
|
||||
usePlayNewQueue.mutate({
|
||||
track: item,
|
||||
tracklist: [item],
|
||||
queueName,
|
||||
queuingType: QueuingType.FromSelection
|
||||
})
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}}
|
||||
onLongPress={() => {
|
||||
navigation.push("Details", {
|
||||
item,
|
||||
isNested: false
|
||||
})
|
||||
}}
|
||||
paddingVertical={"$2"}
|
||||
marginHorizontal={"$1"}
|
||||
>
|
||||
<BlurhashedImage item={item} size={width / 9} />
|
||||
|
||||
<YStack
|
||||
marginLeft={"$1"}
|
||||
justifyContent="flex-start"
|
||||
flex={5}
|
||||
>
|
||||
<Text bold>{ item.Name ?? ""}</Text>
|
||||
{ item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
|
||||
<Text>{ item.AlbumArtist ?? "Untitled Artist" }</Text>
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<XStack alignContent="flex-end" flex={2}>
|
||||
{ item.UserData?.IsFavorite ? (
|
||||
<Icon
|
||||
small
|
||||
color={Colors.Primary}
|
||||
name="heart"
|
||||
/>
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
|
||||
<Icon
|
||||
small
|
||||
name="dots-vertical"
|
||||
onPress={() => {
|
||||
navigation.push("Details", {
|
||||
item,
|
||||
isNested: false
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { usePlayerContext } from "../../../player/provider";
|
||||
import React from "react";
|
||||
import { Separator, Spacer, View, XStack, YStack } from "tamagui";
|
||||
import { Separator, Spacer, useTheme, View, XStack, YStack } from "tamagui";
|
||||
import { Text } from "../helpers/text";
|
||||
import { RunTimeTicks } from "../helpers/time-codes";
|
||||
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Colors } from "../../../enums/colors";
|
||||
import { CachedImage } from "@georstat/react-native-image-cache";
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/image-api";
|
||||
import { queryConfig } from "../../../api/queries/query.config";
|
||||
import { QueryConfig } from "../../../api/queries/query.config";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import Icon from "../helpers/icon";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import Client from "../../../api/client";
|
||||
import { QueuingType } from "../../../enums/queuing-type";
|
||||
|
||||
interface TrackProps {
|
||||
track: BaseItemDto;
|
||||
@@ -21,6 +21,7 @@ interface TrackProps {
|
||||
index: number | undefined;
|
||||
showArtwork?: boolean | undefined;
|
||||
onPress?: () => void | undefined;
|
||||
isNested?: boolean | undefined
|
||||
}
|
||||
|
||||
export default function Track({
|
||||
@@ -30,7 +31,8 @@ export default function Track({
|
||||
index,
|
||||
queueName,
|
||||
showArtwork,
|
||||
onPress
|
||||
onPress,
|
||||
isNested
|
||||
} : {
|
||||
track: BaseItemDto,
|
||||
tracklist: BaseItemDto[],
|
||||
@@ -38,7 +40,8 @@ export default function Track({
|
||||
index?: number | undefined,
|
||||
queueName?: string | undefined,
|
||||
showArtwork?: boolean | undefined,
|
||||
onPress?: () => void | undefined
|
||||
onPress?: () => void | undefined,
|
||||
isNested?: boolean | undefined
|
||||
}) : React.JSX.Element {
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
@@ -46,6 +49,8 @@ export default function Track({
|
||||
|
||||
const isPlaying = nowPlaying?.item.Id === track.Id;
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Separator />
|
||||
@@ -60,10 +65,17 @@ export default function Track({
|
||||
track,
|
||||
index,
|
||||
tracklist,
|
||||
queueName: queueName ? queueName : track.Album ? track.Album! : "Queue"
|
||||
queueName: queueName ? queueName : track.Album ? track.Album! : "Queue",
|
||||
queuingType: QueuingType.FromSelection
|
||||
});
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
navigation.push("Details", {
|
||||
item: track,
|
||||
isNested: isNested
|
||||
})
|
||||
}}
|
||||
paddingVertical={"$2"}
|
||||
marginHorizontal={"$1"}
|
||||
>
|
||||
@@ -79,7 +91,7 @@ export default function Track({
|
||||
.getItemImageUrlById(
|
||||
track.AlbumId ?? "",
|
||||
ImageType.Primary,
|
||||
{ ...queryConfig.images }
|
||||
{ ...QueryConfig.images }
|
||||
)
|
||||
}
|
||||
imageStyle={{
|
||||
@@ -91,7 +103,7 @@ export default function Track({
|
||||
/>
|
||||
|
||||
) : (
|
||||
<Text color={isPlaying ? Colors.Primary : Colors.White}>
|
||||
<Text color={isPlaying ? theme.telemagenta : theme.color}>
|
||||
{ track.IndexNumber?.toString() ?? "" }
|
||||
</Text>
|
||||
)}
|
||||
@@ -100,7 +112,7 @@ export default function Track({
|
||||
<YStack alignContent="center" justifyContent="flex-start" flex={5}>
|
||||
<Text
|
||||
bold
|
||||
color={isPlaying ? Colors.Primary : Colors.White}
|
||||
color={isPlaying ? theme.telemagenta : theme.color}
|
||||
lineBreakStrategyIOS="standard"
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -124,7 +136,7 @@ export default function Track({
|
||||
minWidth={24}
|
||||
>
|
||||
{ track.UserData?.IsFavorite ? (
|
||||
<Icon small name="heart" color={Colors.Primary} />
|
||||
<Icon small name="heart" color={theme.telemagenta.val} />
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
@@ -143,8 +155,9 @@ export default function Track({
|
||||
>
|
||||
<Icon small name="dots-vertical" onPress={() => {
|
||||
navigation.push("Details", {
|
||||
item: track
|
||||
})
|
||||
item: track,
|
||||
isNested: isNested
|
||||
});
|
||||
}} />
|
||||
|
||||
</YStack>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface BlurhashLoadingProps {
|
||||
|
||||
export default function BlurhashedImage({ item, size, type }: { item: BaseItemDto, size: number, type?: ImageType }) : React.JSX.Element {
|
||||
|
||||
const { data: image, isSuccess } = useItemImage(item.Id!, type);
|
||||
const { data: image, isSuccess } = useItemImage(item.Id!, type, size);
|
||||
|
||||
const blurhash = !isEmpty(item.ImageBlurHashes)
|
||||
&& !isEmpty(item.ImageBlurHashes.Primary)
|
||||
@@ -19,11 +19,13 @@ export default function BlurhashedImage({ item, size, type }: { item: BaseItemDt
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<View minHeight={size}>
|
||||
<View minHeight={size} minWidth={size}>
|
||||
|
||||
{ isSuccess ? (
|
||||
<Image
|
||||
src={image}
|
||||
source={{
|
||||
uri: image
|
||||
}}
|
||||
style={{
|
||||
height: size,
|
||||
width: size,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react"
|
||||
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"
|
||||
import { Colors } from "../../../enums/colors"
|
||||
import { useColorScheme } from "react-native";
|
||||
import { ColorTokens, getTokens } from "tamagui";
|
||||
|
||||
const smallSize = 24;
|
||||
|
||||
@@ -9,14 +9,28 @@ const regularSize = 36;
|
||||
|
||||
const largeSize = 48
|
||||
|
||||
export default function Icon({ name, onPress, small, large, color }: { name: string, onPress?: () => void, small?: boolean, large?: boolean, color?: Colors }) : React.JSX.Element {
|
||||
export default function Icon({
|
||||
name, onPress,
|
||||
small,
|
||||
large,
|
||||
color
|
||||
}: {
|
||||
name: string,
|
||||
onPress?: () => void,
|
||||
small?: boolean,
|
||||
large?: boolean,
|
||||
color?: ColorTokens
|
||||
}) : React.JSX.Element {
|
||||
|
||||
const isDarkMode = useColorScheme() === "dark"
|
||||
let size = large ? largeSize : small ? smallSize : regularSize
|
||||
|
||||
return (
|
||||
<MaterialCommunityIcons
|
||||
color={color ? color : isDarkMode ? Colors.White : Colors.Background}
|
||||
color={color ? color
|
||||
: isDarkMode ? getTokens().color.$purpleGray.val
|
||||
: getTokens().color.$purpleDark.val
|
||||
}
|
||||
name={name}
|
||||
onPress={onPress}
|
||||
size={size}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Colors } from '../../../enums/colors';
|
||||
import React, { SetStateAction } from 'react';
|
||||
import React from 'react';
|
||||
import { Input as TamaguiInput} from 'tamagui';
|
||||
|
||||
interface InputProps {
|
||||
onChangeText: React.Dispatch<SetStateAction<string | undefined>>,
|
||||
onChangeText: (value: string | undefined) => void,
|
||||
placeholder: string
|
||||
value: string | undefined;
|
||||
secureTextEntry?: boolean | undefined;
|
||||
@@ -21,6 +21,7 @@ export default function Input(props: InputProps): React.JSX.Element {
|
||||
value={props.value}
|
||||
flexGrow={props.flexGrow ? 1 : "unset"}
|
||||
secureTextEntry={props.secureTextEntry}
|
||||
clearButtonMode="always"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { CachedImage } from "@georstat/react-native-image-cache";
|
||||
import invert from "invert-color"
|
||||
import { Blurhash } from "react-native-blurhash"
|
||||
import { queryConfig } from "../../../api/queries/query.config";
|
||||
import { QueryConfig } from "../../../api/queries/query.config";
|
||||
import { Text } from "./text";
|
||||
import Client from "../../../api/client";
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ItemCard(props: CardProps) {
|
||||
.getItemImageUrlById(
|
||||
props.itemId,
|
||||
ImageType.Logo,
|
||||
{ ...queryConfig.logos})
|
||||
{ ...QueryConfig.logos})
|
||||
}
|
||||
imageStyle={{
|
||||
...logoDimensions,
|
||||
@@ -72,7 +72,7 @@ export function ItemCard(props: CardProps) {
|
||||
.getItemImageUrlById(
|
||||
props.itemId,
|
||||
ImageType.Primary,
|
||||
{ ...queryConfig.images})
|
||||
{ ...QueryConfig.images})
|
||||
}
|
||||
imageStyle={{
|
||||
...dimensions,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Colors } from "../../../enums/colors";
|
||||
import React from "react";
|
||||
import { SliderProps as TamaguiSliderProps, SliderVerticalProps, Slider as TamaguiSlider, styled, Slider } from "tamagui";
|
||||
import { SliderProps as TamaguiSliderProps, SliderVerticalProps, Slider as TamaguiSlider, styled, Slider, getTokens } from "tamagui";
|
||||
|
||||
interface SliderProps {
|
||||
value?: number | undefined;
|
||||
@@ -10,16 +10,16 @@ interface SliderProps {
|
||||
}
|
||||
|
||||
const JellifySliderThumb = styled(Slider.Thumb, {
|
||||
backgroundColor: Colors.Primary,
|
||||
borderColor: Colors.Background,
|
||||
backgroundColor: getTokens().color.$telemagenta,
|
||||
borderColor: getTokens().color.$purpleGray,
|
||||
})
|
||||
|
||||
const JellifySliderTrack = styled(Slider.Track, {
|
||||
backgroundColor: Colors.Borders
|
||||
backgroundColor: getTokens().color.$purpleGray
|
||||
});
|
||||
|
||||
const JellifyActiveSliderTrack = styled(Slider.TrackActive, {
|
||||
backgroundColor: Colors.Primary
|
||||
backgroundColor: getTokens().color.$telemagenta
|
||||
})
|
||||
|
||||
export function HorizontalSlider({
|
||||
|
||||
@@ -12,7 +12,7 @@ export function RunTimeTicks({ children } : { children?: number | null | undefin
|
||||
|
||||
let time = calculateRunTimeFromTicks(children);
|
||||
|
||||
return <Text color="$gray10">{ time }</Text>
|
||||
return <Text color="$purpleGray">{ time }</Text>
|
||||
}
|
||||
|
||||
function calculateRunTimeFromSeconds(seconds: number) : string {
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function Home(): React.JSX.Element {
|
||||
headerShown: false,
|
||||
presentation: "modal"
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
</HomeStack.Group>
|
||||
</HomeStack.Navigator>
|
||||
</HomeProvider>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useUserPlaylists } from "../../../api/queries/playlist";
|
||||
import { ItemCard } from "../../../components/Global/helpers/item-card";
|
||||
import { H2 } from "../../../components/Global/helpers/text";
|
||||
import { ProvidedHomeProps } from "../../../components/types";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import React from "react";
|
||||
import { FlatList } from "react-native";
|
||||
import { View } from "tamagui";
|
||||
|
||||
export default function Playlists({ navigation }: ProvidedHomeProps) : React.JSX.Element {
|
||||
export default function Playlists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}) : React.JSX.Element {
|
||||
|
||||
const { data: playlists } = useUserPlaylists();
|
||||
|
||||
@@ -21,7 +22,7 @@ export default function Playlists({ navigation }: ProvidedHomeProps) : React.JSX
|
||||
itemId={playlist.Id!}
|
||||
caption={playlist.Name ?? "Untitled Playlist"}
|
||||
onPress={() => {
|
||||
navigation.navigate('Playlist', {
|
||||
navigation.push('Playlist', {
|
||||
playlist
|
||||
})
|
||||
}} />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { View } from "tamagui";
|
||||
import { useHomeContext } from "../provider";
|
||||
import { H2 } from "../../Global/helpers/text";
|
||||
import { ProvidedHomeProps } from "../../types";
|
||||
import { StackParamList } from "../../types";
|
||||
import { FlatList } from "react-native";
|
||||
import { ItemCard } from "../../Global/helpers/item-card";
|
||||
import { getPrimaryBlurhashFromDto } from "../../../helpers/blurhash";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
|
||||
export default function RecentArtists({ navigation }: ProvidedHomeProps): React.JSX.Element {
|
||||
export default function RecentArtists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
|
||||
|
||||
const { recentArtists } = useHomeContext();
|
||||
|
||||
@@ -24,7 +25,7 @@ export default function RecentArtists({ navigation }: ProvidedHomeProps): React.
|
||||
itemId={recentArtist.Id!}
|
||||
caption={recentArtist.Name ?? "Unknown Artist"}
|
||||
onPress={() => {
|
||||
navigation.navigate('Artist',
|
||||
navigation.push('Artist',
|
||||
{
|
||||
artist: recentArtist,
|
||||
}
|
||||
|
||||
@@ -4,8 +4,16 @@ import { useHomeContext } from "../provider";
|
||||
import { H2 } from "../../Global/helpers/text";
|
||||
import { ItemCard } from "../../Global/helpers/item-card";
|
||||
import { usePlayerContext } from "../../../player/provider";
|
||||
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";
|
||||
|
||||
export default function RecentlyPlayed(): React.JSX.Element {
|
||||
export default function RecentlyPlayed({
|
||||
navigation
|
||||
} : {
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}): React.JSX.Element {
|
||||
|
||||
const { usePlayNewQueue } = usePlayerContext();
|
||||
const { recentTracks } = useHomeContext();
|
||||
@@ -27,9 +35,17 @@ export default function RecentlyPlayed(): React.JSX.Element {
|
||||
track: recentlyPlayedTrack,
|
||||
index: index,
|
||||
tracklist: recentTracks,
|
||||
queueName: "Recently Played"
|
||||
queueName: "Recently Played",
|
||||
queuingType: QueuingType.FromSelection
|
||||
});
|
||||
}}
|
||||
onLongPress={() => {
|
||||
trigger("impactMedium");
|
||||
navigation.push("Details", {
|
||||
item: recentlyPlayedTrack,
|
||||
isNested: false
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProvidedHomeProps } from "../../../components/types";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { ScrollView, RefreshControl } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { YStack, XStack, Separator } from "tamagui";
|
||||
@@ -9,19 +9,34 @@ import { useHomeContext } from "../provider";
|
||||
import { H3 } from "../../../components/Global/helpers/text";
|
||||
import Avatar from "../../../components/Global/helpers/avatar";
|
||||
import Client from "../../../api/client";
|
||||
import { usePlayerContext } from "../../../player/provider";
|
||||
import { useEffect } from "react";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
|
||||
export function ProvidedHome({ route, navigation }: ProvidedHomeProps): React.JSX.Element {
|
||||
export function ProvidedHome({
|
||||
navigation
|
||||
} : {
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}): React.JSX.Element {
|
||||
|
||||
const { refreshing: refetching, onRefresh: onRefetch } = useHomeContext()
|
||||
|
||||
const { nowPlayingIsFavorite } = usePlayerContext();
|
||||
|
||||
useEffect(() => {
|
||||
onRefetch()
|
||||
}, [
|
||||
nowPlayingIsFavorite
|
||||
])
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={["top", "right", "left"]}>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refetching}
|
||||
onRefresh={onRefetch}
|
||||
refreshing={refetching}
|
||||
onRefresh={onRefetch}
|
||||
/>
|
||||
}>
|
||||
<YStack alignContent='flex-start'>
|
||||
@@ -33,15 +48,15 @@ export function ProvidedHome({ route, navigation }: ProvidedHomeProps): React.JS
|
||||
|
||||
<Separator marginVertical={"$2"} />
|
||||
|
||||
<RecentArtists route={route} navigation={navigation} />
|
||||
<RecentArtists navigation={navigation} />
|
||||
|
||||
<Separator marginVertical={"$3"} />
|
||||
|
||||
<RecentlyPlayed />
|
||||
<RecentlyPlayed navigation={navigation} />
|
||||
|
||||
<Separator marginVertical={"$3"} />
|
||||
|
||||
<Playlists route={route} navigation={navigation}/>
|
||||
<Playlists navigation={navigation}/>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import { StackParamList } from "../types";
|
||||
import TrackOptions from "./helpers/TrackOptions";
|
||||
import { View, XStack, YStack } from "tamagui";
|
||||
import { ScrollView, Spacer, View, XStack, YStack } from "tamagui";
|
||||
import BlurhashedImage from "../Global/helpers/blurhashed-image";
|
||||
import { Text } from "../Global/helpers/text";
|
||||
import { Colors } from "@/enums/colors";
|
||||
import { Colors } from "../../enums/colors";
|
||||
import FavoriteButton from "../Global/components/favorite-button";
|
||||
import { useEffect } from "react";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
|
||||
export default function ItemDetail({
|
||||
item,
|
||||
navigation
|
||||
navigation,
|
||||
isNested
|
||||
} : {
|
||||
item: BaseItemDto,
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
navigation: NativeStackNavigationProp<StackParamList>,
|
||||
isNested?: boolean | undefined
|
||||
}) : React.JSX.Element {
|
||||
|
||||
let options: React.JSX.Element | undefined = undefined;
|
||||
|
||||
useEffect(() => {
|
||||
trigger("impactMedium");
|
||||
}, [
|
||||
item
|
||||
]);
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
switch (item.Type) {
|
||||
case "Audio": {
|
||||
options = TrackOptions({ item, navigation });
|
||||
options = TrackOptions({ item, navigation, isNested });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -47,24 +58,44 @@ export default function ItemDetail({
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={["right", "left"]}>
|
||||
<XStack>
|
||||
<BlurhashedImage
|
||||
item={item}
|
||||
size={width / 3}
|
||||
/>
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<YStack alignItems="center" flex={1}>
|
||||
|
||||
<YStack justifyContent="flex-start">
|
||||
<Text bold fontSize={"$6"}>
|
||||
<XStack
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxHeight={width / 1.5}
|
||||
maxWidth={width / 1.5}
|
||||
>
|
||||
|
||||
<BlurhashedImage
|
||||
item={item}
|
||||
size={width / 1.5}
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
<YStack
|
||||
marginLeft={"$0.5"}
|
||||
alignContent="center"
|
||||
justifyContent="center"
|
||||
flex={2}
|
||||
>
|
||||
<Text textAlign="center" bold fontSize={"$6"}>
|
||||
{ item.Name ?? "Untitled Track" }
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
textAlign="center"
|
||||
fontSize={"$6"}
|
||||
color={Colors.Primary}
|
||||
onPress={() => {
|
||||
if (item.ArtistItems) {
|
||||
navigation.navigate("Artist", {
|
||||
|
||||
if (isNested)
|
||||
navigation.getParent()!.goBack();
|
||||
|
||||
navigation.goBack();
|
||||
navigation.push("Artist", {
|
||||
artist: item.ArtistItems[0]
|
||||
});
|
||||
}
|
||||
@@ -73,15 +104,23 @@ export default function ItemDetail({
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
textAlign="center"
|
||||
fontSize={"$6"}
|
||||
color={"$gray10"}
|
||||
color={"$purpleGray"}
|
||||
>
|
||||
{ item.Album ?? "" }
|
||||
</Text>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<FavoriteButton item={item} />
|
||||
|
||||
<Spacer />
|
||||
|
||||
{ options ?? <View /> }
|
||||
</YStack>
|
||||
|
||||
</XStack>
|
||||
{ options ?? <View /> }
|
||||
</SafeAreaView>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,62 @@
|
||||
import { usePlayerContext } from "../../../player/provider";
|
||||
import { useItem } from "../../../api/queries/item";
|
||||
import Icon from "../../../components/Global/helpers/icon";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { View } from "tamagui";
|
||||
import { XStack } from "tamagui";
|
||||
import { QueuingType } from "../../../enums/queuing-type";
|
||||
|
||||
export default function TrackOptions({
|
||||
item,
|
||||
navigation
|
||||
navigation,
|
||||
isNested
|
||||
} : {
|
||||
item: BaseItemDto,
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
navigation: NativeStackNavigationProp<StackParamList>,
|
||||
isNested: boolean | undefined// Whether this is nested in the player modal
|
||||
}) : React.JSX.Element {
|
||||
|
||||
const { data: album, isSuccess } = useItem(item.AlbumId ?? "");
|
||||
|
||||
const { useAddToQueue } = usePlayerContext();
|
||||
|
||||
return (
|
||||
<View>
|
||||
|
||||
</View>
|
||||
<XStack alignContent="flex-end" justifyContent="space-between">
|
||||
{ isSuccess && (
|
||||
<Icon
|
||||
name="music-box"
|
||||
onPress={() => {
|
||||
|
||||
if (isNested)
|
||||
navigation.getParent()!.goBack();
|
||||
|
||||
navigation.goBack();
|
||||
navigation.push("Album", {
|
||||
album
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Icon
|
||||
name="table-column-plus-before"
|
||||
onPress={() => {
|
||||
useAddToQueue.mutate({
|
||||
track: item,
|
||||
queuingType: QueuingType.PlayingNext
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<Icon
|
||||
name="table-column-plus-after"
|
||||
onPress={() => {
|
||||
useAddToQueue.mutate({
|
||||
track: item
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import React from "react";
|
||||
|
||||
export default function DetailsScreen({
|
||||
route,
|
||||
navigation
|
||||
navigation,
|
||||
} : {
|
||||
route: RouteProp<StackParamList, "Details">,
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
@@ -15,6 +15,7 @@ export default function DetailsScreen({
|
||||
<ItemDetail
|
||||
item={route.params.item}
|
||||
navigation={navigation}
|
||||
isNested={route.params.isNested}
|
||||
/>
|
||||
)
|
||||
}
|
||||
8
components/Player/component.config.ts
Normal file
8
components/Player/component.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { TextTickerProps } from "react-native-text-ticker";
|
||||
|
||||
export const TextTickerConfig : TextTickerProps = {
|
||||
duration: 5000,
|
||||
loop: true,
|
||||
repeatSpacer: 20,
|
||||
marqueeDelay: 1000
|
||||
}
|
||||
@@ -1,29 +1,34 @@
|
||||
import React, { } from "react";
|
||||
import { XStack, YStack } from "tamagui";
|
||||
import { useTheme, View, XStack, YStack } from "tamagui";
|
||||
import { usePlayerContext } from "../../player/provider";
|
||||
import { BottomTabNavigationEventMap } from "@react-navigation/bottom-tabs";
|
||||
import { NavigationHelpers, ParamListBase } from "@react-navigation/native";
|
||||
import { BlurView } from "@react-native-community/blur";
|
||||
import Icon from "../Global/helpers/icon";
|
||||
import { Text } from "../Global/helpers/text";
|
||||
import { Colors } from "../../enums/colors";
|
||||
import { CachedImage } from "@georstat/react-native-image-cache";
|
||||
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { queryConfig } from "../../api/queries/query.config";
|
||||
import { QueryConfig } from "../../api/queries/query.config";
|
||||
import TextTicker from 'react-native-text-ticker';
|
||||
import PlayPauseButton from "./helpers/buttons";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
import Client from "../../api/client";
|
||||
import { TextTickerConfig } from "./component.config";
|
||||
|
||||
export function Miniplayer({ navigation }: { navigation : NavigationHelpers<ParamListBase, BottomTabNavigationEventMap> }) : React.JSX.Element {
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const { nowPlaying, useSkip } = usePlayerContext();
|
||||
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
return (
|
||||
<BlurView overlayColor={Colors.Background}>
|
||||
<View style={{
|
||||
backgroundColor: theme.background.val,
|
||||
borderColor: theme.borderColor.val
|
||||
}}>
|
||||
{ nowPlaying && (
|
||||
|
||||
<XStack
|
||||
@@ -41,7 +46,7 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
|
||||
.getItemImageUrlById(
|
||||
nowPlaying!.item.AlbumId ?? "",
|
||||
ImageType.Primary,
|
||||
{ ...queryConfig.images }
|
||||
{ ...QueryConfig.images }
|
||||
)
|
||||
}
|
||||
imageStyle={{
|
||||
@@ -55,22 +60,17 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
|
||||
</YStack>
|
||||
|
||||
|
||||
<YStack alignContent="flex-start" flex={4} maxWidth={"$20"}>
|
||||
<TextTicker
|
||||
duration={5000}
|
||||
loop
|
||||
repeatSpacer={20}
|
||||
marqueeDelay={1000}
|
||||
>
|
||||
<YStack
|
||||
alignContent="flex-start"
|
||||
marginLeft={"$0.5"}
|
||||
flex={4}
|
||||
maxWidth={"$20"}
|
||||
>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text bold>{nowPlaying?.title ?? "Nothing Playing"}</Text>
|
||||
</TextTicker>
|
||||
|
||||
<TextTicker
|
||||
duration={5000}
|
||||
loop
|
||||
repeatSpacer={20}
|
||||
marqueeDelay={1000}
|
||||
>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text color={Colors.Primary}>{nowPlaying?.artist ?? ""}</Text>
|
||||
</TextTicker>
|
||||
</YStack>
|
||||
@@ -89,6 +89,6 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
|
||||
</XStack>
|
||||
</XStack>
|
||||
)}
|
||||
</BlurView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { queryConfig } from "../../../api/queries/query.config";
|
||||
import { HorizontalSlider } from "../../../components/Global/helpers/slider";
|
||||
import { RunTimeSeconds } from "../../../components/Global/helpers/time-codes";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { usePlayerContext } from "../../../player/provider";
|
||||
import { CachedImage } from "@georstat/react-native-image-cache";
|
||||
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
@@ -14,9 +10,10 @@ import PlayPauseButton from "../helpers/buttons";
|
||||
import { H5, Text } from "../../../components/Global/helpers/text";
|
||||
import Icon from "../../../components/Global/helpers/icon";
|
||||
import { Colors } from "../../../enums/colors";
|
||||
import { State } from "react-native-track-player";
|
||||
import FavoriteHeaderButton from "../../Global/components/favorite-button";
|
||||
import Client from "../../../api/client";
|
||||
import FavoriteButton from "../../Global/components/favorite-button";
|
||||
import BlurhashedImage from "../../../components/Global/helpers/blurhashed-image";
|
||||
import TextTicker from "react-native-text-ticker";
|
||||
import { TextTickerConfig } from "../component.config";
|
||||
|
||||
export default function PlayerScreen({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
|
||||
|
||||
@@ -59,13 +56,17 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
|
||||
<>
|
||||
<YStack>
|
||||
|
||||
<YStack alignItems="center">
|
||||
<YStack
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
>
|
||||
<Text>Playing from</Text>
|
||||
<H5>{ queueName ?? "Queue"}</H5>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<H5>{ queueName ?? "Queue"}</H5>
|
||||
</TextTicker>
|
||||
</YStack>
|
||||
|
||||
<XStack
|
||||
animation={"bouncy"}
|
||||
justifyContent="center"
|
||||
alignContent="center"
|
||||
minHeight={width / 1.1}
|
||||
@@ -73,60 +74,53 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
|
||||
useTogglePlayback.mutate(undefined)
|
||||
}}
|
||||
>
|
||||
<CachedImage
|
||||
source={getImageApi(Client.api!)
|
||||
.getItemImageUrlById(
|
||||
nowPlaying!.item.AlbumId ?? "",
|
||||
ImageType.Primary,
|
||||
{ ...queryConfig.playerArtwork }
|
||||
)
|
||||
}
|
||||
imageStyle={{
|
||||
position: "relative",
|
||||
alignSelf: "center",
|
||||
width: playbackState === State.Playing ? width / 1.1 : width / 1.4,
|
||||
height: playbackState === State.Playing ? width / 1.1 : width / 1.4,
|
||||
borderRadius: 2
|
||||
}}
|
||||
<BlurhashedImage
|
||||
item={nowPlaying!.item}
|
||||
size={width / 1.1}
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
<XStack marginHorizontal={20} paddingVertical={5}>
|
||||
<YStack justifyContent="flex-start" flex={4}>
|
||||
<Text
|
||||
bold
|
||||
fontSize={"$6"}
|
||||
>
|
||||
{nowPlaying!.title ?? "Untitled Track"}
|
||||
</Text>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text
|
||||
bold
|
||||
fontSize={"$6"}
|
||||
>
|
||||
{nowPlaying!.title ?? "Untitled Track"}
|
||||
</Text>
|
||||
</TextTicker>
|
||||
|
||||
<Text
|
||||
fontSize={"$6"}
|
||||
color={Colors.Primary}
|
||||
onPress={() => {
|
||||
if (nowPlaying!.item.ArtistItems) {
|
||||
navigation.goBack(); // Dismiss player modal
|
||||
navigation.push("Artist", {
|
||||
artist: nowPlaying!.item.ArtistItems![0],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{nowPlaying.artist ?? "Unknown Artist"}
|
||||
</Text>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text
|
||||
fontSize={"$6"}
|
||||
color={Colors.Primary}
|
||||
onPress={() => {
|
||||
if (nowPlaying!.item.ArtistItems) {
|
||||
navigation.navigate("Artist", {
|
||||
artist: nowPlaying!.item.ArtistItems![0],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{nowPlaying.artist ?? "Unknown Artist"}
|
||||
</Text>
|
||||
</TextTicker>
|
||||
|
||||
<Text
|
||||
fontSize={"$6"}
|
||||
color={"$gray10"}
|
||||
>
|
||||
{ nowPlaying!.album ?? "" }
|
||||
</Text>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text
|
||||
fontSize={"$6"}
|
||||
color={"$purpleGray"}
|
||||
>
|
||||
{ nowPlaying!.album ?? "" }
|
||||
</Text>
|
||||
</TextTicker>
|
||||
</YStack>
|
||||
|
||||
<XStack
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
flex={1}
|
||||
flex={2}
|
||||
>
|
||||
{/* Buttons for favorites, song menu go here */}
|
||||
|
||||
@@ -134,14 +128,15 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
|
||||
name="dots-horizontal-circle-outline"
|
||||
onPress={() => {
|
||||
navigation.navigate("Details", {
|
||||
item: nowPlaying!.item
|
||||
item: nowPlaying!.item,
|
||||
isNested: true
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<FavoriteHeaderButton
|
||||
<FavoriteButton
|
||||
item={nowPlaying!.item}
|
||||
onToggle={() => setNowPlayingIsFavorite(!nowPlayingIsFavorite)}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function Queue({ navigation }: { navigation: NativeStackNavigatio
|
||||
console.debug(`Skipping to index ${index}`)
|
||||
useSkip.mutate(index);
|
||||
}}
|
||||
isNested
|
||||
/>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function Player({ navigation }: { navigation: NativeStackNavigati
|
||||
name="Details"
|
||||
component={DetailsScreen}
|
||||
options={{
|
||||
headerShown: false
|
||||
headerTitle: ""
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RunTimeTicks } from "../Global/helpers/time-codes";
|
||||
import { H4, H5, Text } from "../Global/helpers/text";
|
||||
import Track from "../Global/components/track";
|
||||
import { FlatList } from "react-native";
|
||||
import { queryConfig } from "../../api/queries/query.config";
|
||||
import { QueryConfig } from "../../api/queries/query.config";
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/image-api";
|
||||
import { CachedImage } from "@georstat/react-native-image-cache";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
@@ -41,7 +41,7 @@ export default function Playlist(props: PlaylistProps): React.JSX.Element {
|
||||
.getItemImageUrlById(
|
||||
props.playlist.Id!,
|
||||
ImageType.Primary,
|
||||
{ ...queryConfig.images})}
|
||||
{ ...QueryConfig.images})}
|
||||
imageStyle={{
|
||||
position: "relative",
|
||||
width: 300,
|
||||
@@ -74,7 +74,7 @@ export default function Playlist(props: PlaylistProps): React.JSX.Element {
|
||||
|
||||
<XStack justifyContent="flex-end">
|
||||
<Text
|
||||
color={"$gray10"}
|
||||
color={"$purpleGray"}
|
||||
style={{ display: "block"}}
|
||||
>
|
||||
Total Runtime:
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
import React from "react";
|
||||
import { View } from "tamagui";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import Input from "../Global/helpers/input";
|
||||
import { debounce } from "lodash";
|
||||
import Item from "../Global/components/item";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "../types";
|
||||
import { QueryKeys } from "../../enums/query-keys";
|
||||
import { fetchSearchResults } from "../../api/queries/functions/search";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlatList } from "react-native";
|
||||
|
||||
export default function Search({
|
||||
navigation
|
||||
}: {
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}): React.JSX.Element {
|
||||
|
||||
const [searchString, setSearchString] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data: items, refetch, isFetched, isFetching } = useQuery({
|
||||
queryKey: [QueryKeys.Search, searchString],
|
||||
queryFn: () => fetchSearchResults(searchString)
|
||||
})
|
||||
|
||||
const search = useCallback(
|
||||
debounce(() => {
|
||||
refetch();
|
||||
}, 750),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchStringUpdate = (value: string | undefined) => {
|
||||
setSearchString(value)
|
||||
search();
|
||||
}
|
||||
|
||||
export default function Search(): React.JSX.Element {
|
||||
return (
|
||||
<View>
|
||||
|
||||
</View>
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
progressViewOffset={10}
|
||||
ListHeaderComponent={(
|
||||
<Input
|
||||
placeholder="The Seeker"
|
||||
onChangeText={(value) => handleSearchStringUpdate(value)}
|
||||
value={searchString}
|
||||
/>
|
||||
)}
|
||||
data={items}
|
||||
refreshing={isFetching}
|
||||
renderItem={({ index, item }) => {
|
||||
return (
|
||||
<Item item={item} queueName={searchString ?? "Search"} navigation={navigation} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
17
components/Search/screen.tsx
Normal file
17
components/Search/screen.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RouteProp } from "@react-navigation/native";
|
||||
import { StackParamList } from "../types";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import React from "react";
|
||||
import Search from "./component";
|
||||
|
||||
export default function SearchScreen({
|
||||
route,
|
||||
navigation
|
||||
} : {
|
||||
route: RouteProp<StackParamList>,
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}) : React.JSX.Element {
|
||||
return (
|
||||
<Search navigation={navigation} />
|
||||
)
|
||||
}
|
||||
67
components/Search/stack.tsx
Normal file
67
components/Search/stack.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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 { PlaylistScreen } from "../Playlist/screens";
|
||||
import DetailsScreen from "../ItemDetail/screen";
|
||||
|
||||
const Stack = createNativeStackNavigator<StackParamList>();
|
||||
|
||||
export default function SearchStack() : React.JSX.Element {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
id="Search"
|
||||
>
|
||||
<Stack.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
options={{
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="Artist"
|
||||
component={ArtistScreen}
|
||||
options={({ route }) => ({
|
||||
title: route.params.artist.Name ?? "Unknown Artist",
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="Album"
|
||||
component={AlbumScreen}
|
||||
options={({ route }) => ({
|
||||
headerShown: true,
|
||||
headerTitle: ""
|
||||
})}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="Playlist"
|
||||
component={PlaylistScreen}
|
||||
options={({ route }) => ({
|
||||
headerShown: true,
|
||||
headerTitle: ""
|
||||
})}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="Details"
|
||||
component={DetailsScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal"
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { StackParamList } from "@/components/types";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import AccountDetails from "../helpers/account-details";
|
||||
|
||||
export default function AccountDetails({
|
||||
export default function AccountDetailsScreen({
|
||||
navigation
|
||||
} : {
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}) : React.JSX.Element {
|
||||
|
||||
return (
|
||||
<AccountDetails navigation={navigation} />
|
||||
<AccountDetails />
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import { SafeAreaView } from "react-native";
|
||||
import { ListItem, ScrollView, Separator, YGroup } from "tamagui";
|
||||
import AccountDetails from "../helpers/account-details";
|
||||
import SignOut from "../helpers/sign-out";
|
||||
import ServerDetails from "../helpers/server-details";
|
||||
import LibraryDetails from "../helpers/library-details";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { StackParamList } from "@/components/types";
|
||||
import { StackParamList } from "../../../components/types";
|
||||
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
||||
|
||||
export default function Root({
|
||||
navigation
|
||||
@@ -14,33 +14,33 @@ export default function Root({
|
||||
navigation: NativeStackNavigationProp<StackParamList>
|
||||
}) : React.JSX.Element {
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<YGroup
|
||||
alignSelf="center"
|
||||
bordered
|
||||
width={240}
|
||||
size="$5"
|
||||
>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title="Account Details"
|
||||
subTitle="Everything is about you, man"
|
||||
onPress={() => {
|
||||
navigation.push("AccountDetails")
|
||||
}}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
</YGroup>
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
<ServerDetails />
|
||||
<Separator marginVertical={15} />
|
||||
<LibraryDetails />
|
||||
<SignOut />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<YGroup
|
||||
alignSelf="center"
|
||||
bordered
|
||||
width={width / 1.5}
|
||||
size="$5"
|
||||
>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title="Account Details"
|
||||
subTitle="Everything is about you, man"
|
||||
onPress={() => {
|
||||
navigation.push("AccountDetails")
|
||||
}}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
</YGroup>
|
||||
|
||||
<ServerDetails />
|
||||
<Separator marginVertical={15} />
|
||||
<LibraryDetails />
|
||||
<SignOut />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -21,9 +21,10 @@ export default function Settings(): React.JSX.Element {
|
||||
/>
|
||||
|
||||
<SettingsStack.Screen
|
||||
name="Account"
|
||||
name="AccountDetails"
|
||||
component={AccountDetails}
|
||||
options={{
|
||||
title: "Account",
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleStyle: {
|
||||
fontFamily: 'Aileron-Bold'
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createStackNavigator } from "@react-navigation/stack"
|
||||
import { NowPlaying } from "./CarPlay/NowPlaying";
|
||||
|
||||
const Stack = createStackNavigator();
|
||||
|
||||
export default function JellifyCarplay(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name="NowPlaying" component={NowPlaying} />
|
||||
</Stack.Navigator>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import { PortalProvider } from "tamagui";
|
||||
import Client from "../api/client";
|
||||
import { JellifyProvider, useJellifyContext } from "./provider";
|
||||
import { CarPlay } from "react-native-carplay"
|
||||
import JellifyCarplay from "./carplay";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
import CarPlayHome from "./CarPlay/Home";
|
||||
|
||||
export default function Jellify(): React.JSX.Element {
|
||||
|
||||
@@ -31,43 +32,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
const { loggedIn } = useJellifyContext();
|
||||
|
||||
const [carPlayConnected, setCarPlayConnected] = useState(CarPlay.connected);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("Client instance changed")
|
||||
}, [
|
||||
Client.instance
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
function onConnect() {
|
||||
setCarPlayConnected(true)
|
||||
}
|
||||
|
||||
function onDisconnect() {
|
||||
setCarPlayConnected(false)
|
||||
}
|
||||
|
||||
CarPlay.registerOnConnect(onConnect);
|
||||
CarPlay.registerOnDisconnect(onDisconnect);
|
||||
return () => {
|
||||
CarPlay.unregisterOnConnect(onConnect)
|
||||
CarPlay.unregisterOnDisconnect(onDisconnect)
|
||||
};
|
||||
});
|
||||
|
||||
return carPlayConnected ? (
|
||||
<NavigationContainer>
|
||||
{ loggedIn ? (
|
||||
<JellifyCarplay />
|
||||
) : (
|
||||
<View>
|
||||
<Text>Please login in the app before using CarPlay</Text>
|
||||
</View>
|
||||
)}
|
||||
</NavigationContainer>
|
||||
) : (
|
||||
return (
|
||||
<NavigationContainer theme={isDarkMode ? JellifyDarkTheme : JellifyLightTheme}>
|
||||
<SafeAreaProvider>
|
||||
{ loggedIn ? (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Client from "../api/client";
|
||||
import { isUndefined } from "lodash";
|
||||
import { createContext, ReactNode, useContext, useState } from "react";
|
||||
import { createContext, ReactNode, useContext, useEffect, useState } from "react";
|
||||
import { CarPlay } from "react-native-carplay";
|
||||
|
||||
interface JellifyContext {
|
||||
loggedIn: boolean;
|
||||
setLoggedIn: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
carPlayConnected: boolean;
|
||||
}
|
||||
|
||||
const JellifyContextInitializer = () => {
|
||||
@@ -15,15 +16,35 @@ const JellifyContextInitializer = () => {
|
||||
!isUndefined(Client.server)
|
||||
);
|
||||
|
||||
const [carPlayConnected, setCarPlayConnected] = useState(CarPlay.connected);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
function onConnect() {
|
||||
setCarPlayConnected(true)
|
||||
}
|
||||
|
||||
function onDisconnect() {
|
||||
setCarPlayConnected(false)
|
||||
}
|
||||
|
||||
CarPlay.registerOnConnect(onConnect);
|
||||
CarPlay.registerOnDisconnect(onDisconnect);
|
||||
return () => {
|
||||
CarPlay.unregisterOnConnect(onConnect)
|
||||
CarPlay.unregisterOnDisconnect(onDisconnect)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
loggedIn,
|
||||
setLoggedIn,
|
||||
carPlayConnected
|
||||
}
|
||||
}
|
||||
|
||||
const JellifyContext = createContext<JellifyContext>({
|
||||
loggedIn: false,
|
||||
setLoggedIn: () => {}
|
||||
carPlayConnected: false
|
||||
});
|
||||
|
||||
export const JellifyProvider: ({ children }: {
|
||||
@@ -31,14 +52,14 @@ export const JellifyProvider: ({ children }: {
|
||||
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
|
||||
const {
|
||||
loggedIn,
|
||||
setLoggedIn
|
||||
carPlayConnected
|
||||
} = JellifyContextInitializer();
|
||||
|
||||
return (
|
||||
<JellifyContext.Provider
|
||||
value={{
|
||||
loggedIn,
|
||||
setLoggedIn
|
||||
carPlayConnected
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -4,13 +4,13 @@ import Home from "./Home/component";
|
||||
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { Colors } from "../enums/colors";
|
||||
import Search from "./Search/component";
|
||||
import Favorites from "./Favorites/component";
|
||||
import Settings from "./Settings/stack";
|
||||
import { Discover } from "./Discover/component";
|
||||
import { Miniplayer } from "./Player/mini-player";
|
||||
import { Separator } from "tamagui";
|
||||
import { usePlayerContext } from "../player/provider";
|
||||
import SearchStack from "./Search/stack";
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
@@ -63,8 +63,9 @@ export function Tabs() : React.JSX.Element {
|
||||
|
||||
<Tab.Screen
|
||||
name="Search"
|
||||
component={Search}
|
||||
component={SearchStack}
|
||||
options={{
|
||||
headerShown: false,
|
||||
tabBarIcon: ({color, size }) => (
|
||||
<MaterialCommunityIcons name="magnify" color={color} size={size} />
|
||||
)
|
||||
|
||||
@@ -34,7 +34,8 @@ export type StackParamList = {
|
||||
playlist: BaseItemDto
|
||||
};
|
||||
Details: {
|
||||
item: BaseItemDto
|
||||
item: BaseItemDto,
|
||||
isNested: boolean | undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export enum Colors {
|
||||
Primary = "#cc2f71", // Telemagenta
|
||||
Secondary = "#514C63", // English Violet
|
||||
Secondary = "#66617B", // Wisteria
|
||||
Borders = "#100538", // Russian Violet
|
||||
Background = "#070217", // Rich Black
|
||||
|
||||
|
||||
@@ -35,4 +35,7 @@ export enum QueryKeys {
|
||||
FavoriteTracks = "FavoriteTracks",
|
||||
UserData = "UserData",
|
||||
UpdatePlayerOptions = "UpdatePlayerOptions",
|
||||
Item = "Item",
|
||||
Search = "Search",
|
||||
SearchSuggestions = "SearchSuggestions",
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { JellifyTrack } from "../types/JellifyTrack";
|
||||
import { RatingType, TrackType } from "react-native-track-player";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { QueuingType } from "../enums/queuing-type";
|
||||
import querystring from "querystring"
|
||||
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import Client from "../api/client";
|
||||
import { isUndefined } from "lodash";
|
||||
|
||||
const container = "opus,mp3,aac,m4a,flac,webma,webm,wav,ogg,mpa,wma";
|
||||
|
||||
// TODO: Make this configurable
|
||||
const transcodingContainer = "m4a";
|
||||
|
||||
export function mapDtoToTrack(item: BaseItemDto, queuingType?: QueuingType) : JellifyTrack {
|
||||
|
||||
const urlParams = {
|
||||
"Container": container,
|
||||
"Container": item.Container,
|
||||
"TranscodingContainer": transcodingContainer,
|
||||
"TranscodingProtocol": "hls",
|
||||
"EnableRemoteMedia": true,
|
||||
|
||||
5
index.js
5
index.js
@@ -6,6 +6,7 @@ import { PlaybackService } from './player/service'
|
||||
import TrackPlayer from 'react-native-track-player';
|
||||
import Client from './api/client';
|
||||
|
||||
Client.instance;
|
||||
AppRegistry.registerComponent(appName, () => App);
|
||||
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||
Client.instance;
|
||||
AppRegistry.registerComponent('RNCarPlayScene', () => App)
|
||||
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||
@@ -1,4 +1,5 @@
|
||||
#import "RNCarPlay.h"
|
||||
#import <RCTAppDelegate.h>
|
||||
|
||||
#ifdef DEBUG
|
||||
#ifdef FB_SONARKIT_ENABLED
|
||||
|
||||
@@ -47,6 +47,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, RCTBridgeDelegate {
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||
}
|
||||
|
||||
override func bundleURL() -> URL? {
|
||||
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index");
|
||||
}
|
||||
|
||||
private func initializeFlipper(with application: UIApplication) {
|
||||
#if DEBUG
|
||||
#if FB_SONARKIT_ENABLED
|
||||
|
||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -29,6 +29,7 @@
|
||||
"nativewind": "4.0.36",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.75.2",
|
||||
"react-native-background-actions": "^4.0.1",
|
||||
"react-native-blurhash": "^2.1.0",
|
||||
"react-native-carplay": "^2.4.1-beta.0",
|
||||
"react-native-device-info": "^11.1.0",
|
||||
@@ -9796,6 +9797,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
@@ -15417,6 +15424,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-background-actions": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-background-actions/-/react-native-background-actions-4.0.1.tgz",
|
||||
"integrity": "sha512-LADhnb4ag1oH5Lotq0j8K9e2cFmrafFyg2PCME88VkTjqDUgNcJonkNdMCTHN0N3fh+hwAA7nDR4Cxkj9Q8eCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^4.0.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/Rapsssito"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.47.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-blurhash": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-blurhash/-/react-native-blurhash-2.1.0.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"nativewind": "4.0.36",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.75.2",
|
||||
"react-native-background-actions": "^4.0.1",
|
||||
"react-native-blurhash": "^2.1.0",
|
||||
"react-native-carplay": "^2.4.1-beta.0",
|
||||
"react-native-device-info": "^11.1.0",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Jellify: A music app for Jellyfin</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Hello world!</p>
|
||||
</body>
|
||||
@@ -1 +1 @@
|
||||
export const UPDATE_INTERVAL: number = 1000
|
||||
export const UPDATE_INTERVAL: number = 250 // We need to do better rounding in the player scrubber before lowering this value for silky smoothness
|
||||
@@ -1,17 +1,18 @@
|
||||
import { isEmpty } from "lodash";
|
||||
import { QueuingType } from "../../enums/queuing-type";
|
||||
import { JellifyTrack } from "../../types/JellifyTrack";
|
||||
import { getActiveTrackIndex } from "react-native-track-player/lib/src/trackPlayer";
|
||||
|
||||
/**
|
||||
* Finds and returns the index of the player queue to insert additional tracks into
|
||||
* @param playQueue The current player queue
|
||||
* @returns The index to insert songs to play next at
|
||||
*/
|
||||
export const findPlayNextIndexStart = (playQueue: JellifyTrack[]) => {
|
||||
if (playQueue.length > 0)
|
||||
return 1
|
||||
export const findPlayNextIndexStart = async (playQueue: JellifyTrack[]) => {
|
||||
if (isEmpty(playQueue))
|
||||
return 0;
|
||||
|
||||
return 0;
|
||||
return (await getActiveTrackIndex())! + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,10 +20,18 @@ export const findPlayNextIndexStart = (playQueue: JellifyTrack[]) => {
|
||||
* @param playQueue The current player queue
|
||||
* @returns The index to insert songs to add to the user queue
|
||||
*/
|
||||
export const findPlayQueueIndexStart = (playQueue: JellifyTrack[]) => {
|
||||
export const findPlayQueueIndexStart = async (playQueue: JellifyTrack[]) => {
|
||||
|
||||
if (isEmpty(playQueue))
|
||||
return 0;
|
||||
|
||||
return playQueue.findIndex(queuedTrack => queuedTrack.QueuingType === QueuingType.FromSelection);
|
||||
const activeIndex = await getActiveTrackIndex();
|
||||
|
||||
if (playQueue.findIndex(track => track.QueuingType === QueuingType.FromSelection) === -1)
|
||||
return activeIndex! + 1
|
||||
|
||||
return playQueue.findIndex((queuedTrack, index) =>
|
||||
queuedTrack.QueuingType === QueuingType.FromSelection &&
|
||||
index > activeIndex!
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,8 @@ const CAPABILITIES: Capability[] = [
|
||||
// Capability.JumpBackward,
|
||||
Capability.SkipToNext,
|
||||
Capability.SkipToPrevious,
|
||||
Capability.Like,
|
||||
Capability.Dislike
|
||||
// Capability.Like,
|
||||
// Capability.Dislike
|
||||
]
|
||||
|
||||
export const useSetupPlayer = () => useQuery({
|
||||
@@ -31,15 +31,15 @@ export const useSetupPlayer = () => useQuery({
|
||||
capabilities: CAPABILITIES,
|
||||
notificationCapabilities: CAPABILITIES,
|
||||
compactCapabilities: CAPABILITIES,
|
||||
ratingType: RatingType.Heart,
|
||||
likeOptions: {
|
||||
isActive: false,
|
||||
title: "Favorite"
|
||||
},
|
||||
dislikeOptions: {
|
||||
isActive: true,
|
||||
title: "Unfavorite"
|
||||
}
|
||||
// ratingType: RatingType.Heart,
|
||||
// likeOptions: {
|
||||
// isActive: false,
|
||||
// title: "Favorite"
|
||||
// },
|
||||
// dislikeOptions: {
|
||||
// isActive: true,
|
||||
// title: "Unfavorite"
|
||||
// }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueuingType } from "../enums/queuing-type";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
export interface QueueMutation {
|
||||
@@ -5,4 +6,10 @@ export interface QueueMutation {
|
||||
index?: number | undefined;
|
||||
tracklist: BaseItemDto[];
|
||||
queueName: string;
|
||||
queuingType?: QueuingType | undefined;
|
||||
}
|
||||
|
||||
export interface AddToQueueMutation {
|
||||
track: BaseItemDto,
|
||||
queuingType?: QueuingType | undefined;
|
||||
}
|
||||
@@ -2,21 +2,21 @@ import { createContext, ReactNode, SetStateAction, useContext, useEffect, useSta
|
||||
import { JellifyTrack } from "../types/JellifyTrack";
|
||||
import { storage } from "../constants/storage";
|
||||
import { MMKVStorageKeys } from "../enums/mmkv-storage-keys";
|
||||
import { findPlayQueueIndexStart } from "./helpers/index";
|
||||
import TrackPlayer, { Event, Progress, State, usePlaybackState, useProgress, useTrackPlayerEvents } from "react-native-track-player";
|
||||
import { findPlayNextIndexStart, findPlayQueueIndexStart } from "./helpers/index";
|
||||
import TrackPlayer, { Event, Progress, State, Track, 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 { UPDATE_INTERVAL } from "./config";
|
||||
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||
import { QueueMutation } from "./interfaces";
|
||||
import { mapDtoToTrack } from "../helpers/mappings";
|
||||
import { QueuingType } from "../enums/queuing-type";
|
||||
import { trigger } from "react-native-haptic-feedback";
|
||||
import { getQueue, pause, seekTo, skip, skipToNext, skipToPrevious } from "react-native-track-player/lib/src/trackPlayer";
|
||||
import { convertRunTimeTicksToSeconds } from "..//helpers/runtimeticks";
|
||||
import { convertRunTimeTicksToSeconds } from "../helpers/runtimeticks";
|
||||
import Client from "../api/client";
|
||||
import { AddToQueueMutation, QueueMutation } from "./interfaces";
|
||||
|
||||
interface PlayerContext {
|
||||
showPlayer: boolean;
|
||||
@@ -28,6 +28,7 @@ interface PlayerContext {
|
||||
nowPlaying: JellifyTrack | undefined;
|
||||
queue: JellifyTrack[];
|
||||
queueName: string | undefined;
|
||||
useAddToQueue: UseMutationResult<void, Error, AddToQueueMutation, unknown>;
|
||||
useTogglePlayback: UseMutationResult<void, Error, number | undefined, unknown>;
|
||||
useSeekTo: UseMutationResult<void, Error, number, unknown>;
|
||||
useSkip: UseMutationResult<void, Error, number | undefined, unknown>;
|
||||
@@ -74,7 +75,7 @@ const PlayerContextInitializer = () => {
|
||||
}
|
||||
|
||||
const addToQueue = async (tracks: JellifyTrack[]) => {
|
||||
let insertIndex = findPlayQueueIndexStart(queue);
|
||||
const insertIndex = await findPlayQueueIndexStart(queue);
|
||||
console.debug(`Adding ${tracks.length} to queue at index ${insertIndex}`)
|
||||
|
||||
await TrackPlayer.add(tracks, insertIndex);
|
||||
@@ -83,12 +84,36 @@ const PlayerContextInitializer = () => {
|
||||
|
||||
setShowMiniplayer(true);
|
||||
}
|
||||
|
||||
const addToNext = async (tracks: JellifyTrack[]) => {
|
||||
const insertIndex = await findPlayNextIndexStart(queue);
|
||||
|
||||
console.debug(`Adding ${tracks.length} to queue at index ${insertIndex}`);
|
||||
|
||||
await TrackPlayer.add(tracks, insertIndex);
|
||||
|
||||
setQueue(await getQueue() as JellifyTrack[]);
|
||||
|
||||
setShowMiniplayer(true);
|
||||
}
|
||||
//#endregion Functions
|
||||
|
||||
//#region Hooks
|
||||
const useAddToQueue = useMutation({
|
||||
mutationFn: async (mutation: AddToQueueMutation) => {
|
||||
trigger("impactMedium");
|
||||
|
||||
if (mutation.queuingType === QueuingType.PlayingNext)
|
||||
return addToNext([mapDtoToTrack(mutation.track, mutation.queuingType)]);
|
||||
|
||||
else
|
||||
return addToQueue([mapDtoToTrack(mutation.track, mutation.queuingType)])
|
||||
}
|
||||
})
|
||||
|
||||
const useTogglePlayback = useMutation({
|
||||
mutationFn: async (index?: number | undefined) => {
|
||||
trigger("impactLight");
|
||||
trigger("impactMedium");
|
||||
if (playbackState === State.Playing)
|
||||
await pause();
|
||||
else
|
||||
@@ -98,7 +123,7 @@ const PlayerContextInitializer = () => {
|
||||
|
||||
const useSeekTo = useMutation({
|
||||
mutationFn: async (position: number) => {
|
||||
trigger('impactLight');
|
||||
trigger('impactMedium');
|
||||
await seekTo(position);
|
||||
|
||||
handlePlaybackProgressUpdated(Client.sessionId, playStateApi, nowPlaying!, {
|
||||
@@ -111,7 +136,7 @@ const PlayerContextInitializer = () => {
|
||||
|
||||
const useSkip = useMutation({
|
||||
mutationFn: async (index?: number | undefined) => {
|
||||
trigger("impactLight")
|
||||
trigger("impactMedium")
|
||||
if (!isUndefined(index)) {
|
||||
setIsSkipping(true);
|
||||
setNowPlaying(queue[index]);
|
||||
@@ -128,7 +153,7 @@ const PlayerContextInitializer = () => {
|
||||
|
||||
const usePrevious = useMutation({
|
||||
mutationFn: async () => {
|
||||
trigger("impactLight");
|
||||
trigger("impactMedium");
|
||||
|
||||
const nowPlayingIndex = queue.findIndex((track) => track.item.Id === nowPlaying!.item.Id);
|
||||
|
||||
@@ -141,7 +166,7 @@ const PlayerContextInitializer = () => {
|
||||
|
||||
const usePlayNewQueue = useMutation({
|
||||
mutationFn: async (mutation: QueueMutation) => {
|
||||
trigger("impactLight");
|
||||
trigger("impactMedium");
|
||||
|
||||
setIsSkipping(true);
|
||||
|
||||
@@ -261,6 +286,7 @@ const PlayerContextInitializer = () => {
|
||||
nowPlaying,
|
||||
queue,
|
||||
queueName,
|
||||
useAddToQueue,
|
||||
useTogglePlayback,
|
||||
useSeekTo,
|
||||
useSkip,
|
||||
@@ -283,6 +309,24 @@ export const PlayerContext = createContext<PlayerContext>({
|
||||
nowPlaying: undefined,
|
||||
queue: [],
|
||||
queueName: undefined,
|
||||
useAddToQueue: {
|
||||
mutate: () => {},
|
||||
mutateAsync: async () => {},
|
||||
data: undefined,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
isError: false,
|
||||
isIdle: true,
|
||||
isPaused: false,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
status: "idle",
|
||||
reset: () => {},
|
||||
context: {},
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0
|
||||
},
|
||||
useTogglePlayback: {
|
||||
mutate: () => {},
|
||||
mutateAsync: async () => {},
|
||||
@@ -389,6 +433,7 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
|
||||
nowPlaying,
|
||||
queue,
|
||||
queueName,
|
||||
useAddToQueue,
|
||||
useTogglePlayback,
|
||||
useSeekTo,
|
||||
useSkip,
|
||||
@@ -408,6 +453,7 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
|
||||
nowPlaying,
|
||||
queue,
|
||||
queueName,
|
||||
useAddToQueue,
|
||||
useTogglePlayback,
|
||||
useSeekTo,
|
||||
useSkip,
|
||||
|
||||
@@ -2,7 +2,7 @@ import Client from "../api/client";
|
||||
import { JellifyTrack } from "../types/JellifyTrack";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import TrackPlayer, { Event, RatingType } from "react-native-track-player";
|
||||
import { getActiveTrack } from "react-native-track-player/lib/src/trackPlayer";
|
||||
import { getActiveTrack, getActiveTrackIndex } from "react-native-track-player/lib/src/trackPlayer";
|
||||
|
||||
/**
|
||||
* Jellify Playback Service.
|
||||
@@ -41,50 +41,31 @@ export async function PlaybackService() {
|
||||
|
||||
TrackPlayer.addEventListener(Event.RemoteLike, async () => {
|
||||
|
||||
const progress = await TrackPlayer.getProgress();
|
||||
const nowPlaying = await getActiveTrack() as JellifyTrack;
|
||||
const nowPlayingIndex = await getActiveTrackIndex();
|
||||
|
||||
await getUserLibraryApi(Client.api!)
|
||||
.markFavoriteItem({
|
||||
itemId: nowPlaying.item.Id!
|
||||
});
|
||||
|
||||
await TrackPlayer.updateOptions({
|
||||
likeOptions: {
|
||||
isActive: false,
|
||||
title: "Favorite"
|
||||
},
|
||||
dislikeOptions: {
|
||||
isActive: true,
|
||||
title: "Unfavorite"
|
||||
}
|
||||
});
|
||||
await TrackPlayer.updateMetadataForTrack(nowPlayingIndex!, {
|
||||
rating: RatingType.Heart
|
||||
})
|
||||
});
|
||||
|
||||
TrackPlayer.addEventListener(Event.RemoteDislike, async () => {
|
||||
|
||||
const progress = await TrackPlayer.getProgress();
|
||||
const nowPlaying = await getActiveTrack() as JellifyTrack;
|
||||
const nowPlayingIndex = await getActiveTrackIndex();
|
||||
|
||||
await getUserLibraryApi(Client.api!)
|
||||
.markFavoriteItem({
|
||||
itemId: nowPlaying.item.Id!
|
||||
});
|
||||
|
||||
await TrackPlayer.updateNowPlayingMetadata({
|
||||
elapsedTime: progress.position,
|
||||
await TrackPlayer.updateMetadataForTrack(nowPlayingIndex!, {
|
||||
rating: undefined
|
||||
});
|
||||
|
||||
await TrackPlayer.updateOptions({
|
||||
likeOptions: {
|
||||
isActive: true,
|
||||
title: "Favorite"
|
||||
},
|
||||
dislikeOptions: {
|
||||
isActive: false,
|
||||
title: "Unfavorite"
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { animations, tokens, themes, media, shorthands } from '@tamagui/config/v3'
|
||||
import { createTamagui } from 'tamagui' // or '@tamagui/core'
|
||||
import { animations, tokens as TamaguiTokens, media, shorthands } from '@tamagui/config/v3'
|
||||
import { createTamagui, createTokens } from 'tamagui' // or '@tamagui/core'
|
||||
import { headingFont, bodyFont } from './fonts.config'
|
||||
|
||||
const tokens = createTokens({
|
||||
...TamaguiTokens,
|
||||
color: {
|
||||
purpleDark: "#070217",
|
||||
purple: "#100538",
|
||||
purpleGray: "#66617B",
|
||||
telemagenta: "#cc2f71",
|
||||
white: "#ffffff",
|
||||
black: "#000000"
|
||||
},
|
||||
})
|
||||
|
||||
const jellifyConfig = createTamagui({
|
||||
animations,
|
||||
fonts:{
|
||||
@@ -11,7 +23,18 @@ const jellifyConfig = createTamagui({
|
||||
media,
|
||||
shorthands,
|
||||
tokens,
|
||||
themes,
|
||||
themes: {
|
||||
dark: {
|
||||
background: tokens.color.purpleDark,
|
||||
borderColor: tokens.color.purpleGray,
|
||||
color: tokens.color.white
|
||||
},
|
||||
light: {
|
||||
background: tokens.color.white,
|
||||
borderColor: tokens.color.purpleGray,
|
||||
color: tokens.color.purpleDark
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type JellifyConfig = typeof jellifyConfig
|
||||
|
||||
Reference in New Issue
Block a user