Migrate remaining files from Redux to database

- Created downloads hooks (useDownloads, useDownload, useIsDownloaded)
- Created download queue manager with database operations
- Updated Downloads screen to use database hooks
- Updated DownloadIcon component to use database
- Updated DownloadManager to use database
- Updated usePlayTracks to use database hooks
- Updated useCurrentTrack to use database hooks
- Updated TrackPopupMenu to use database hooks
- Updated Search screen to use database hooks and fetchers
- Made playTracks backward compatible with CarPlay templates

Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-08 15:26:50 +00:00
co-authored by leinelissen
parent 17fd5aa9f1
commit 1ff5b1b341
12 changed files with 340 additions and 111 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useTypedSelector } from '@/store';
import { useDownload } from '@/store/downloads/hooks';
import CloudIcon from '@/assets/icons/cloud.svg';
import CloudDownArrow from '@/assets/icons/cloud-down-arrow.svg';
import CloudExclamationMarkIcon from '@/assets/icons/cloud-exclamation-mark.svg';
@@ -32,9 +32,9 @@ function DownloadIcon({ trackId, size = 16, fill, style }: DownloadIconProps) {
const defaultStyles = useDefaultStyles();
const iconFill = fill || defaultStyles.textQuarterOpacity.color;
// Get download icon from state
const entity = useTypedSelector((state) => state.downloads.entities[trackId]);
const isQueued = useTypedSelector((state) => state.downloads.queued.includes(trackId));
// Get download icon from database
const { entity } = useDownload(trackId);
const isQueued = entity && !entity.isComplete && !entity.isFailed;
// Memoize calculations for radius and circumference of the circle
const radius = useMemo(() => size / 2, [size]);
+15 -20
View File
@@ -1,8 +1,10 @@
import { xor } from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { DocumentDirectoryPath, readDir } from 'react-native-fs';
import { useAppDispatch, useTypedSelector } from '@/store';
import { completeDownload, downloadTrack } from '@/store/downloads/actions';
import { useDownloads } from '@/store/downloads/hooks';
import { useSourceId } from '@/store/db/useSourceId';
import { downloadTrack } from '@/store/downloads/queue';
import { completeDownload } from '@/store/downloads/db';
import { getMimeTypeForExtension } from '@/utility/mimeType';
/**
@@ -17,9 +19,8 @@ const MAX_CONCURRENT_DOWNLOADS = 5;
*/
function DownloadManager () {
// Retrieve store helpers
const { queued, ids, entities } = useTypedSelector((state) => state.downloads);
const rehydrated = useTypedSelector((state) => state._persist.rehydrated);
const dispatch = useAppDispatch();
const sourceId = useSourceId();
const { queued, ids, entities } = useDownloads(sourceId);
// Keep state for the currently active downloads (i.e. the downloads that
// have actually been pushed out to react-native-fs).
@@ -42,7 +43,7 @@ function DownloadManager () {
queue.filter((id) => !activeDownloads.current.has(id))
.forEach((id) => {
// We dispatch the actual call to start downloading
dispatch(downloadTrack(id));
downloadTrack(id);
// And add it to the active downloads
activeDownloads.current.add(id);
});
@@ -52,7 +53,7 @@ function DownloadManager () {
xor(Array.from(activeDownloads.current), queue)
.forEach((id) => activeDownloads.current.delete(id));
}, [queued, dispatch, activeDownloads]);
}, [queued, activeDownloads]);
useEffect(() => {
// GUARD: We only run this function once
@@ -60,16 +61,15 @@ function DownloadManager () {
return;
}
// GUARD: If the state has not been rehydrated, we cannot check against
// the store ids.
if (!rehydrated) {
// GUARD: Need a source ID to hydrate orphans
if (!sourceId) {
return;
}
/**
* Whenever the store is cleared, existing downloads get "lost" because
* the only reference we have is the store. This function checks for
* those lost downloads and adds them to the store
* those lost downloads and adds them to the database
*/
async function hydrateOrphanedDownloads() {
// Retrieve all files for this app
@@ -82,7 +82,7 @@ function DownloadManager () {
const mimeType = getMimeTypeForExtension(extension);
// GUARD: Only process audio mime types
if (!mimeType || mimeType.startsWith('audio')) {
if (!mimeType || !mimeType.startsWith('audio')) {
return;
}
@@ -92,19 +92,14 @@ function DownloadManager () {
return;
}
// Add the download to the store
dispatch(completeDownload({
id,
location: file.path,
size: file.size,
}));
// Add the download to the database
completeDownload(id, file.path);
});
}
hydrateOrphanedDownloads();
setHasRehydratedOrphans(true);
}, [rehydrated, ids, hasRehydratedOrphans, dispatch, entities]);
}, [sourceId, ids, hasRehydratedOrphans, entities]);
return null;
}
+13 -11
View File
@@ -2,11 +2,13 @@ import useDefaultStyles from '@/components/Colors';
import React, { useCallback, useMemo } from 'react';
import { Alert, FlatListProps, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppDispatch, useTypedSelector } from '@/store';
import formatBytes from '@/utility/formatBytes';
import TrashIcon from '@/assets/icons/trash.svg';
import ArrowClockwise from '@/assets/icons/arrow-clockwise.svg';
import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/actions';
import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/queue';
import { useDownloads } from '@/store/downloads/hooks';
import { useTracks } from '@/store/music/hooks';
import { useSourceId } from '@/store/db/useSourceId';
import Button from '@/components/Button';
import DownloadIcon from '@/components/DownloadIcon';
import styled from 'styled-components/native';
@@ -37,11 +39,11 @@ const ErrorWrapper = styled.View`
function Downloads() {
const defaultStyles = useDefaultStyles();
const dispatch = useAppDispatch();
const getImage = useGetImage();
const sourceId = useSourceId();
const { entities, ids } = useTypedSelector((state) => state.downloads);
const tracks = useTypedSelector((state) => state.music.tracks.entities);
const { entities, ids } = useDownloads(sourceId);
const { tracks } = useTracks(sourceId);
// Calculate the total download size
const totalDownloadSize = useMemo(() => (
@@ -53,9 +55,9 @@ function Downloads() {
*/
// Delete a single downloaded track
const handleDelete = useCallback((id: string) => {
dispatch(removeDownloadedTrack(id));
}, [dispatch]);
const handleDelete = useCallback(async (id: string) => {
await removeDownloadedTrack(id);
}, []);
// Delete all downloaded tracks
const handleDeleteAllTracks = useCallback(() => {
@@ -76,9 +78,9 @@ function Downloads() {
}, [handleDelete, ids]);
// Retry a single failed track
const retryTrack = useCallback((id: string) => {
dispatch(queueTrackForDownload(id));
}, [dispatch]);
const retryTrack = useCallback(async (id: string) => {
await queueTrackForDownload(id);
}, []);
// Retry all failed tracks
const failedIds = useMemo(() => ids.filter((id) => !entities[id]?.isComplete), [ids, entities]);
+42 -35
View File
@@ -4,10 +4,17 @@ import Input from '@/components/Input';
import { ActivityIndicator, Animated, KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native';
import styled from 'styled-components/native';
import { AppState, useAppDispatch, useTypedSelector } from '@/store';
import Fuse, { IFuseOptions } from 'fuse.js';
import { Album, AlbumTrack, MusicArtist, Playlist } from '@/store/music/types';
import { addSearchQuery, clearSearchHistory } from '@/store/search';
import { useAlbums, useTracks, useArtists, usePlaylists } from '@/store/music/hooks';
import { useDownloads } from '@/store/downloads/hooks';
import { useSourceId } from '@/store/db/useSourceId';
import { searchAndStore } from '@/store/music/fetchers';
import { addSearchQuery, clearSearchHistory, parseSearchQueries } from '@/store/search/db';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { searchQueries } from '@/store/db/schema/search-queries';
import { eq, desc } from 'drizzle-orm';
import { FlatList } from 'react-native-gesture-handler';
import TouchableHandler from '@/components/TouchableHandler';
@@ -15,7 +22,6 @@ import { useNavigation } from '@react-navigation/native';
import { useGetImage } from '@/utility/JellyfinApi/lib';
import { t } from '@/localisation';
import useDefaultStyles from '@/components/Colors';
import { searchAndFetch } from '@/store/music/actions';
import { SubHeader, Text } from '@/components/Typography';
import DownloadIcon from '@/components/DownloadIcon';
import ChevronRight from '@/assets/icons/chevron-right.svg';
@@ -40,7 +46,7 @@ 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,
android: parseInt(Platform.Version as string, 10) >= 35 ? 0 : 72,
});
const SEARCH_INPUT_HEIGHT = 104;
@@ -149,17 +155,11 @@ interface SearchResult {
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();
const sourceId = useSourceId();
// Prepare state for fuse and albums
const [searchTerm, setSearchTerm] = useState('');
@@ -168,17 +168,26 @@ export default function Search() {
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);
const { albums: albumEntities } = useAlbums(sourceId);
const { tracks: trackEntities } = useTracks(sourceId);
const { artists: artistEntities } = useArtists(sourceId);
const { playlists: playlistEntities } = usePlaylists(sourceId);
const { entities: downloadEntities } = useDownloads(sourceId);
// Use live query for search history
const { data: searchHistoryData } = useLiveQuery(
sourceId
? db.select().from(searchQueries).where(eq(searchQueries.sourceId, sourceId)).orderBy(desc(searchQueries.timestamp)).limit(10)
: null
);
const searchHistory = useMemo(() => {
return searchHistoryData ? parseSearchQueries(searchHistoryData as any) : [];
}, [searchHistoryData]);
// Prepare helpers
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
const dispatch = useAppDispatch();
/**
* This function retrieves search results from Jellyfin. It is a seperate
@@ -187,11 +196,11 @@ export default function Search() {
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string) => {
await dispatch(searchAndFetch({ term: searchTerm }));
await searchAndStore(searchTerm);
// Loading is now complete
setLoading(false);
}, 150), [dispatch]);
}, 150), []);
/**
* Debounced function to save search query to history after 10 seconds
@@ -199,12 +208,10 @@ export default function Search() {
*/
// 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]);
if (sourceId) {
addSearchQuery(sourceId, query, filters, localOnly);
}
}, 10_000), [sourceId]);
const searchItems = useMemo(() => ({
@@ -320,11 +327,9 @@ export default function Search() {
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,
}));
if (sourceId) {
await addSearchQuery(sourceId, searchTerm.trim(), Array.from(activeFilters), localPlaybackOnly);
}
switch (type) {
case 'Audio': {
@@ -349,7 +354,7 @@ export default function Search() {
navigation.navigate('Playlist', { id });
break;
}
}, [navigation, searchItems, dispatch, playTracks, searchTerm, activeFilters, localPlaybackOnly]);
}, [navigation, searchItems, playTracks, searchTerm, activeFilters, localPlaybackOnly, sourceId]);
const applyHistoryItem = useCallback((query: string, filters: SearchType[], localOnly: boolean) => {
setSearchTerm(query);
@@ -362,9 +367,11 @@ export default function Search() {
setSearchTerm('');
}, [searchTerm, activeFilters, localPlaybackOnly, saveSearchToHistory]);
const handleClearHistory = useCallback(() => {
dispatch(clearSearchHistory());
}, [dispatch]);
const handleClearHistory = useCallback(async () => {
if (sourceId) {
await clearSearchHistory(sourceId);
}
}, [sourceId]);
const SearchInput = React.useMemo(() => (
<Animated.View style={{ paddingBottom: SEARCH_INPUT_OFFSET }}>
+15 -13
View File
@@ -1,7 +1,10 @@
import React, { useCallback } from 'react';
import { useNavigation, StackActions, useRoute, RouteProp } from '@react-navigation/native';
import { StackParams } from '@/screens/types';
import { useAppDispatch, useTypedSelector } from '@/store';
import { useTracks } from '@/store/music/hooks';
import { useIsDownloaded } from '@/store/downloads/hooks';
import { useSourceId } from '@/store/db/useSourceId';
import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/queue';
import { Header, SubHeader } from '@/components/Typography';
import styled from 'styled-components/native';
import { t } from '@/localisation';
@@ -12,9 +15,7 @@ import TrashIcon from '@/assets/icons/trash.svg';
import { WrappableButton, WrappableButtonRow } from '@/components/WrappableButtonRow';
import CoverImage from '@/components/CoverImage';
import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/actions';
import usePlayTracks from '@/utility/usePlayTracks';
import { selectIsDownloaded } from '@/store/downloads/selectors';
import { useGetImage } from '@/utility/JellyfinApi/lib';
import { ColoredBlurView } from '@/components/Colors';
@@ -37,13 +38,14 @@ function TrackPopupMenu() {
// Retrieve helpers
const navigation = useNavigation();
const dispatch = useAppDispatch();
const playTracks = usePlayTracks();
const getImage = useGetImage();
const sourceId = useSourceId();
// Retrieve data from store
const track = useTypedSelector((state) => state.music.tracks.entities[trackId]);
const isDownloaded = useTypedSelector(selectIsDownloaded(trackId));
// Retrieve data from database
const { tracks } = useTracks(sourceId);
const track = tracks[trackId];
const isDownloaded = useIsDownloaded(trackId);
// Set callback to close the modal
const closeModal = useCallback(() => {
@@ -63,16 +65,16 @@ function TrackPopupMenu() {
}, [playTracks, closeModal, trackId]);
// Callback for downloading the track
const handleDownload = useCallback(() => {
dispatch(queueTrackForDownload(trackId));
const handleDownload = useCallback(async () => {
await queueTrackForDownload(trackId);
closeModal();
}, [trackId, dispatch, closeModal]);
}, [trackId, closeModal]);
// Callback for removing the downloaded track
const handleDelete = useCallback(() => {
dispatch(removeDownloadedTrack(trackId));
const handleDelete = useCallback(async () => {
await removeDownloadedTrack(trackId);
closeModal();
}, [trackId, dispatch, closeModal]);
}, [trackId, closeModal]);
return (
<ColoredBlurView style={{flex: 1}}>
+19 -6
View File
@@ -19,6 +19,7 @@ export interface Download {
export interface DownloadMetadata {
size?: number;
error?: string;
image?: string;
}
/**
@@ -100,7 +101,10 @@ export async function updateDownloadProgress(
};
if (metadata) {
updates.metadataJson = JSON.stringify(metadata);
// Merge with existing metadata
const existing = await getDownload(id);
const existingMetadata = existing ? parseDownloadMetadata(existing) : {};
updates.metadataJson = JSON.stringify({ ...existingMetadata, ...metadata });
}
await db.update(downloads)
@@ -115,18 +119,26 @@ export async function updateDownloadProgress(
*/
export async function completeDownload(
id: string,
hash?: string,
filename?: string
filename?: string,
imageFilename?: string
): Promise<void> {
const updates: any = {
isComplete: true,
isFailed: false,
progress: 100,
progress: 1,
updatedAt: Date.now(),
};
if (hash) updates.hash = hash;
if (filename) updates.filename = filename;
if (filename) {
updates.filename = filename;
}
// Merge image into existing metadata
if (imageFilename) {
const existing = await getDownload(id);
const existingMetadata = existing ? parseDownloadMetadata(existing) : {};
updates.metadataJson = JSON.stringify({ ...existingMetadata, image: imageFilename });
}
await db.update(downloads)
.set(updates)
@@ -182,6 +194,7 @@ export function parseDownloadMetadata(download: Download): DownloadMetadata {
export interface DownloadWithMetadata extends Download {
size?: number;
error?: string;
image?: string;
}
export function enrichDownload(download: Download): DownloadWithMetadata {
+63
View File
@@ -0,0 +1,63 @@
/**
* Database-backed hooks for downloads data
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { downloads } from '@/store/db/schema/downloads';
import { eq } from 'drizzle-orm';
import { enrichDownload, type Download, type DownloadWithMetadata } from './db';
/**
* Get all downloads for a source
*/
export function useDownloads(sourceId: string) {
const { data, error } = useLiveQuery(
sourceId ? db.select().from(downloads).where(eq(downloads.sourceId, sourceId)) : null
);
return useMemo(() => {
const entities: Record<string, DownloadWithMetadata> = {};
const ids: string[] = [];
const queued: string[] = [];
(data || []).forEach(download => {
const enriched = enrichDownload(download as Download);
entities[enriched.id] = enriched;
ids.push(enriched.id);
// If download is not complete and not failed, it's queued
if (!enriched.isComplete && !enriched.isFailed) {
queued.push(enriched.id);
}
});
return { entities, ids, queued, error };
}, [data, error]);
}
/**
* Get a single download by id
*/
export function useDownload(trackId: string) {
const { data, error } = useLiveQuery(
trackId ? db.select().from(downloads).where(eq(downloads.id, trackId)).limit(1) : null
);
return useMemo(() => {
const download = data?.[0] as Download | undefined;
return {
entity: download ? enrichDownload(download) : undefined,
error
};
}, [data, error]);
}
/**
* Check if a track is downloaded
*/
export function useIsDownloaded(trackId: string): boolean {
const { entity } = useDownload(trackId);
return entity?.isComplete === true;
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Download queue manager
* Manages queueing and executing track downloads
*/
import { DocumentDirectoryPath, downloadFile, unlink, exists } from 'react-native-fs';
import { getActiveSource } from '@/store/settings/db';
import { db } from '@/store/db';
import { tracks } from '@/store/db/schema/tracks';
import { eq } from 'drizzle-orm';
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
import { getImage } from '@/utility/JellyfinApi/lib';
import { getExtensionForUrl } from '@/utility/mimeType';
import {
initializeDownload,
updateDownloadProgress,
completeDownload,
failDownload,
removeDownload as dbRemoveDownload
} from './db';
/**
* Queue a track for download
*/
export async function queueTrackForDownload(trackId: string): Promise<void> {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
// Initialize the download in the database
await initializeDownload(source.id, trackId);
}
/**
* Execute a track download
* This is called by the DownloadManager component
*/
export async function downloadTrack(trackId: string): Promise<void> {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
try {
// Get track from database
const trackData = await db
.select()
.from(tracks)
.where(eq(tracks.id, trackId))
.limit(1);
const dbTrack = trackData[0];
if (!dbTrack) {
await failDownload(trackId, 'Track not found in database');
return;
}
// Enrich track to get full AlbumTrack object
const metadata = dbTrack.metadataJson ? JSON.parse(dbTrack.metadataJson) : {};
const track = {
Id: dbTrack.id,
Name: dbTrack.name,
AlbumId: dbTrack.albumId,
Album: dbTrack.album,
AlbumArtist: dbTrack.albumArtist,
ProductionYear: dbTrack.productionYear,
IndexNumber: dbTrack.indexNumber,
ParentIndexNumber: dbTrack.parentIndexNumber,
HasLyrics: dbTrack.hasLyrics,
RunTimeTicks: dbTrack.runTimeTicks,
...metadata,
};
// Generate URLs
const audioUrl = generateTrackUrl(trackId);
const imageUrl = getImage(track);
// Get extensions
const [audioExt, imageExt] = await Promise.all([
getExtensionForUrl(audioUrl),
imageUrl ? getExtensionForUrl(imageUrl).catch(() => null) : null
]);
// Generate file locations
const audioLocation = `${DocumentDirectoryPath}/${trackId}.${audioExt}`;
const imageLocation = imageExt ? `${DocumentDirectoryPath}/${trackId}.${imageExt}` : undefined;
// Download audio file
const { promise: audioPromise } = downloadFile({
fromUrl: audioUrl,
progressInterval: 1000,
background: true,
begin: ({ contentLength }) => {
updateDownloadProgress(trackId, 0, { size: contentLength });
},
progress: (result) => {
const progressValue = result.bytesWritten / result.contentLength;
updateDownloadProgress(trackId, progressValue);
},
toFile: audioLocation,
});
// Download image file if available
const { promise: imagePromise } = imageExt && imageLocation
? downloadFile({
fromUrl: imageUrl!,
toFile: imageLocation,
background: true,
})
: { promise: Promise.resolve(null) };
// Wait for completion
const [audioResult, imageResult] = await Promise.all([audioPromise, imagePromise]);
const totalSize = audioResult.bytesWritten + (imageResult?.bytesWritten || 0);
// Mark as complete
await completeDownload(trackId, audioLocation, imageLocation);
await updateDownloadProgress(trackId, 1, { size: totalSize });
} catch (error) {
await failDownload(trackId, error instanceof Error ? error.message : 'Unknown error');
}
}
/**
* Remove a downloaded track
*/
export async function removeDownloadedTrack(trackId: string): Promise<void> {
// Get the download from database
const downloadData = await db
.select()
.from(require('@/store/db/schema/downloads').downloads)
.where(eq(require('@/store/db/schema/downloads').downloads.id, trackId))
.limit(1);
const download = downloadData[0];
if (download) {
// Delete files if they exist
if (download.filename && await exists(download.filename)) {
await unlink(download.filename);
}
// Extract image path from metadata if present
const metadata = download.metadataJson ? JSON.parse(download.metadataJson) : {};
if (metadata.image && await exists(metadata.image)) {
await unlink(metadata.image);
}
}
// Remove from database
await dbRemoveDownload(trackId);
}
-1
View File
@@ -12,7 +12,6 @@ import { tracks } from '@/store/db/schema/tracks';
import { playlists } from '@/store/db/schema/playlists';
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
import { eq, desc, and, inArray } from 'drizzle-orm';
import { parseISO } from 'date-fns';
import { ALPHABET_LETTERS } from '@/CONSTANTS';
import type { SectionListData } from 'react-native';
import type { Album, AlbumTrack, MusicArtist, Playlist } from './types';
-11
View File
@@ -3,8 +3,6 @@ import { version } from '../../../package.json';
import { Album, AlbumTrack, ArtistItem, Playlist } from '@/store/music/types';
import { db } from '@/store/db';
import { sources } from '@/store/db/schema/sources';
import { downloads } from '@/store/db/schema/downloads';
import { eq } from 'drizzle-orm';
import { useLiveQuery } from '@/store/db/live-queries';
import { useCallback } from 'react';
@@ -141,15 +139,6 @@ export function getImage(item: string | number | Album | AlbumTrack | Playlist |
return undefined;
}
// Get the item ID
const itemId = typeof item === 'string' || typeof item === 'number'
? item
: 'PrimaryImageItemId' in item
? item.PrimaryImageItemId || item.Id
: 'AlbumId' in item
? item.AlbumId || item.Id
: item.Id;
// Return server URL for the image
if (typeof item === 'string' || typeof item === 'number') {
if (__DEV__) {
+5 -3
View File
@@ -1,4 +1,5 @@
import { useTypedSelector } from '@/store';
import { useTracks } from '@/store/music/hooks';
import { useSourceId } from '@/store/db/useSourceId';
import { AlbumTrack } from '@/store/music/types';
import { useEffect, useMemo, useState } from 'react';
import TrackPlayer, { Event, useTrackPlayerEvents, Track } from 'react-native-track-player';
@@ -16,8 +17,9 @@ export default function useCurrentTrack(): CurrentTrackResponse {
const [track, setTrack] = useState<Track | undefined>();
const [index, setIndex] = useState<number | undefined>();
// Retrieve entities from the store
const entities = useTypedSelector((state) => state.music.tracks.entities);
// Retrieve entities from the database
const sourceId = useSourceId();
const { tracks: entities } = useTracks(sourceId);
// Attempt to extract the track from the store
const albumTrack = useMemo(() => (
+15 -7
View File
@@ -1,10 +1,12 @@
import { useTypedSelector } from '@/store';
import { useCallback } from 'react';
import TrackPlayer, { Track } from 'react-native-track-player';
import { shuffle as shuffleArray } from 'lodash';
import { generateTrack } from './JellyfinApi/track';
import { useTracks } from '@/store/music/hooks';
import { useDownloads } from '@/store/downloads/hooks';
import { useSourceId } from '@/store/db/useSourceId';
import type { AlbumTrack } from '@/store/music/types';
import type { DownloadEntity } from '@/store/downloads/types';
import type { DownloadWithMetadata } from '@/store/downloads/db';
interface PlayOptions {
play: boolean;
@@ -33,7 +35,7 @@ const defaults: PlayOptions = {
export async function playTracks(
trackIds: string[] | undefined,
tracks: Record<string, AlbumTrack>,
downloads: Record<string, DownloadEntity>,
downloads: Record<string, DownloadWithMetadata | any>,
options: Partial<PlayOptions> = {},
): Promise<Track[] | undefined> {
if (!trackIds) {
@@ -61,9 +63,14 @@ export async function playTracks(
// Check if a downloaded version exists, and if so rewrite the URL
const download = downloads[trackId];
if (download?.location) {
generatedTrack.url = 'file://' + download.location;
if (download?.isComplete) {
// Handle both old Redux format (location) and new DB format (filename)
const audioPath = download.filename || download.location;
if (audioPath) {
generatedTrack.url = 'file://' + audioPath;
}
}
// Check for downloaded image (both old and new format)
if (download?.image) {
generatedTrack.artwork = 'file://' + download.image;
}
@@ -165,8 +172,9 @@ export async function playTracks(
* supplied id.
*/
export default function usePlayTracks() {
const tracksEntities = useTypedSelector(state => state.music.tracks.entities);
const downloadsEntities = useTypedSelector(state => state.downloads.entities);
const sourceId = useSourceId();
const { tracks: tracksEntities } = useTracks(sourceId);
const { entities: downloadsEntities } = useDownloads(sourceId);
return useCallback(
(trackIds: string[] | undefined, options: Partial<PlayOptions> = {}) =>