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:
Nikolay Babanov
2026-01-24 12:42:28 +01:00
committed by GitHub
co-authored by Nikolay Babanov Lei Nelissen
parent 315b8836d4
commit 3d0f7ff9ef
48 changed files with 830 additions and 287 deletions
+1 -1
View File
@@ -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

+55
View File
@@ -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;
+6
View File
@@ -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')
+14 -11
View File
@@ -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;
+3
View File
@@ -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": "Желаете ли да пуснете докладването на грешки?",
+3
View File
@@ -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!",
+3
View File
@@ -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?",
+3
View File
@@ -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",
+3
View File
@@ -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.",
+3
View File
@@ -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",
+3
View File
@@ -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.",
+3
View File
@@ -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?",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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!",
+3
View File
@@ -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": "ストリーミング",
+3
View File
@@ -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.",
+3
View File
@@ -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",
+3
View File
@@ -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ń",
+3
View File
@@ -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",
+3
View File
@@ -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?",
+3
View File
@@ -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": "Вы уверены, что хотите удалить все загруженные в настоящее время треки?",
+3
View File
@@ -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",
+3
View File
@@ -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!",
+3
View File
@@ -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": "ரத்துசெய்",
+3
View File
@@ -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": "Ви впевнені, що ви хочете видалити всі завантажені треки?",
+3
View File
@@ -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": "刪除",
+3
View File
@@ -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'
+2 -2
View File
@@ -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;
+3 -1
View File
@@ -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 ? (
<>
+46 -16
View File
@@ -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;
}
+8 -8
View File
@@ -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;
+6 -3
View File
@@ -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;
+6 -2
View File
@@ -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;
+388 -148
View File
@@ -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>
);
}
}
+2 -3
View File
@@ -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;
+1 -1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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)
);
+68 -6
View File
@@ -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;
+8 -38
View File
@@ -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,
);
+17 -1
View File
@@ -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;
+53
View File
@@ -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;
+21
View File
@@ -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);
}
+10
View File
@@ -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);
}
+11 -11
View File
@@ -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);
}