mirror of
https://github.com/leinelissen/jellyfin-audio-player.git
synced 2026-09-02 21:03:11 +03:00
feat: Search upgrade (#285)
* Upgraded search page - Removed artificial combinding of Fuse and normal fetch results. - Jellyfin API results are now properly integrated into Fuse. - Fuse results are now given better ranking weights. - Added support for artist searching. - Added support for playlist searching. - Added support for instant mixes when a track is selected from search. - Made artist page to look and feel like the albums. - Added support for artist overview display. Fixed searching * Removed separate album retrieving from search * Utilized fix version of react-native-reanimated to solve build issues Code cleanup Fixed package-lock * Fixed linting * Removed debug code and fixed linting errors * [Search Upgrade] Made tracks to instant play from search - InstantMix is no longer treated as a regular playlist. - When a music track is selected from the search results - it will play a mix based on the song. - Refactored Artist page to have full-width artist image and gradient on the overview text. - Made overview fetching to be part of the original artist result to skip additional requests. - Made navigating back to work for all supported items in the search results. * Fixed variable names * feat: filters on search page and saved search queries * fix: artists view * fix: dont dispatch instantmix call * fix: update search parameters to include AlbumArtist * fix: dont query for artists in api yet * fix: remove any * fix: add missing translations for "artist" in multiple languages * fix: lint --------- Co-authored-by: Nikolay Babanov <nikolay.babanov@chaos.com> Co-authored-by: Lei Nelissen <lei@codified.nl>
This commit is contained in:
co-authored by
Nikolay Babanov
Lei Nelissen
parent
315b8836d4
commit
3d0f7ff9ef
@@ -1,3 +1,3 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.18849 19.0509C6.81935 19.4201 6.80177 20.0792 7.19728 20.466C7.58399 20.8527 8.24317 20.8439 8.61231 20.4747L13.9912 15.087L19.3789 20.4747C19.7568 20.8527 20.4072 20.8527 20.794 20.466C21.1719 20.0704 21.1807 19.4288 20.794 19.0509L15.415 13.6632L20.794 8.28431C21.1807 7.90638 21.1807 7.25599 20.794 6.86927C20.3984 6.49134 19.7568 6.48255 19.3789 6.86048L13.9912 12.2482L8.61231 6.86048C8.24317 6.49134 7.5752 6.47376 7.19728 6.86927C6.81056 7.25599 6.81935 7.91517 7.18849 8.28431L12.5762 13.6632L7.18849 19.0509Z" fill="#1C1C1E"/>
|
||||
<path d="M7.18849 19.0509C6.81935 19.4201 6.80177 20.0792 7.19728 20.466C7.58399 20.8527 8.24317 20.8439 8.61231 20.4747L13.9912 15.087L19.3789 20.4747C19.7568 20.8527 20.4072 20.8527 20.794 20.466C21.1719 20.0704 21.1807 19.4288 20.794 19.0509L15.415 13.6632L20.794 8.28431C21.1807 7.90638 21.1807 7.25599 20.794 6.86927C20.3984 6.49134 19.7568 6.48255 19.3789 6.86048L13.9912 12.2482L8.61231 6.86048C8.24317 6.49134 7.5752 6.47376 7.19728 6.86927C6.81056 7.25599 6.81935 7.91517 7.18849 8.28431L12.5762 13.6632L7.18849 19.0509Z" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 651 B After Width: | Height: | Size: 637 B |
@@ -0,0 +1,55 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { View, ViewProps } from 'react-native';
|
||||
import styled from 'styled-components/native';
|
||||
|
||||
import useDefaultStyles from '@/components/Colors';
|
||||
import { Paragraph, Text } from '@/components/Typography';
|
||||
|
||||
interface CollapsibleTextProps {
|
||||
text: string;
|
||||
closedLines?: number;
|
||||
openLines?: number;
|
||||
style?: ViewProps['style'];
|
||||
}
|
||||
|
||||
const ShowMoreButton = styled.TouchableOpacity`
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const ShowMoreText = styled(Text)`
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const CollapsibleText: React.FC<CollapsibleTextProps> = ({
|
||||
text,
|
||||
closedLines = 4,
|
||||
openLines = 0,
|
||||
style,
|
||||
}) => {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleOpen = useCallback(() => {
|
||||
setIsOpen(!isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<View style={[{ width: '100%' }, style]}>
|
||||
<Paragraph
|
||||
numberOfLines={isOpen ? openLines : closedLines}
|
||||
ellipsizeMode="tail"
|
||||
style={defaultStyles.textSmall}
|
||||
>{text}</Paragraph>
|
||||
<ShowMoreButton onPress={toggleOpen}>
|
||||
<ShowMoreText style={defaultStyles.text}>
|
||||
{isOpen ? 'Show less' : 'Show more'}
|
||||
</ShowMoreText>
|
||||
</ShowMoreButton>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleText;
|
||||
@@ -19,6 +19,12 @@ function generateStyles(scheme: ColorSchemeName, highContrast: boolean) {
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
},
|
||||
textSmall: {
|
||||
color: scheme === 'dark' ? '#fff' : '#000',
|
||||
fontSize: 12,
|
||||
lineHeight: 20,
|
||||
opacity: 0.5,
|
||||
},
|
||||
textHalfOpacity: {
|
||||
color: highContrast
|
||||
? (scheme === 'dark' ? '#ffffffbb' : '#000000bb')
|
||||
|
||||
@@ -6,9 +6,9 @@ import styled from 'styled-components/native';
|
||||
|
||||
const Screen = Dimensions.get('screen');
|
||||
|
||||
const Container = styled.View<{ size: number }>`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
const Container = styled.View<{ width: number, height: number }>`
|
||||
width: ${(props) => props.width}px;
|
||||
height: ${(props) => props.height}px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
`;
|
||||
@@ -29,6 +29,7 @@ interface Props {
|
||||
radius?: number;
|
||||
style?: ViewProps['style'];
|
||||
src?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const emptyAlbumLight = require('@/assets/images/empty-album-light.png');
|
||||
@@ -44,6 +45,7 @@ function CoverImage({
|
||||
opacity = 0.85,
|
||||
margin = 112,
|
||||
radius = 12,
|
||||
height = undefined,
|
||||
style,
|
||||
src,
|
||||
}: Props) {
|
||||
@@ -59,9 +61,9 @@ function CoverImage({
|
||||
}, [blurRadius, margin]);
|
||||
|
||||
return (
|
||||
<Container size={imageSize} style={style}>
|
||||
<Container width={imageSize} height={height || imageSize} style={style}>
|
||||
<BlurContainer size={canvasSize} offset={blurRadius}>
|
||||
<RoundedRect x={blurRadius} y={blurRadius} width={imageSize} height={imageSize} color={defaultStyles.imageBackground.backgroundColor} r={12}>
|
||||
<RoundedRect x={blurRadius} y={blurRadius} width={imageSize} height={height || imageSize} color={defaultStyles.imageBackground.backgroundColor} r={12}>
|
||||
<Shadow dx={0} dy={1} blur={2} color="#0000000d" />
|
||||
<Shadow dx={0} dy={2} blur={4} color="#0000000d" />
|
||||
<Shadow dx={0} dy={4} blur={8} color="#0000000d" />
|
||||
@@ -73,9 +75,10 @@ function CoverImage({
|
||||
<SkiaImage
|
||||
image={image}
|
||||
width={imageSize}
|
||||
height={imageSize}
|
||||
height={height || imageSize}
|
||||
opacity={opacity}
|
||||
key="image-blur"
|
||||
fit="cover"
|
||||
>
|
||||
<Offset x={blurRadius} y={blurRadius} />
|
||||
<Blur blur={blurRadius / 2} />
|
||||
@@ -84,14 +87,14 @@ function CoverImage({
|
||||
mask={
|
||||
<RoundedRect
|
||||
width={imageSize}
|
||||
height={imageSize}
|
||||
height={height || imageSize}
|
||||
x={blurRadius}
|
||||
y={blurRadius} r={radius}
|
||||
/>
|
||||
}
|
||||
key="image"
|
||||
>
|
||||
<SkiaImage image={image} width={imageSize} height={imageSize}>
|
||||
<SkiaImage image={image} fit="cover" width={imageSize} height={height || imageSize}>
|
||||
<Offset x={blurRadius} y={blurRadius} />
|
||||
</SkiaImage>
|
||||
</Mask>
|
||||
@@ -101,14 +104,14 @@ function CoverImage({
|
||||
mask={
|
||||
<RoundedRect
|
||||
width={imageSize}
|
||||
height={imageSize}
|
||||
height={height || imageSize}
|
||||
x={blurRadius}
|
||||
y={blurRadius} r={radius}
|
||||
/>
|
||||
}
|
||||
key="fallback"
|
||||
>
|
||||
<SkiaImage image={fallback} width={imageSize} height={imageSize}>
|
||||
<SkiaImage image={fallback} width={imageSize} height={height || imageSize} fit="cover">
|
||||
<Offset x={blurRadius} y={blurRadius} />
|
||||
</SkiaImage>
|
||||
</Mask>
|
||||
@@ -118,4 +121,4 @@ function CoverImage({
|
||||
);
|
||||
}
|
||||
|
||||
export default CoverImage;
|
||||
export default CoverImage;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"play-next": "Пусни следваща",
|
||||
"delete-track": "Изтрий песен",
|
||||
"artists": "Изпълнители",
|
||||
"artist": "Изпълнител",
|
||||
"queue": "Опашка",
|
||||
"error-reporting-description": "Докато използвате приложението, може да срещнете грешки. Докладването на тези грешки помага на програмата да стане по-сигурна и стабилна.",
|
||||
"set-server": "Настройте Jellyfin сървър",
|
||||
@@ -51,6 +52,8 @@
|
||||
"music": "Музика",
|
||||
"disable": "Изключи",
|
||||
"search": "Търси",
|
||||
"recent-searches": "Скорошни търсения",
|
||||
"clear-history": "Изчисти историята",
|
||||
"download-playlist": "Изтегли списък",
|
||||
"library": "Jellyfin Библиотека",
|
||||
"enable-error-reporting": "Желаете ли да пуснете докладването на грешки?",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"color-scheme-light": "Aspecte Clar",
|
||||
"color-scheme-dark": "Aspecte Fosc",
|
||||
"artists": "Artistes",
|
||||
"artist": "Artista",
|
||||
"privacy-policy": "Política de Privacitat",
|
||||
"reset-cache": "Restablir la memòria cau",
|
||||
"track": "Cançó",
|
||||
@@ -47,6 +48,8 @@
|
||||
"albums": "Àlbums",
|
||||
"all-albums": "Tots els Àlbums",
|
||||
"search": "Cerca",
|
||||
"recent-searches": "Cerques recents",
|
||||
"clear-history": "Esborra l'historial",
|
||||
"music": "Música",
|
||||
"now-playing": "Reproduïnt Ara",
|
||||
"onboarding-welcome": "Benvinguts!",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"play-next": "Přehrát další",
|
||||
"delete-track": "Odstranit skladbu",
|
||||
"artists": "Umělci",
|
||||
"artist": "Umělec",
|
||||
"queue": "Fronta",
|
||||
"error-reporting-description": "Během používání této aplikace můžete narazit na chyby. Nahlášení těchto chyb pomáhá vytvářet bezpečnější a stabilnější prostředí aplikace.",
|
||||
"set-server": "Nastavit server",
|
||||
@@ -51,6 +52,8 @@
|
||||
"music": "Hudba",
|
||||
"disable": "Zakázat",
|
||||
"search": "Vyhledat",
|
||||
"recent-searches": "Nedávná vyhledávání",
|
||||
"clear-history": "Vymazat historii",
|
||||
"download-playlist": "Stáhnnout seznam skladeb",
|
||||
"library": "Knihovna",
|
||||
"enable-error-reporting": "Chcete povolit hlášení chyb?",
|
||||
|
||||
@@ -20,10 +20,13 @@
|
||||
"color-scheme-dark": "Mørkt tema",
|
||||
"privacy-policy": "Privatlivspolitik",
|
||||
"search": "Søg",
|
||||
"recent-searches": "Seneste søgninger",
|
||||
"clear-history": "Ryd historik",
|
||||
"settings": "Indstillinger",
|
||||
"onboarding-welcome": "Velkommen!",
|
||||
"more-info": "Mere info",
|
||||
"artists": "Kunstnere",
|
||||
"artist": "Kunstner",
|
||||
"now-playing": "Afspiller nu",
|
||||
"albums": "Albummer",
|
||||
"all-albums": "Alle albummer",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"color-scheme-light": "Heller-Modus",
|
||||
"color-scheme-dark": "Dunkler-Modus",
|
||||
"artists": "Künstler",
|
||||
"artist": "Künstler",
|
||||
"privacy-policy": "Datenschutz-Bestimmungen",
|
||||
"play-next": "Als nächstes abspielen",
|
||||
"play-album": "Album abspielen",
|
||||
@@ -24,6 +25,8 @@
|
||||
"albums": "Alben",
|
||||
"all-albums": "Alle Alben",
|
||||
"search": "Suchen",
|
||||
"recent-searches": "Letzte Suchanfragen",
|
||||
"clear-history": "Verlauf löschen",
|
||||
"music": "Musik",
|
||||
"now-playing": "Läuft gerade",
|
||||
"onboarding-intro": "Mit Fintunes können Sie Ihre Musikbibliothek von überall aus streamen, mit voller Unterstützung für Hintergrundwiedergabe und Casting.",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"albums": "Albums",
|
||||
"all-albums": "All Albums",
|
||||
"search": "Search",
|
||||
"recent-searches": "Recent Searches",
|
||||
"clear-history": "Clear History",
|
||||
"music": "Music",
|
||||
"now-playing": "Now Playing",
|
||||
"onboarding-welcome": "Welcome!",
|
||||
@@ -71,6 +73,7 @@
|
||||
"color-scheme-light": "Light Mode",
|
||||
"color-scheme-dark": "Dark Mode",
|
||||
"artists": "Artists",
|
||||
"artist": "Artist",
|
||||
"privacy-policy": "Privacy Policy",
|
||||
"sleep-timer": "Sleep timer",
|
||||
"delete": "Delete",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"albums": "Álbumes",
|
||||
"all-albums": "Todos los Álbumes",
|
||||
"search": "Buscar",
|
||||
"recent-searches": "Búsquedas recientes",
|
||||
"clear-history": "Borrar historial",
|
||||
"music": "Música",
|
||||
"now-playing": "Reproduciendo ahora",
|
||||
"onboarding-welcome": "Bienvenido!",
|
||||
@@ -62,6 +64,7 @@
|
||||
"delete-track": "Eliminar pista",
|
||||
"color-scheme-dark": "Modo oscuro",
|
||||
"artists": "Artistas",
|
||||
"artist": "Artista",
|
||||
"error-reporting": "Error al reportar",
|
||||
"error-reporting-description": "Durante el uso de esta aplicación, puede encontrar errores. Informar estos errores ayuda a crear una experiencia de aplicación más segura y estable.",
|
||||
"why-use-tracking-description": "El rastreo ayuda a acelerar el desarrollo de esta aplicación informando de casos extraños y descuidos. Esto ayuda a hacer la aplicación más estable y robusta, mejorando así la experiencia de la aplicación para todos.",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"play-album": "Toista albumi",
|
||||
"all-albums": "Kaikki albumit",
|
||||
"search": "Etsi",
|
||||
"recent-searches": "Viimeisimmät haut",
|
||||
"clear-history": "Tyhjennä historia",
|
||||
"sleep-timer": "Uniajastin",
|
||||
"delete": "Poista",
|
||||
"cancel": "Peruuta",
|
||||
@@ -41,6 +43,7 @@
|
||||
"color-scheme-light": "Vaalea tila",
|
||||
"color-scheme-dark": "Tumma tila",
|
||||
"artists": "Artistit",
|
||||
"artist": "Artisti",
|
||||
"privacy-policy": "Yksityisyydensuoja",
|
||||
"why-use-tracking": "Miksi käyttää seurantaa?",
|
||||
"what-data-is-gathered": "Mitä tietoja kerätään?",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"albums": "Albums",
|
||||
"all-albums": "Tous les Albums",
|
||||
"search": "Rechercher",
|
||||
"recent-searches": "Recherches récentes",
|
||||
"clear-history": "Effacer l'historique",
|
||||
"music": "Musique",
|
||||
"now-playing": "En cours",
|
||||
"onboarding-welcome": "Bienvenue !",
|
||||
@@ -61,6 +63,7 @@
|
||||
"color-scheme-light": "Mode clair",
|
||||
"color-scheme-dark": "Mode sombre",
|
||||
"artists": "Artistes",
|
||||
"artist": "Artiste",
|
||||
"download-track": "Télécharger le morceau",
|
||||
"download-playlist": "Télécharger la liste de lecture",
|
||||
"delete-track": "Supprimer le morceau",
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
"album": "Album",
|
||||
"albums": "Albums",
|
||||
"all-albums": "Semua Album",
|
||||
"recent-searches": "Pencarian Terbaru",
|
||||
"clear-history": "Hapus Riwayat",
|
||||
"search": "Mencari",
|
||||
"music": "Musik",
|
||||
"now-playing": "Sedang Diputar",
|
||||
@@ -77,6 +79,7 @@
|
||||
"color-scheme-system": "Sistem",
|
||||
"color-scheme-dark": "Mode Gelap",
|
||||
"artists": "Seniman",
|
||||
"artist": "Seniman",
|
||||
"privacy-policy": "Kebijakan Privasi",
|
||||
"sleep-timer": "Pengatur waktu tidur",
|
||||
"delete": "Menghapus",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"color-scheme-light": "Modalità Luce",
|
||||
"color-scheme-dark": "Modalità Notte",
|
||||
"artists": "Artisti",
|
||||
"artist": "Artista",
|
||||
"play-next": "Riproduci Prossima",
|
||||
"play-album": "Riproduci Album",
|
||||
"queue": "Coda",
|
||||
@@ -18,6 +19,8 @@
|
||||
"albums": "Album",
|
||||
"all-albums": "Tutti gli Album",
|
||||
"search": "Cerca",
|
||||
"recent-searches": "Ricerche recenti",
|
||||
"clear-history": "Cancella cronologia",
|
||||
"music": "Musica",
|
||||
"now-playing": "Ora in Riproduzione",
|
||||
"onboarding-welcome": "Benvenuto!",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"albums": "アルバム",
|
||||
"all-albums": "すべてのアルバム",
|
||||
"search": "検索",
|
||||
"recent-searches": "最近の検索",
|
||||
"clear-history": "履歴を消去",
|
||||
"music": "音楽",
|
||||
"now-playing": "再生中",
|
||||
"onboarding-welcome": "ようこそ。",
|
||||
@@ -65,6 +67,7 @@
|
||||
"delete-album": "アルバムを削除",
|
||||
"color-scheme": "カラースキーム",
|
||||
"artists": "アーティスト",
|
||||
"artist": "アーティスト",
|
||||
"downloads": "ダウンロード",
|
||||
"no-downloads": "まだ何もダウンロードしていません",
|
||||
"streaming": "ストリーミング",
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"album": "Album",
|
||||
"albums": "Album",
|
||||
"search": "Søk",
|
||||
"recent-searches": "Siste søk",
|
||||
"clear-history": "Tøm historikk",
|
||||
"music": "Musikk",
|
||||
"recent-albums": "Nylige album",
|
||||
"error-reporting": "Feilrapportering",
|
||||
@@ -21,6 +23,7 @@
|
||||
"color-scheme-light": "Lysmodus",
|
||||
"color-scheme-dark": "Mørk modus",
|
||||
"artists": "Artister",
|
||||
"artist": "Artist",
|
||||
"all-albums": "Alle album",
|
||||
"now-playing": "Spilles nå",
|
||||
"onboarding-welcome": "Velkommen.",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"albums": "Albums",
|
||||
"all-albums": "Alle Albums",
|
||||
"search": "Zoeken",
|
||||
"recent-searches": "Recente zoekopdrachten",
|
||||
"clear-history": "Geschiedenis wissen",
|
||||
"music": "Muziek",
|
||||
"now-playing": "Nu spelend",
|
||||
"onboarding-welcome": "Welkom!",
|
||||
@@ -67,6 +69,7 @@
|
||||
"color-scheme-description": "Fintunes volgt standaard het kleurenschema van uw besturingssysteem. U kunt er echter voor kiezen om dit te overschrijven om ervoor te zorgen dat Fintunes altijd in de donkere of lichte modus staat.",
|
||||
"color-scheme": "Kleurenschema",
|
||||
"artists": "Artiesten",
|
||||
"artist": "Artiest",
|
||||
"playback-reporting": "Afspeelrapportage",
|
||||
"color-scheme-system": "Systeem",
|
||||
"color-scheme-light": "Lichte modus",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"albums": "Albumy",
|
||||
"all-albums": "Wszystkie albumy",
|
||||
"search": "Szukaj",
|
||||
"recent-searches": "Ostatnie wyszukiwania",
|
||||
"clear-history": "Wyczyść historię",
|
||||
"music": "Muzyka",
|
||||
"now-playing": "Teraz gra",
|
||||
"onboarding-cta": "Aby rozpocząć pracę, potrzebujesz serwera Jellyfin. Kliknij poniższy przycisk, aby wprowadzić adres serwera Jellyfin i zalogować się do niego.",
|
||||
@@ -70,6 +72,7 @@
|
||||
"color-scheme-description": "Domyślnie Fintunes będzie stosować się do schematu kolorów Twojego systemu operacyjnego. Możesz jednak pominąć tę opcję, aby mieć pewność, że Fintunes jest zawsze w trybie ciemnym lub jasnym.",
|
||||
"color-scheme-system": "System",
|
||||
"artists": "Artysta",
|
||||
"artist": "Artysta",
|
||||
"privacy-policy": "Polityka prywatności",
|
||||
"sleep-timer": "Timer uśpienia",
|
||||
"delete": "Usuń",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"albums": "Álbuns",
|
||||
"all-albums": "Todos os Álbuns",
|
||||
"search": "Pesquisar",
|
||||
"recent-searches": "Pesquisas recentes",
|
||||
"clear-history": "Limpar histórico",
|
||||
"music": "Música",
|
||||
"now-playing": "A Reproduzir",
|
||||
"onboarding-welcome": "Bem-vindo!",
|
||||
@@ -71,6 +73,7 @@
|
||||
"color-scheme-light": "Modo Claro",
|
||||
"color-scheme-dark": "Modo Escuro",
|
||||
"artists": "Artistas",
|
||||
"artist": "Artista",
|
||||
"privacy-policy": "Politica de Privacidade",
|
||||
"sleep-timer": "Temporizador para Dormir",
|
||||
"delete": "Eliminar",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"play-album": "Tocar álbum",
|
||||
"queue": "Fila",
|
||||
"search": "Buscar",
|
||||
"recent-searches": "Pesquisas recentes",
|
||||
"clear-history": "Limpar histórico",
|
||||
"music": "Música",
|
||||
"now-playing": "Tocando agora",
|
||||
"onboarding-welcome": "Boas-vindas!",
|
||||
@@ -70,6 +72,7 @@
|
||||
"color-scheme-light": "Modo Claro",
|
||||
"color-scheme-dark": "Modo Escuro",
|
||||
"artists": "Artistas",
|
||||
"artist": "Artista",
|
||||
"privacy-policy": "Política de Privacidade",
|
||||
"sleep-timer": "Temporizador de sono",
|
||||
"confirm-delete-all-tracks": "Tem a certeza que deseja excluir todas as faixas atualmente descarregadas?",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"albums": "Альбомы",
|
||||
"all-albums": "Все альбомы",
|
||||
"search": "Поиск",
|
||||
"recent-searches": "Недавние поиски",
|
||||
"clear-history": "Очистить историю",
|
||||
"music": "Музыка",
|
||||
"now-playing": "Сейчас играет",
|
||||
"onboarding-welcome": "Добро пожаловать!",
|
||||
@@ -70,6 +72,7 @@
|
||||
"color-scheme-light": "Светлая тема",
|
||||
"color-scheme-dark": "Темная тема",
|
||||
"artists": "Исполнители",
|
||||
"artist": "Исполнитель",
|
||||
"privacy-policy": "Политика конфиденциальности",
|
||||
"sleep-timer": "Время сна",
|
||||
"confirm-delete-all-tracks": "Вы уверены, что хотите удалить все загруженные в настоящее время треки?",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"albums": "Albumi",
|
||||
"all-albums": "Vsi albumi",
|
||||
"search": "Iskanje",
|
||||
"recent-searches": "Nedavna iskanja",
|
||||
"clear-history": "Počisti zgodovino",
|
||||
"music": "Glasba",
|
||||
"now-playing": "Zdaj igra",
|
||||
"set-server": "Nastavite strežnik Jellyfin",
|
||||
@@ -70,6 +72,7 @@
|
||||
"color-scheme-light": "Svetli način",
|
||||
"color-scheme-dark": "Temni način",
|
||||
"artists": "Izvajalci",
|
||||
"artist": "Izvajalec",
|
||||
"privacy-policy": "Politika zasebnosti",
|
||||
"sleep-timer": "Časovnik za spanje",
|
||||
"cancel": "Prekliči",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"playing-on": "Spelar vidare",
|
||||
"local-playback": "Lokal uppspelning",
|
||||
"artists": "Artister",
|
||||
"artist": "Artist",
|
||||
"privacy-policy": "Integritetspolicy",
|
||||
"play-next": "Spela nästa",
|
||||
"play-album": "Spela album",
|
||||
@@ -13,6 +14,8 @@
|
||||
"albums": "Album",
|
||||
"all-albums": "Alla album",
|
||||
"search": "Sök",
|
||||
"recent-searches": "Senaste sökningar",
|
||||
"clear-history": "Rensa historik",
|
||||
"music": "Musik",
|
||||
"now-playing": "Nu spelas",
|
||||
"onboarding-welcome": "Välkommen!",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"album": "தொகுப்பு",
|
||||
"albums": "தொகுப்புகள்",
|
||||
"search": "தேடல்",
|
||||
"recent-searches": "சமீபத்திய தேடல்கள்",
|
||||
"clear-history": "வரலாற்றை அழி",
|
||||
"music": "இசை",
|
||||
"enable": "இயக்கு",
|
||||
"delete": "நீக்கு",
|
||||
@@ -30,6 +32,7 @@
|
||||
"color-scheme-light": "ஒளி பயன்முறை",
|
||||
"color-scheme-dark": "இருண்ட முறை",
|
||||
"artists": "கலைஞர்கள்",
|
||||
"artist": "கலைஞர்",
|
||||
"privacy-policy": "தனியுரிமைக் கொள்கை",
|
||||
"sleep-timer": "தூக்க நேரங்குறிகருவி",
|
||||
"cancel": "ரத்துசெய்",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"albums": "Альбоми",
|
||||
"all-albums": "Всі альбоми",
|
||||
"search": "Пошук",
|
||||
"recent-searches": "Недавні пошуки",
|
||||
"clear-history": "Очистити історію",
|
||||
"music": "Музика",
|
||||
"now-playing": "Зараз грає",
|
||||
"onboarding-welcome": "Ласкаво просимо!",
|
||||
@@ -70,6 +72,7 @@
|
||||
"delete-track": "Видалити трек",
|
||||
"color-scheme-dark": "Темний режим",
|
||||
"artists": "Виконавець",
|
||||
"artist": "Виконавець",
|
||||
"privacy-policy": "Політика конфіденційності",
|
||||
"sleep-timer": "Таймер сну",
|
||||
"confirm-delete-all-tracks": "Ви впевнені, що ви хочете видалити всі завантажені треки?",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"albums": "专辑",
|
||||
"all-albums": "所有专辑",
|
||||
"search": "搜索",
|
||||
"recent-searches": "最近搜索",
|
||||
"clear-history": "清除历史",
|
||||
"music": "歌曲",
|
||||
"now-playing": "正在播放",
|
||||
"onboarding-welcome": "欢迎",
|
||||
@@ -67,6 +69,7 @@
|
||||
"color-scheme": "配色方案",
|
||||
"color-scheme-system": "系统",
|
||||
"artists": "艺术家",
|
||||
"artist": "艺术家",
|
||||
"color-scheme-dark": "深色模式",
|
||||
"playback-reporting-description": "使用播放报告,您的所有播放事件都会被转发回您的服务器。这让您可以跟踪您最常听的歌曲,特别是使用 ListenBrainz 或 LastFM 等插件时。",
|
||||
"color-scheme-light": "浅色模式",
|
||||
|
||||
@@ -31,9 +31,12 @@
|
||||
"albums": "專輯",
|
||||
"all-albums": "全部專輯",
|
||||
"search": "搜尋",
|
||||
"recent-searches": "最近搜尋",
|
||||
"clear-history": "清除歷史",
|
||||
"music": "音樂",
|
||||
"now-playing": "正在播放",
|
||||
"artists": "藝術家",
|
||||
"artist": "藝術家",
|
||||
"privacy-policy": "隱私權政策",
|
||||
"sleep-timer": "睡眠倒數",
|
||||
"delete": "刪除",
|
||||
|
||||
@@ -8,6 +8,8 @@ export type LocaleKeys = 'play-next'
|
||||
| 'albums'
|
||||
| 'all-albums'
|
||||
| 'search'
|
||||
| 'recent-searches'
|
||||
| 'clear-history'
|
||||
| 'music'
|
||||
| 'now-playing'
|
||||
| 'onboarding-welcome'
|
||||
@@ -70,6 +72,7 @@ export type LocaleKeys = 'play-next'
|
||||
| 'color-scheme-light'
|
||||
| 'color-scheme-dark'
|
||||
| 'artists'
|
||||
| 'artist'
|
||||
| 'privacy-policy'
|
||||
| 'sleep-timer'
|
||||
| 'delete'
|
||||
|
||||
@@ -37,7 +37,7 @@ function MusicStack() {
|
||||
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
|
||||
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
|
||||
<Stack.Screen name="Artists" component={Artists} options={{ headerTitle: t('artists') }} />
|
||||
<Stack.Screen name="Artist" component={Artist} options={({ route }) => ({ headerTitle: route.params.Name })} />
|
||||
<Stack.Screen name="Artist" component={Artist} options={({ route }) => ({ headerTitle: route.params.name })} />
|
||||
<Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />
|
||||
<Stack.Screen name="Playlist" component={Playlist} options={{ headerTitle: t('playlist') }} />
|
||||
</Stack.Navigator>
|
||||
@@ -47,4 +47,4 @@ function MusicStack() {
|
||||
);
|
||||
}
|
||||
|
||||
export default MusicStack;
|
||||
export default MusicStack;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import styled from 'styled-components';
|
||||
import { Dimensions, Pressable } from 'react-native';
|
||||
import AlbumImage from './components/AlbumImage';
|
||||
import useDefaultStyles from '@/components/Colors';
|
||||
|
||||
type Route = RouteProp<StackParams, 'Album'>;
|
||||
|
||||
@@ -53,6 +54,7 @@ function SimilarAlbum({ id }: { id: string }) {
|
||||
const Album: React.FC = () => {
|
||||
const { params: { id } } = useRoute<Route>();
|
||||
const dispatch = useAppDispatch();
|
||||
const defaultStyles = useDefaultStyles();
|
||||
|
||||
// Retrieve the album data from the store
|
||||
const album = useTypedSelector((state) => state.music.albums.entities[id]);
|
||||
@@ -85,7 +87,7 @@ const Album: React.FC = () => {
|
||||
deleteButtonText={t('delete-album')}
|
||||
>
|
||||
{album?.Overview ? (
|
||||
<Text style={{ opacity: 0.5, lineHeight: 20, fontSize: 12, paddingBottom: 24 }}>{album?.Overview}</Text>
|
||||
<Text style={[defaultStyles.textSmall, { paddingBottom: 24 }]}>{album?.Overview}</Text>
|
||||
) : null}
|
||||
{album?.Similar?.length ? (
|
||||
<>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { chunk } from 'lodash';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import { View } from 'react-native';
|
||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||
import styled from 'styled-components/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useAppDispatch, useTypedSelector } from '@/store';
|
||||
import { fetchAllAlbums } from '@/store/music/actions';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from '@/CONSTANTS';
|
||||
import TouchableHandler from '@/components/TouchableHandler';
|
||||
import AlbumImage, { AlbumItem } from './components/AlbumImage';
|
||||
import styled from 'styled-components/native';
|
||||
import useDefaultStyles from '@/components/Colors';
|
||||
import { Album } from '@/store/music/types';
|
||||
import { Text } from '@/components/Typography';
|
||||
import { SubHeader, Text } from '@/components/Typography';
|
||||
import { ShadowWrapper } from '@/components/Shadow';
|
||||
import { NavigationProp, StackParams } from '@/screens/types';
|
||||
import { SafeFlatList } from '@/components/SafeNavigatorView';
|
||||
import { chunk } from 'lodash';
|
||||
import CoverImage from '@/components/CoverImage';
|
||||
import CollapsibleText from '@/components/CollapsibleText';
|
||||
import { t } from '@/localisation';
|
||||
|
||||
import AlbumImage, { AlbumItem } from './components/AlbumImage';
|
||||
|
||||
interface GeneratedAlbumItemProps {
|
||||
id: string | number;
|
||||
@@ -29,6 +33,12 @@ const HalfOpacity = styled.Text`
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const ArtistImageContainer = styled.View`
|
||||
margin: 24px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const GeneratedAlbumItem = React.memo(function GeneratedAlbumItem(props: GeneratedAlbumItemProps) {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const { id, imageUrl, name, artist, onPress } = props;
|
||||
@@ -46,20 +56,34 @@ const GeneratedAlbumItem = React.memo(function GeneratedAlbumItem(props: Generat
|
||||
);
|
||||
});
|
||||
|
||||
const Artist: React.FC = () => {
|
||||
|
||||
export default function Artist() {
|
||||
const { params } = useRoute<RouteProp<StackParams, 'Artist'>>();
|
||||
|
||||
// Retrieve data from store
|
||||
const { entities: albums } = useTypedSelector((state) => state.music.albums);
|
||||
const { ids: allAlbumIds, entities: albums } = useTypedSelector((state) => state.music.albums);
|
||||
const isLoading = useTypedSelector((state) => state.music.albums.isLoading);
|
||||
const lastRefreshed = useTypedSelector((state) => state.music.albums.lastRefreshed);
|
||||
|
||||
const artist = useTypedSelector((state) => state.music.artists.entities[params.id]);
|
||||
|
||||
const albumIds = useMemo(() => {
|
||||
return allAlbumIds.filter(id => {
|
||||
const album = albums[id];
|
||||
return album?.ArtistItems?.find(item => item.Id === params.id);
|
||||
});
|
||||
}, [allAlbumIds, albums, params.id]);
|
||||
|
||||
// console.log(artist);
|
||||
|
||||
// Initialise helpers
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const { params } = useRoute<RouteProp<StackParams, 'Artist'>>();
|
||||
const getImage = useGetImage();
|
||||
|
||||
// Set callbacks
|
||||
const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]);
|
||||
const retrieveData = useCallback(() => {
|
||||
dispatch(fetchAllAlbums());
|
||||
}, [dispatch]);
|
||||
const selectAlbum = useCallback((id: string) => navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]);
|
||||
const generateItem = useCallback(({ item }: { item: string[] }) => {
|
||||
return (
|
||||
@@ -85,16 +109,22 @@ const Artist: React.FC = () => {
|
||||
retrieveData();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<SafeFlatList
|
||||
data={chunk(params.albumIds, 2)}
|
||||
ListHeaderComponent={
|
||||
<View style={{ padding: 24, paddingTop: 0, paddingBottom: 8 }}>
|
||||
<ArtistImageContainer>
|
||||
<CoverImage src={getImage(artist)} margin={48} height={200} />
|
||||
</ArtistImageContainer>
|
||||
{artist?.Overview ? <CollapsibleText text={artist.Overview} /> : null}
|
||||
<SubHeader>{t('albums')}</SubHeader>
|
||||
</View>
|
||||
}
|
||||
data={chunk(albumIds, 2)}
|
||||
refreshing={isLoading}
|
||||
onRefresh={retrieveData}
|
||||
renderItem={generateItem}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default Artist;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useAppDispatch, useTypedSelector } from '@/store';
|
||||
import { fetchAllAlbums } from '@/store/music/actions';
|
||||
import { fetchAllArtists } from '@/store/music/actions';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from '@/CONSTANTS';
|
||||
import AlbumImage from './components/AlbumImage';
|
||||
import { SectionArtistItem, selectArtists } from '@/store/music/selectors';
|
||||
@@ -60,7 +60,7 @@ const SectionHeading = React.memo(function SectionHeading(props: { label: string
|
||||
interface GeneratedArtistItemProps {
|
||||
item: SectionArtistItem;
|
||||
imageURL: string | undefined;
|
||||
onPress: (payload: SectionArtistItem) => void;
|
||||
onPress: (payload: { id: string; name: string; }) => void;
|
||||
}
|
||||
|
||||
const GeneratedArtistItem = React.memo(function GeneratedArtistItem(props: GeneratedArtistItemProps) {
|
||||
@@ -68,7 +68,7 @@ const GeneratedArtistItem = React.memo(function GeneratedArtistItem(props: Gener
|
||||
const { item, imageURL, onPress } = props;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(item);
|
||||
onPress({ id: item.Id, name: item.Name });
|
||||
}, [item, onPress]);
|
||||
|
||||
return (
|
||||
@@ -101,8 +101,8 @@ const GeneratedArtistItem = React.memo(function GeneratedArtistItem(props: Gener
|
||||
|
||||
const Artists: React.FC = () => {
|
||||
// Retrieve data from store
|
||||
const isLoading = useTypedSelector((state) => state.music.albums.isLoading);
|
||||
const lastRefreshed = useTypedSelector((state) => state.music.albums.lastRefreshed);
|
||||
const isLoading = useTypedSelector((state) => state.music.artists.isLoading);
|
||||
const lastRefreshed = useTypedSelector((state) => state.music.artists.lastRefreshed);
|
||||
const sections = useTypedSelector(selectArtists);
|
||||
|
||||
// Initialise helpers
|
||||
@@ -134,8 +134,8 @@ const Artists: React.FC = () => {
|
||||
}, [flatData]);
|
||||
|
||||
// Set callbacks
|
||||
const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]);
|
||||
const selectArtist = useCallback((payload: SectionArtistItem) => (
|
||||
const retrieveData = useCallback(() => dispatch(fetchAllArtists()), [dispatch]);
|
||||
const selectArtist = useCallback((payload: { id: string; name: string; }) => (
|
||||
navigation.navigate('Artist', payload)
|
||||
), [navigation]);
|
||||
const selectLetter = useCallback(({ letter }: { letter: string, index: number }) => {
|
||||
@@ -187,4 +187,4 @@ const Artists: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Artists;
|
||||
export default Artists;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ALBUM_CACHE_AMOUNT_OF_DAYS } from '@/CONSTANTS';
|
||||
import { t } from '@/localisation';
|
||||
import { StackParams } from '@/screens/types';
|
||||
|
||||
type Route = RouteProp<StackParams, 'Album'>;
|
||||
type Route = RouteProp<StackParams, 'Playlist'>;
|
||||
|
||||
const Playlist: React.FC = () => {
|
||||
const { params: { id } } = useRoute<Route>();
|
||||
@@ -19,7 +19,10 @@ const Playlist: React.FC = () => {
|
||||
const playlistTracks = useTypedSelector((state) => state.music.tracks.byPlaylist[id]);
|
||||
|
||||
// Define a function for refreshing this entity
|
||||
const refresh = useCallback(() => dispatch(fetchTracksByPlaylist(id)), [dispatch, id]);
|
||||
const refresh = useCallback(
|
||||
() => dispatch(fetchTracksByPlaylist(id)),
|
||||
[dispatch, id]
|
||||
);
|
||||
|
||||
// Auto-fetch the track data periodically
|
||||
useEffect(() => {
|
||||
@@ -44,4 +47,4 @@ const Playlist: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Playlist;
|
||||
export default Playlist;
|
||||
|
||||
@@ -299,4 +299,4 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackListView;
|
||||
export default TrackListView;
|
||||
|
||||
@@ -5,6 +5,8 @@ import useDefaultStyles, { ColoredBlurView } from '@/components/Colors';
|
||||
import { StackParams } from '@/screens/types';
|
||||
import Search from './stacks/Search';
|
||||
import Album from '@/screens/Music/stacks/Album';
|
||||
import Artist from '@/screens/Music/stacks/Artist';
|
||||
import Playlist from '@/screens/Music/stacks/Playlist';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import NowPlaying from '@/screens/Music/overlays/NowPlaying';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
@@ -36,11 +38,13 @@ function SearchStack() {
|
||||
>
|
||||
<Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search'), headerShown: false }} />
|
||||
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
|
||||
<Stack.Screen name="Artist" component={Artist} options={({ route }) => ({ headerTitle: route.params.name })} />
|
||||
<Stack.Screen name="Playlist" component={Playlist} options={{ headerTitle: t('playlist') }} />
|
||||
</Stack.Navigator>
|
||||
<NowPlaying offset={isInitialRoute ? 64 : 0} />
|
||||
<NowPlaying offset={isInitialRoute ? 104 : 0} />
|
||||
</GestureHandlerRootView>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchStack;
|
||||
export default SearchStack;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import Input from '@/components/Input';
|
||||
import { ActivityIndicator, Animated, KeyboardAvoidingView, Platform, View } from 'react-native';
|
||||
import { ActivityIndicator, Animated, KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native';
|
||||
import styled from 'styled-components/native';
|
||||
import { useAppDispatch, useTypedSelector } from '@/store';
|
||||
|
||||
import { AppState, useAppDispatch, useTypedSelector } from '@/store';
|
||||
import Fuse, { IFuseOptions } from 'fuse.js';
|
||||
import { Album, AlbumTrack } from '@/store/music/types';
|
||||
import { Album, AlbumTrack, MusicArtist, Playlist } from '@/store/music/types';
|
||||
import { addSearchQuery, clearSearchHistory } from '@/store/search';
|
||||
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import TouchableHandler from '@/components/TouchableHandler';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import { t } from '@/localisation';
|
||||
import useDefaultStyles, { ColoredBlurView } from '@/components/Colors';
|
||||
import { searchAndFetchAlbums } from '@/store/music/actions';
|
||||
import { debounce } from 'lodash';
|
||||
import { searchAndFetch } from '@/store/music/actions';
|
||||
import { Text } from '@/components/Typography';
|
||||
import DownloadIcon from '@/components/DownloadIcon';
|
||||
import ChevronRight from '@/assets/icons/chevron-right.svg';
|
||||
@@ -21,21 +24,25 @@ import { ShadowWrapper } from '@/components/Shadow';
|
||||
import { NavigationProp } from '@/screens/types';
|
||||
import { useNavigationOffsets } from '@/components/SafeNavigatorView';
|
||||
import BaseAlbumImage from '@/screens/Music/stacks/components/AlbumImage';
|
||||
import usePlayTracks from '@/utility/usePlayTracks';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
// import MicrophoneIcon from '@/assets/icons/microphone.svg';
|
||||
// import AlbumIcon from '@/assets/icons/collection.svg';
|
||||
// import TrackIcon from '@/assets/icons/note.svg';
|
||||
// import PlaylistIcon from '@/assets/icons/note-list.svg';
|
||||
// import StreamIcon from '@/assets/icons/cloud.svg';
|
||||
// import LocalIcon from '@/assets/icons/internal-drive.svg';
|
||||
// import SelectableFilter from './components/SelectableFilter';
|
||||
import MicrophoneIcon from '@/assets/icons/microphone.svg';
|
||||
import AlbumIcon from '@/assets/icons/collection.svg';
|
||||
import TrackIcon from '@/assets/icons/note.svg';
|
||||
import PlaylistIcon from '@/assets/icons/note-list.svg';
|
||||
import LocalIcon from '@/assets/icons/internal-drive.svg';
|
||||
import TrashIcon from '@/assets/icons/trash.svg';
|
||||
import XMarkIcon from '@/assets/icons/xmark.svg';
|
||||
import SelectableFilter from './components/SelectableFilter';
|
||||
import Button from '@/components/Button';
|
||||
import { retrieveInstantMixByTrackId } from '@/utility/JellyfinApi/playlist';
|
||||
|
||||
const KEYBOARD_OFFSET = Platform.select({
|
||||
ios: 0,
|
||||
// Android 15+ has edge-to-edge support, changing the keyboard offset to 0
|
||||
android: Number.parseInt(Platform.Version as string) >= 35 ? 0 : 72,
|
||||
});
|
||||
const SEARCH_INPUT_HEIGHT = 62;
|
||||
const SEARCH_INPUT_HEIGHT = 104;
|
||||
|
||||
const Container = styled(View)`
|
||||
padding: 4px 24px 0 24px;
|
||||
@@ -57,7 +64,16 @@ const Loading = styled.View`
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const AlbumImage = styled(BaseAlbumImage)`
|
||||
const ClearButton = styled.TouchableOpacity`
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
const SearchItemImage = styled(BaseAlbumImage)`
|
||||
border-radius: 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -78,45 +94,121 @@ const SearchResult = styled.View`
|
||||
height: 54px;
|
||||
`;
|
||||
|
||||
const fuseOptions: IFuseOptions<Album> = {
|
||||
keys: ['Name', 'AlbumArtist', 'AlbumArtists', 'Artists'],
|
||||
const HistoryItem = styled.View`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 32px;
|
||||
min-height: 54px;
|
||||
`;
|
||||
|
||||
const HistoryTextContainer = styled.View`
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
const HistoryIconWrapper = styled.View`
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
const fuseOptions: IFuseOptions<Album | AlbumTrack | MusicArtist | Playlist> = {
|
||||
keys: [
|
||||
{
|
||||
name: 'Name',
|
||||
weight: 5
|
||||
},
|
||||
{
|
||||
name: 'AlbumArtist',
|
||||
weight: 0.7
|
||||
},
|
||||
{
|
||||
name: 'AlbumArtists',
|
||||
weight: 0.7
|
||||
},
|
||||
{
|
||||
name: 'Artists',
|
||||
weight: 0.7
|
||||
}
|
||||
],
|
||||
threshold: 0.1,
|
||||
includeScore: true,
|
||||
fieldNormWeight: 1,
|
||||
includeScore: true
|
||||
};
|
||||
|
||||
type AudioResult = {
|
||||
type: 'Audio',
|
||||
type SearchType = 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist';
|
||||
|
||||
interface SearchResult {
|
||||
type: SearchType;
|
||||
id: string;
|
||||
album: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AlbumResult = {
|
||||
type: 'AlbumArtist',
|
||||
id: string;
|
||||
album: undefined;
|
||||
name: undefined;
|
||||
album?: string;
|
||||
}
|
||||
|
||||
type CombinedResults = (AudioResult | AlbumResult)[];
|
||||
type SearchItem = Album | AlbumTrack | MusicArtist | Playlist;
|
||||
|
||||
const albumSelector = (state: AppState) => state.music.albums.entities;
|
||||
const tracksSelector = (state: AppState) => state.music.tracks.entities;
|
||||
const artistsSelector = (state: AppState) => state.music.artists.entities;
|
||||
const playlistsSelector = (state: AppState) => state.music.playlists.entities;
|
||||
const downloadsSelector = (state: AppState) => state.downloads.entities;
|
||||
const searchHistorySelector = (state: AppState) => state.search.queryHistory;
|
||||
|
||||
export default function Search() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const offsets = useNavigationOffsets({ includeOverlay: false });
|
||||
const playTracks = usePlayTracks();
|
||||
|
||||
// Prepare state for fuse and albums
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
|
||||
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
|
||||
const albums = useTypedSelector(state => state.music.albums.entities);
|
||||
const [fuseResults, setFuseResults] = useState<SearchResult[]>([]);
|
||||
const [activeFilters, setActiveFilters] = useState<Set<SearchType>>(new Set());
|
||||
const [localPlaybackOnly, setLocalPlaybackOnly] = useState(false);
|
||||
|
||||
const albumEntities: Record<string, Album> = useTypedSelector(albumSelector);
|
||||
const trackEntities: Record<string, AlbumTrack> = useTypedSelector(tracksSelector);
|
||||
const artistEntities: Record<string, MusicArtist> = useTypedSelector(artistsSelector);
|
||||
const playlistEntities: Record<string, Playlist> = useTypedSelector(playlistsSelector);
|
||||
const downloadEntities = useTypedSelector(downloadsSelector);
|
||||
const searchHistory = useTypedSelector(searchHistorySelector);
|
||||
|
||||
// Prepare helpers
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
/**
|
||||
* This function retrieves search results from Jellyfin. It is a seperate
|
||||
* callback, so that we can make sure it is properly debounced and doesn't
|
||||
* cause execessive jank in the interface.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string) => {
|
||||
await dispatch(searchAndFetch({ term: searchTerm }));
|
||||
|
||||
// Loading is now complete
|
||||
setLoading(false);
|
||||
}, 150), [dispatch]);
|
||||
|
||||
/**
|
||||
* Debounced function to save search query to history after 10 seconds
|
||||
* to avoid saving incomplete searches
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const saveSearchToHistory = useCallback(debounce((query: string, filters: SearchType[], localOnly: boolean) => {
|
||||
dispatch(addSearchQuery({
|
||||
query,
|
||||
filters,
|
||||
localPlaybackOnly: localOnly,
|
||||
}));
|
||||
}, 10_000), [dispatch]);
|
||||
|
||||
|
||||
const searchItems = useMemo(() => ({
|
||||
...albumEntities,
|
||||
...trackEntities,
|
||||
...artistEntities,
|
||||
...playlistEntities
|
||||
}), [albumEntities, trackEntities, artistEntities, playlistEntities]);
|
||||
|
||||
/**
|
||||
* Since it is impractical to have a global fuse variable, we need to
|
||||
* instantiate it for thsi function. With this effect, we generate a new
|
||||
@@ -124,47 +216,10 @@ export default function Search() {
|
||||
* more intelligently by removing and adding the changed albums, but this is
|
||||
* an open todo.
|
||||
*/
|
||||
const fuse = useMemo(() => (
|
||||
new Fuse(Object.values(albums) as Album[], fuseOptions)
|
||||
), [albums]);
|
||||
|
||||
/**
|
||||
* This function retrieves search results from Jellyfin. It is a seperate
|
||||
* callback, so that we can make sure it is properly debounced and doesn't
|
||||
* cause execessive jank in the interface.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string, currentResults: CombinedResults) => {
|
||||
// First, query the Jellyfin API
|
||||
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm }));
|
||||
|
||||
// Convert the current results to album ids
|
||||
const albumIds = currentResults.map(item => item.id);
|
||||
|
||||
// Parse the result in correct typescript form
|
||||
const results = (payload as { results: (Album | AlbumTrack)[] }).results;
|
||||
|
||||
// Filter any results that are already displayed
|
||||
const items = results.filter(item => (
|
||||
!(item.Type === 'MusicAlbum' && albumIds.includes(item.Id))
|
||||
// Then convert the results to proper result form
|
||||
)).map((item) => ({
|
||||
type: item.Type,
|
||||
id: item.Id,
|
||||
album: item.Type === 'Audio'
|
||||
? item.AlbumId
|
||||
: undefined,
|
||||
name: item.Type === 'Audio'
|
||||
? item.Name
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
// Lastly, we'll merge the two and assign them to the state
|
||||
setJellyfinResults([...items] as CombinedResults);
|
||||
|
||||
// Loading is now complete
|
||||
setLoading(false);
|
||||
}, 50), [dispatch, setJellyfinResults]);
|
||||
const fuse = useMemo(
|
||||
() => new Fuse(Object.values(searchItems) as SearchItem[], fuseOptions),
|
||||
[searchItems]
|
||||
);
|
||||
|
||||
/**
|
||||
* Whenever the search term changes, we gather results from Fuse and assign
|
||||
@@ -176,24 +231,66 @@ export default function Search() {
|
||||
}
|
||||
|
||||
const retrieveResults = async () => {
|
||||
const searchTermTrimmed = searchTerm.trim();
|
||||
|
||||
// First set the immediate results from fuse
|
||||
const fuseResults = fuse.search(searchTerm);
|
||||
const albums: AlbumResult[] = fuseResults
|
||||
.map(({ item }) => ({
|
||||
const fuseResults = fuse.search(searchTermTrimmed);
|
||||
let results: SearchResult[] = fuseResults
|
||||
.map(({ item }) => ({
|
||||
id: item.Id,
|
||||
type: 'AlbumArtist',
|
||||
album: undefined,
|
||||
name: undefined,
|
||||
type: item.Type as SearchType,
|
||||
album: (item as AlbumTrack)?.Album,
|
||||
name: item.Name,
|
||||
}));
|
||||
|
||||
// Apply active filters (no filters active = all active)
|
||||
if (activeFilters.size > 0) {
|
||||
results = results.filter(result => activeFilters.has(result.type));
|
||||
}
|
||||
|
||||
// Apply local playback filter
|
||||
if (localPlaybackOnly) {
|
||||
results = results.filter(result => {
|
||||
const item = searchItems[result.id];
|
||||
if (!item) return false;
|
||||
|
||||
switch (result.type) {
|
||||
case 'Audio':
|
||||
// For tracks, check if downloaded
|
||||
return downloadEntities[result.id]?.isComplete === true;
|
||||
case 'MusicAlbum':
|
||||
// For albums, check if any tracks are downloaded
|
||||
return (item as Album).Tracks?.some(trackId =>
|
||||
downloadEntities[trackId]?.isComplete === true
|
||||
) ?? false;
|
||||
case 'MusicArtist':
|
||||
// For artists, check if any of their tracks are downloaded
|
||||
return Object.values(trackEntities)
|
||||
.filter(track => track.ArtistItems?.some(artist => artist.Id === result.id))
|
||||
.some(track => downloadEntities[track.Id]?.isComplete === true);
|
||||
case 'Playlist':
|
||||
// For playlists, check if any tracks are downloaded
|
||||
return (item as Playlist).Tracks?.some(trackId =>
|
||||
downloadEntities[trackId]?.isComplete === true
|
||||
) ?? false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Assign the preliminary results
|
||||
setFuseResults(albums);
|
||||
setFuseResults(results);
|
||||
setLoading(true);
|
||||
|
||||
// Save search query to history after 10 seconds (debounced)
|
||||
saveSearchToHistory(searchTermTrimmed, Array.from(activeFilters), localPlaybackOnly);
|
||||
|
||||
try {
|
||||
// Wrap the call in a try/catch block so that we catch any
|
||||
// network issues in search and just use local search if the
|
||||
// network is unavailable
|
||||
fetchJellyfinResults(searchTerm, albums);
|
||||
fetchJellyfinResults(searchTermTrimmed);
|
||||
} catch {
|
||||
// Reset the loading indicator if the network fails
|
||||
setLoading(false);
|
||||
@@ -201,12 +298,68 @@ export default function Search() {
|
||||
};
|
||||
|
||||
retrieveResults();
|
||||
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
|
||||
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults, albumEntities, trackEntities, artistEntities, playlistEntities, activeFilters, localPlaybackOnly, downloadEntities, searchItems, saveSearchToHistory]);
|
||||
|
||||
// Handlers
|
||||
const selectAlbum = useCallback((id: string) => {
|
||||
navigation.navigate('Album', { id, album: albums[id] as Album });
|
||||
}, [navigation, albums]);
|
||||
const toggleFilter = useCallback((filterType: SearchType) => {
|
||||
setActiveFilters(prev => {
|
||||
const newFilters = new Set(prev);
|
||||
if (newFilters.has(filterType)) {
|
||||
newFilters.delete(filterType);
|
||||
} else {
|
||||
newFilters.add(filterType);
|
||||
}
|
||||
return newFilters;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectItem = useCallback(async ({ id, type }: { id: string; type: SearchType; }) => {
|
||||
// Save search query immediately when user selects a result
|
||||
dispatch(addSearchQuery({
|
||||
query: searchTerm.trim(),
|
||||
filters: Array.from(activeFilters),
|
||||
localPlaybackOnly,
|
||||
}));
|
||||
|
||||
switch (type) {
|
||||
case 'Audio': {
|
||||
playTracks([id], { play: true });
|
||||
const similarSongs = await retrieveInstantMixByTrackId(id);
|
||||
|
||||
// Remove the first from the list, because it is the same as the currently selected song.
|
||||
similarSongs.shift();
|
||||
playTracks(similarSongs.map(item => item.Id), { play: false, method: 'add-to-end' });
|
||||
break;
|
||||
}
|
||||
case 'MusicAlbum':
|
||||
navigation.navigate('Album', { id, album: searchItems?.[id] as Album });
|
||||
break;
|
||||
case 'MusicArtist':
|
||||
{
|
||||
const { Name: name } = searchItems[id];
|
||||
navigation.navigate('Artist', { id, name });
|
||||
}
|
||||
break;
|
||||
case 'Playlist':
|
||||
navigation.navigate('Playlist', { id });
|
||||
break;
|
||||
}
|
||||
}, [navigation, searchItems, dispatch, playTracks, searchTerm, activeFilters, localPlaybackOnly]);
|
||||
|
||||
const applyHistoryItem = useCallback((query: string, filters: SearchType[], localOnly: boolean) => {
|
||||
setSearchTerm(query);
|
||||
setActiveFilters(new Set(filters));
|
||||
setLocalPlaybackOnly(localOnly);
|
||||
}, []);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
saveSearchToHistory(searchTerm.trim(), Array.from(activeFilters), localPlaybackOnly);
|
||||
setSearchTerm('');
|
||||
}, [searchTerm, activeFilters, localPlaybackOnly, saveSearchToHistory]);
|
||||
|
||||
const handleClearHistory = useCallback(() => {
|
||||
dispatch(clearSearchHistory());
|
||||
}, [dispatch]);
|
||||
|
||||
const SearchInput = React.useMemo(() => (
|
||||
<Animated.View>
|
||||
@@ -222,105 +375,192 @@ export default function Search() {
|
||||
testID="search-input"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{isLoading && <Loading style={{ marginTop: -4 }}><ActivityIndicator /></Loading>}
|
||||
{searchTerm.length > 0 && !isLoading ? (
|
||||
<ClearButton onPress={handleClearSearch} style={{ marginTop: -4 }}>
|
||||
<XMarkIcon width={16} height={16} fill={defaultStyles.textHalfOpacity.color} />
|
||||
</ClearButton>
|
||||
) : null}
|
||||
{isLoading ? <Loading style={{ marginTop: -4 }}><ActivityIndicator /></Loading> : null}
|
||||
</View>
|
||||
</Container>
|
||||
{/* <ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={{ paddingHorizontal: 32, paddingBottom: 12, flex: 1, flexDirection: 'row' }}>
|
||||
<SelectableFilter
|
||||
text="Artists"
|
||||
icon={MicrophoneIcon}
|
||||
active
|
||||
active={activeFilters.has('MusicArtist')}
|
||||
onPress={() => toggleFilter('MusicArtist')}
|
||||
/>
|
||||
<SelectableFilter
|
||||
text="Albums"
|
||||
icon={AlbumIcon}
|
||||
active={false}
|
||||
active={activeFilters.has('MusicAlbum')}
|
||||
onPress={() => toggleFilter('MusicAlbum')}
|
||||
/>
|
||||
<SelectableFilter
|
||||
text="Tracks"
|
||||
icon={TrackIcon}
|
||||
active={false}
|
||||
active={activeFilters.has('Audio')}
|
||||
onPress={() => toggleFilter('Audio')}
|
||||
/>
|
||||
<SelectableFilter
|
||||
text="Playlist"
|
||||
icon={PlaylistIcon}
|
||||
active={false}
|
||||
/>
|
||||
<SelectableFilter
|
||||
text="Streaming"
|
||||
icon={StreamIcon}
|
||||
active={false}
|
||||
active={activeFilters.has('Playlist')}
|
||||
onPress={() => toggleFilter('Playlist')}
|
||||
/>
|
||||
<SelectableFilter
|
||||
text="Local Playback"
|
||||
icon={LocalIcon}
|
||||
active={false}
|
||||
active={localPlaybackOnly}
|
||||
onPress={() => setLocalPlaybackOnly(prev => !prev)}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView> */}
|
||||
</ScrollView>
|
||||
</ColoredBlurView>
|
||||
</Animated.View>
|
||||
), [searchTerm, setSearchTerm, defaultStyles, isLoading]);
|
||||
|
||||
const searchResults = useMemo(() => ([
|
||||
...fuseResults,
|
||||
...jellyfinResults,
|
||||
]), [fuseResults, jellyfinResults]);
|
||||
), [searchTerm, setSearchTerm, defaultStyles, isLoading, activeFilters, toggleFilter, localPlaybackOnly, handleClearSearch]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, paddingTop: insets.top, marginBottom: offsets.bottom }}>
|
||||
<KeyboardAvoidingView behavior="height" style={{ flex: 1 }} keyboardVerticalOffset={KEYBOARD_OFFSET}>
|
||||
<FlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={{ flex: 2, }}
|
||||
contentContainerStyle={{ paddingTop: offsets.top, paddingBottom: SEARCH_INPUT_HEIGHT }}
|
||||
scrollIndicatorInsets={{ top: offsets.top / 2, bottom: offsets.bottom / 2 + 10 + SEARCH_INPUT_HEIGHT }}
|
||||
data={searchResults}
|
||||
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
|
||||
const album = albums[trackAlbum || id];
|
||||
{searchTerm ? (
|
||||
<FlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={{ flex: 2, }}
|
||||
contentContainerStyle={{ paddingTop: offsets.top, paddingBottom: SEARCH_INPUT_HEIGHT }}
|
||||
scrollIndicatorInsets={{ top: offsets.top / 2, bottom: offsets.bottom / 2 + 10 + SEARCH_INPUT_HEIGHT }}
|
||||
data={fuseResults}
|
||||
renderItem={({ item: { id, type, name } }: { item: SearchResult }) => {
|
||||
const searchItem = searchItems?.[id];
|
||||
|
||||
// GUARD: If the album cannot be found in the store, we
|
||||
// cannot display it.
|
||||
if (!album) {
|
||||
return null;
|
||||
}
|
||||
// GUARD: If the album cannot be found in the store, we
|
||||
// cannot display it.
|
||||
if (!searchItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableHandler<string> id={album.Id} onPress={selectAlbum} testID={`search-result-${album.Id}`}>
|
||||
<SearchResult>
|
||||
<ShadowWrapper>
|
||||
<AlbumImage source={{ uri: getImage(album) }} style={defaultStyles.imageBackground} />
|
||||
</ShadowWrapper>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text numberOfLines={1}>
|
||||
{trackName || album.Name}
|
||||
</Text>
|
||||
{(album.AlbumArtist || album.Name) && (
|
||||
return (
|
||||
<TouchableHandler<{ id: string; type: SearchType; }> id={{ id, type }} onPress={selectItem} testID={`search-result-${id}`}>
|
||||
<SearchResult>
|
||||
<ShadowWrapper>
|
||||
<SearchItemImage source={{ uri: getImage(searchItem) }} style={defaultStyles.imageBackground} />
|
||||
</ShadowWrapper>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
|
||||
{type === 'AlbumArtist'
|
||||
? `${t('album')} • ${album.AlbumArtist}`
|
||||
: `${t('track')} • ${album.AlbumArtist} — ${album.Name}`
|
||||
{ type === 'MusicAlbum' ?
|
||||
<>
|
||||
<AlbumIcon width={12} height={12} fill={defaultStyles.textHalfOpacity.color} />
|
||||
{' '}
|
||||
{t('album')}
|
||||
{' • '}
|
||||
{(searchItem as Album)?.AlbumArtist}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{ type === 'Audio' ?
|
||||
<>
|
||||
<TrackIcon width={12} height={12} fill={defaultStyles.textHalfOpacity.color} />
|
||||
{' '}
|
||||
{t('track')}
|
||||
{' • '}
|
||||
{(searchItem as AlbumTrack)?.AlbumArtist}
|
||||
{' — '}
|
||||
{searchItem?.Name}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{ type === 'MusicArtist' ?
|
||||
<>
|
||||
<MicrophoneIcon width={12} height={12} fill={defaultStyles.textHalfOpacity.color} />
|
||||
{' '}
|
||||
{t('artist')}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{ type === 'Playlist' ?
|
||||
<>
|
||||
<PlaylistIcon width={12} height={12} fill={defaultStyles.textHalfOpacity.color} />
|
||||
{' '}
|
||||
{t('playlist')}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
</HalfOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ marginLeft: 16 }}>
|
||||
<DownloadIcon trackId={id} />
|
||||
</View>
|
||||
<View style={{ marginLeft: 16 }}>
|
||||
<ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} />
|
||||
</View>
|
||||
</SearchResult>
|
||||
</View>
|
||||
{ type === 'Audio' ?
|
||||
<View style={{ marginLeft: 16 }}>
|
||||
<DownloadIcon trackId={id} />
|
||||
</View>
|
||||
: null
|
||||
}
|
||||
<View style={{ marginLeft: 16 }}>
|
||||
<ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} />
|
||||
</View>
|
||||
</SearchResult>
|
||||
</TouchableHandler>
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item) => item.id}
|
||||
extraData={[searchTerm, searchItems, activeFilters]}
|
||||
/>
|
||||
) : searchHistory.length > 0 ? (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingTop: offsets.top, paddingBottom: SEARCH_INPUT_HEIGHT }}
|
||||
>
|
||||
<View style={{ paddingTop: 20, paddingBottom: 10, paddingHorizontal: 32 }}>
|
||||
<Text style={{ ...defaultStyles.text, fontSize: 18, letterSpacing: -0.25 }}>
|
||||
{t('recent-searches')}
|
||||
</Text>
|
||||
</View>
|
||||
{searchHistory.map((item, index) => (
|
||||
<TouchableHandler
|
||||
key={index}
|
||||
id={item}
|
||||
onPress={() => applyHistoryItem(item.query, item.filters, item.localPlaybackOnly)}
|
||||
>
|
||||
<HistoryItem>
|
||||
<HistoryIconWrapper>
|
||||
<SearchIcon
|
||||
width={18}
|
||||
height={18}
|
||||
fill={defaultStyles.textHalfOpacity.color}
|
||||
/>
|
||||
</HistoryIconWrapper>
|
||||
<HistoryTextContainer>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16 }}>
|
||||
{item.query}
|
||||
</Text>
|
||||
{(item.filters.length > 0 || item.localPlaybackOnly) ? (
|
||||
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
|
||||
{item.filters.length > 0 ? item.filters.map(f => {
|
||||
switch (f) {
|
||||
case 'MusicArtist': return 'Artists';
|
||||
case 'MusicAlbum': return 'Albums';
|
||||
case 'Audio': return 'Tracks';
|
||||
case 'Playlist': return 'Playlist';
|
||||
}
|
||||
}).join(', ') : null}
|
||||
{item.filters.length > 0 ? item.localPlaybackOnly ? ' • ' : null : null}
|
||||
{item.localPlaybackOnly ? 'Local Playback' : null}
|
||||
</HalfOpacity>
|
||||
) : null}
|
||||
</HistoryTextContainer>
|
||||
</HistoryItem>
|
||||
</TouchableHandler>
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item) => item.id}
|
||||
extraData={[searchTerm, albums]}
|
||||
/>
|
||||
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
|
||||
))}
|
||||
<View style={{ paddingHorizontal: 32, paddingTop: 12 }}>
|
||||
<Button title={t('clear-history')} icon={TrashIcon} onPress={handleClearHistory} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{(searchTerm.length && !fuseResults.length && !isLoading) ? (
|
||||
<FullSizeContainer>
|
||||
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
|
||||
</FullSizeContainer>
|
||||
@@ -329,4 +569,4 @@ export default function Search() {
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { SectionArtistItem } from '@/store/music/selectors';
|
||||
import { Album } from '@/store/music/types';
|
||||
|
||||
export type StackParams = {
|
||||
@@ -7,9 +6,9 @@ export type StackParams = {
|
||||
Albums: undefined;
|
||||
Album: { id: string, album: Album };
|
||||
Artists: undefined;
|
||||
Artist: SectionArtistItem;
|
||||
Artist: { id: string; name: string };
|
||||
Playlists: undefined;
|
||||
Playlist: { id: string };
|
||||
Playlist: { id: string; };
|
||||
RecentAlbums: undefined;
|
||||
Search: undefined;
|
||||
SetJellyfinServer: undefined;
|
||||
|
||||
@@ -37,7 +37,7 @@ export const downloadTrack = createAsyncThunk(
|
||||
// Actually kick off the download
|
||||
const { promise: audioPromise } = downloadFile({
|
||||
fromUrl: audioUrl,
|
||||
progressInterval: 250,
|
||||
progressInterval: 1000,
|
||||
background: true,
|
||||
begin: ({ jobId, contentLength }) => {
|
||||
// Dispatch the initialization
|
||||
|
||||
@@ -7,6 +7,7 @@ import settings from './settings';
|
||||
import music, { initialState as musicInitialState } from './music';
|
||||
import downloads, { initialState as downloadsInitialState } from './downloads';
|
||||
import sleepTimer from './sleep-timer';
|
||||
import search from './search';
|
||||
import { ColorScheme } from './settings/types';
|
||||
import MigratedStorage from '@/utility/MigratedStorage';
|
||||
|
||||
@@ -89,6 +90,7 @@ const reducers = combineReducers({
|
||||
music: music.reducer,
|
||||
downloads: downloads.reducer,
|
||||
sleepTimer: sleepTimer.reducer,
|
||||
search: search.reducer,
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, reducers);
|
||||
|
||||
+26
-34
@@ -1,12 +1,14 @@
|
||||
import { AsyncThunkPayloadCreator, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { Album, AlbumTrack, CodecMetadata, Lyrics, Playlist } from './types';
|
||||
import { Album, AlbumTrack, CodecMetadata, Lyrics, Playlist, MusicArtist } from './types';
|
||||
import type { AsyncThunkAPI } from '..';
|
||||
import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album';
|
||||
import { retrieveAllPlaylists, retrievePlaylistTracks } from '@/utility/JellyfinApi/playlist';
|
||||
import { searchItem } from '@/utility/JellyfinApi/search';
|
||||
import { retrieveAllPlaylists, retrievePlaylistTracks, retrieveInstantMixByTrackId } from '@/utility/JellyfinApi/playlist';
|
||||
import { retrieveAllArtists } from '@/utility/JellyfinApi/artist';
|
||||
import { searchItem, SearchResult } from '@/utility/JellyfinApi/search';
|
||||
import { retrieveTrackLyrics } from '@/utility/JellyfinApi/lyrics';
|
||||
import { retrieveTrackCodecMetadata } from '@/utility/JellyfinApi/track';
|
||||
|
||||
|
||||
export const albumAdapter = createEntityAdapter<Album, string>({
|
||||
selectId: album => album.Id,
|
||||
sortComparer: (a, b) => a.Name.localeCompare(b.Name),
|
||||
@@ -95,37 +97,9 @@ export const fetchSimilarAlbums = createAsyncThunk<Album[], string, AsyncThunkAP
|
||||
retrieveSimilarAlbums,
|
||||
);
|
||||
|
||||
type SearchAndFetchResults = {
|
||||
albums: Album[];
|
||||
results: (Album | AlbumTrack)[];
|
||||
};
|
||||
|
||||
export const searchAndFetchAlbums = createAsyncThunk<
|
||||
SearchAndFetchResults,
|
||||
{ term: string, limit?: number },
|
||||
AsyncThunkAPI
|
||||
>(
|
||||
export const searchAndFetch = createAsyncThunk<SearchResult[], { term: string, limit?: number }, AsyncThunkAPI>(
|
||||
'/search',
|
||||
async ({ term, limit = 24 }, thunkAPI) => {
|
||||
const state = thunkAPI.getState();
|
||||
const results = await searchItem(term, limit);
|
||||
|
||||
const albums = await Promise.all(results.filter((item) => (
|
||||
!state.music.albums.ids.includes(item.Type === 'MusicAlbum' ? item.Id : item.AlbumId)
|
||||
&& (item.Type === 'Audio' ? item.AlbumId : true)
|
||||
)).map(async (item) => {
|
||||
if (item.Type === 'MusicAlbum') {
|
||||
return item;
|
||||
}
|
||||
|
||||
return retrieveAlbum(item.AlbumId);
|
||||
}));
|
||||
|
||||
return {
|
||||
albums,
|
||||
results
|
||||
};
|
||||
}
|
||||
async ({ term, limit = 24 }) => searchItem(term, limit)
|
||||
);
|
||||
|
||||
export const playlistAdapter = createEntityAdapter<Playlist, string>({
|
||||
@@ -146,5 +120,23 @@ export const fetchAllPlaylists = createAsyncThunk<Playlist[], undefined, AsyncTh
|
||||
*/
|
||||
export const fetchTracksByPlaylist = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
|
||||
'/tracks/byPlaylist',
|
||||
postProcessTracks(retrievePlaylistTracks),
|
||||
postProcessTracks(retrievePlaylistTracks)
|
||||
);
|
||||
|
||||
export const artistAdapter = createEntityAdapter<MusicArtist, string>({
|
||||
selectId: artist => artist.Id,
|
||||
sortComparer: (a, b) => a.Name.localeCompare(b.Name)
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all albums available on the jellyfin server
|
||||
*/
|
||||
export const fetchAllArtists = createAsyncThunk<MusicArtist[], undefined, AsyncThunkAPI>(
|
||||
'/artists/all',
|
||||
retrieveAllArtists
|
||||
);
|
||||
|
||||
export const fetchInstantMixByTrackId = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
|
||||
'/instantMix/byTrackId',
|
||||
(trackId: string) => retrieveInstantMixByTrackId(trackId)
|
||||
);
|
||||
|
||||
@@ -4,17 +4,21 @@ import {
|
||||
fetchTracksByAlbum,
|
||||
trackAdapter,
|
||||
fetchRecentAlbums,
|
||||
searchAndFetchAlbums,
|
||||
searchAndFetch,
|
||||
playlistAdapter,
|
||||
fetchAllPlaylists,
|
||||
fetchTracksByPlaylist,
|
||||
fetchAlbum,
|
||||
artistAdapter,
|
||||
fetchAllArtists,
|
||||
fetchInstantMixByTrackId,
|
||||
fetchSimilarAlbums,
|
||||
fetchCodecMetadataByTrack,
|
||||
fetchLyricsByTrack
|
||||
} from './actions';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { Album, AlbumTrack, Playlist } from './types';
|
||||
import { Album, AlbumTrack, Playlist, MusicArtist } from './types';
|
||||
|
||||
import { setJellyfinCredentials } from '@/store/settings/actions';
|
||||
|
||||
export interface State {
|
||||
@@ -36,6 +40,12 @@ export interface State {
|
||||
entities: Record<string, Playlist>;
|
||||
ids: string[];
|
||||
lastRefreshed?: number,
|
||||
},
|
||||
artists: {
|
||||
isLoading: boolean;
|
||||
entities: Record<string, MusicArtist>;
|
||||
ids: string[];
|
||||
lastRefreshed?: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +63,10 @@ export const initialState: State = {
|
||||
playlists: {
|
||||
...playlistAdapter.getInitialState(),
|
||||
isLoading: false,
|
||||
},
|
||||
artists: {
|
||||
...artistAdapter.getInitialState(),
|
||||
isLoading: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,10 +136,36 @@ const music = createSlice({
|
||||
builder.addCase(fetchTracksByAlbum.pending, (state) => { state.tracks.isLoading = true; });
|
||||
builder.addCase(fetchTracksByAlbum.rejected, (state) => { state.tracks.isLoading = false; });
|
||||
|
||||
builder.addCase(searchAndFetchAlbums.pending, (state) => { state.albums.isLoading = true; });
|
||||
builder.addCase(searchAndFetchAlbums.fulfilled, (state, { payload }) => {
|
||||
albumAdapter.upsertMany(state.albums, payload.albums);
|
||||
builder.addCase(searchAndFetch.pending, (state) => {
|
||||
state.albums.isLoading = true;
|
||||
state.artists.isLoading = true;
|
||||
state.playlists.isLoading = true;
|
||||
state.tracks.isLoading = true;
|
||||
});
|
||||
builder.addCase(searchAndFetch.fulfilled, (state, { payload }) => {
|
||||
const albums = payload.filter(item =>
|
||||
item.Type === 'MusicAlbum' && !albumAdapter.getSelectors().selectById(state.albums, item.Id)
|
||||
) as Album[];
|
||||
albumAdapter.upsertMany(state.albums, albums);
|
||||
state.albums.isLoading = false;
|
||||
|
||||
const artists = payload.filter(item =>
|
||||
item.Type === 'MusicArtist' && !artistAdapter.getSelectors().selectById(state.artists, item.Id)
|
||||
) as MusicArtist[];
|
||||
artistAdapter.upsertMany(state.artists, artists);
|
||||
state.artists.isLoading = false;
|
||||
|
||||
const tracks = payload.filter(item =>
|
||||
item.Type === 'Audio' && !trackAdapter.getSelectors().selectById(state.tracks, item.Id)
|
||||
) as AlbumTrack[];
|
||||
trackAdapter.upsertMany(state.tracks, tracks);
|
||||
state.tracks.isLoading = false;
|
||||
|
||||
const playlists = payload.filter(item =>
|
||||
item.Type === 'Playlist' && !playlistAdapter.getSelectors().selectById(state.playlists, item.Id)
|
||||
) as Playlist[];
|
||||
playlistAdapter.upsertMany(state.playlists, playlists);
|
||||
state.playlists.isLoading = false;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -162,10 +202,32 @@ const music = createSlice({
|
||||
builder.addCase(fetchTracksByPlaylist.pending, (state) => { state.tracks.isLoading = true; });
|
||||
builder.addCase(fetchTracksByPlaylist.rejected, (state) => { state.tracks.isLoading = false; });
|
||||
|
||||
builder.addCase(fetchInstantMixByTrackId.pending, state => { state.tracks.isLoading = true; });
|
||||
builder.addCase(fetchInstantMixByTrackId.rejected, state => { state.tracks.isLoading = false; });
|
||||
builder.addCase(fetchInstantMixByTrackId.fulfilled, (state, { payload }) => {
|
||||
const tracks = payload.filter(item =>
|
||||
!trackAdapter.getSelectors().selectById(state.tracks, item.Id)
|
||||
) as AlbumTrack[];
|
||||
trackAdapter.upsertMany(state.tracks, tracks);
|
||||
|
||||
state.tracks.isLoading = false;
|
||||
});
|
||||
|
||||
// Reset any caches we have when a new server is set
|
||||
builder.addCase(setJellyfinCredentials, () => initialState);
|
||||
|
||||
/**
|
||||
* Fetch All artists
|
||||
*/
|
||||
builder.addCase(fetchAllArtists.fulfilled, (state, { payload }) => {
|
||||
artistAdapter.setAll(state.artists, payload);
|
||||
state.artists.isLoading = false;
|
||||
state.artists.lastRefreshed = new Date().getTime();
|
||||
});
|
||||
builder.addCase(fetchAllArtists.pending, (state) => { state.artists.isLoading = true; });
|
||||
builder.addCase(fetchAllArtists.rejected, (state) => { state.artists.isLoading = false; });
|
||||
|
||||
/**
|
||||
* Fetch track metadata
|
||||
*/
|
||||
builder.addCase(fetchCodecMetadataByTrack.fulfilled, (state, { payload, meta }) => {
|
||||
@@ -177,4 +239,4 @@ const music = createSlice({
|
||||
}
|
||||
});
|
||||
|
||||
export default music;
|
||||
export default music;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parseISO } from 'date-fns';
|
||||
import { ALPHABET_LETTERS } from '@/CONSTANTS';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { SectionListData } from 'react-native';
|
||||
import { ArtistItem } from './types';
|
||||
import { MusicArtist } from './types';
|
||||
|
||||
/**
|
||||
* Retrieves a list of the n most recent albums
|
||||
@@ -92,46 +92,16 @@ export const selectAlbumsByAlphabet = createSelector(
|
||||
splitAlbumsByAlphabet,
|
||||
);
|
||||
|
||||
export type SectionArtistItem = ArtistItem & { albumIds: string[] };
|
||||
export type SectionArtistItem = MusicArtist;
|
||||
|
||||
/**
|
||||
* Retrieve all artists based on the available albums
|
||||
*/
|
||||
export function artistsFromAlbums(state: AppState['music']['albums']) {
|
||||
// Loop through all albums to retrieve the AlbumArtists
|
||||
const artists = state.ids.reduce((sum, id) => {
|
||||
// Retrieve the album from the state
|
||||
const album = state.entities[id];
|
||||
export type SectionedArtist = SectionListData<MusicArtist>;
|
||||
|
||||
// Then, loop through all artists
|
||||
album?.ArtistItems.forEach((artist) => {
|
||||
// GUARD: Check that an array already exists for this artist
|
||||
if (!(artist.Name in sum)) {
|
||||
sum[artist.Name] = { albumIds: [] as string[], ...artist };
|
||||
}
|
||||
|
||||
// Add the album id to the artist in the object
|
||||
sum[artist.Name].albumIds.push(id);
|
||||
}, []);
|
||||
|
||||
return sum;
|
||||
}, {} as Record<string, SectionArtistItem>);
|
||||
|
||||
// Now, alphabetically order the object by artist names
|
||||
const sortedArtists = Object.entries(artists)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
return sortedArtists;
|
||||
}
|
||||
|
||||
export type SectionedArtist = SectionListData<SectionArtistItem>;
|
||||
|
||||
function splitArtistsByAlphabet(state: AppState['music']['albums']) {
|
||||
const artists = artistsFromAlbums(state);
|
||||
function splitArtistsByAlphabet(state: AppState['music']['artists']) {
|
||||
const artists = Object.values(state.entities);
|
||||
const sections: SectionedArtist[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] }));
|
||||
|
||||
artists.forEach((artist) => {
|
||||
const letter = artist[0].toUpperCase().charAt(0);
|
||||
const letter = artist.Name.toUpperCase().charAt(0);
|
||||
const index = letter ? ALPHABET_LETTERS.indexOf(letter) : 26;
|
||||
|
||||
// Then find the current row in this section (note that albums are
|
||||
@@ -139,7 +109,7 @@ function splitArtistsByAlphabet(state: AppState['music']['albums']) {
|
||||
const section = sections[index >= 0 ? index : 26];
|
||||
|
||||
// Add the album to the row
|
||||
(section.data as unknown as SectionArtistItem[]).push(artist[1]);
|
||||
(section.data as unknown as MusicArtist[]).push(artist);
|
||||
});
|
||||
|
||||
return sections;
|
||||
@@ -149,6 +119,6 @@ function splitArtistsByAlphabet(state: AppState['music']['albums']) {
|
||||
* Wrap splitByAlphabet into a memoized selector
|
||||
*/
|
||||
export const selectArtists = createSelector(
|
||||
(state: AppState) => state.music.albums,
|
||||
(state: AppState) => state.music.artists,
|
||||
splitArtistsByAlphabet,
|
||||
);
|
||||
|
||||
@@ -39,6 +39,22 @@ export interface AlbumArtist {
|
||||
Id: string;
|
||||
}
|
||||
|
||||
export interface MusicArtist {
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Id: string;
|
||||
ChannelId: string;
|
||||
RunTimeTicks: number;
|
||||
IsFolder: boolean;
|
||||
UserData: UserData;
|
||||
Type: 'MusicArtist';
|
||||
ImageTags: ImageTags;
|
||||
BackdropImageTags: any[];
|
||||
ImageBlurHashes: any;
|
||||
LocationType: string;
|
||||
Overview: string;
|
||||
}
|
||||
|
||||
export interface ImageTags {
|
||||
Primary: string;
|
||||
}
|
||||
@@ -143,7 +159,7 @@ export interface Playlist {
|
||||
ChannelId?: any;
|
||||
RunTimeTicks: number;
|
||||
IsFolder: boolean;
|
||||
Type: string;
|
||||
Type: 'Playlist';
|
||||
UserData: UserData;
|
||||
PrimaryImageAspectRatio: number;
|
||||
ImageTags: ImageTags;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
type SearchType = 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist';
|
||||
|
||||
export interface SearchQuery {
|
||||
query: string;
|
||||
filters: SearchType[];
|
||||
localPlaybackOnly: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
queryHistory: SearchQuery[];
|
||||
}
|
||||
|
||||
export const initialState: State = {
|
||||
queryHistory: [],
|
||||
};
|
||||
|
||||
const search = createSlice({
|
||||
name: 'search',
|
||||
initialState,
|
||||
reducers: {
|
||||
addSearchQuery(state, action: PayloadAction<Omit<SearchQuery, 'timestamp'>>) {
|
||||
const newQuery: SearchQuery = {
|
||||
...action.payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Remove duplicate queries (same query and filters)
|
||||
state.queryHistory = state.queryHistory.filter(
|
||||
item => !(item.query === newQuery.query &&
|
||||
JSON.stringify(item.filters.sort()) === JSON.stringify(newQuery.filters.sort()) &&
|
||||
item.localPlaybackOnly === newQuery.localPlaybackOnly)
|
||||
);
|
||||
|
||||
// Add new query to the beginning
|
||||
state.queryHistory.unshift(newQuery);
|
||||
|
||||
// Keep only the last 10 queries
|
||||
if (state.queryHistory.length > 10) {
|
||||
state.queryHistory = state.queryHistory.slice(0, 10);
|
||||
}
|
||||
},
|
||||
clearSearchHistory(state) {
|
||||
state.queryHistory = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addSearchQuery, clearSearchHistory } = search.actions;
|
||||
|
||||
export default search;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MusicArtist } from '@/store/music/types';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
const artistOptions = {
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Recursive: 'true',
|
||||
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,Overview',
|
||||
ImageTypeLimit: '1',
|
||||
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
|
||||
};
|
||||
|
||||
const artistParams = new URLSearchParams(artistOptions).toString();
|
||||
|
||||
/**
|
||||
* Retrieve all artists that are available on the Jellyfin server
|
||||
*/
|
||||
export function retrieveAllArtists() {
|
||||
return fetchApi<{ Items: MusicArtist[] }>(() => `/Artists/AlbumArtists?${artistParams}`)
|
||||
.then(response => response.Items);
|
||||
}
|
||||
@@ -35,3 +35,13 @@ export async function retrievePlaylistTracks(ItemId: string) {
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`)
|
||||
.then((d) => d.Items);
|
||||
}
|
||||
|
||||
export async function retrieveInstantMixByTrackId(trackId: string, limit = 100) {
|
||||
const credentials = asyncFetchStore().getState().settings.credentials;
|
||||
const instantMixOptions = {
|
||||
UserId: credentials?.user_id ?? '',
|
||||
Limit: limit.toString()
|
||||
};
|
||||
const instantMixParams = new URLSearchParams(instantMixOptions).toString();
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(`/Items/${trackId}/InstantMix?${instantMixParams}`).then(response => response.Items);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Album, AlbumTrack } from '@/store/music/types';
|
||||
import { Album, AlbumTrack, MusicArtist, Playlist } from '@/store/music/types';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
const searchParams = {
|
||||
IncludeItemTypes: 'Audio,MusicAlbum',
|
||||
IncludeItemTypes: 'Audio,MusicAlbum,Playlist',
|
||||
SortBy: 'SearchScore,Album,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Recursive: 'true',
|
||||
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,Overview',
|
||||
ImageTypeLimit: '1',
|
||||
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb'
|
||||
};
|
||||
|
||||
export type SearchResult = Album | AlbumTrack | MusicArtist | Playlist;
|
||||
|
||||
/**
|
||||
* Remotely search the Jellyfin library for a particular search term
|
||||
*/
|
||||
export async function searchItem(
|
||||
export function searchItem(
|
||||
term: string, limit = 24
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
@@ -20,11 +25,6 @@ export async function searchItem(
|
||||
Limit: limit.toString(),
|
||||
}).toString();
|
||||
|
||||
const results = await fetchApi<{ Items: (Album | AlbumTrack)[]}>(({ user_id }) => `/Users/${user_id}/Items?${params}`);
|
||||
|
||||
return results!.Items
|
||||
.filter((item) => (
|
||||
// GUARD: Ensure that we're either dealing with an album or a track from an album.
|
||||
item.Type === 'MusicAlbum' || (item.Type === 'Audio' && item.AlbumId)
|
||||
));
|
||||
}
|
||||
return fetchApi<{ Items: SearchResult[]}>(({ user_id }) => `/Users/${user_id}/Items?${params}`)
|
||||
.then(result => result.Items);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user