fix: retrieve codec metadata and lyrics asynchronously

This commit is contained in:
Lei Nelissen
2024-10-25 00:25:01 +02:00
parent 4dd0d6e0e5
commit 77db5a51d2
9 changed files with 125 additions and 88 deletions

View File

@@ -1,17 +1,13 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'; import React, {useCallback, useMemo, useRef, useState} from 'react';
import { LayoutChangeEvent, LayoutRectangle, StyleSheet, View } from 'react-native'; import { LayoutChangeEvent, LayoutRectangle, StyleSheet, View } from 'react-native';
import Animated from 'react-native-reanimated'; import Animated from 'react-native-reanimated';
import { Lyrics } from '@/utility/JellyfinApi/lyrics';
import { useProgress } from 'react-native-track-player'; import { useProgress } from 'react-native-track-player';
import useCurrentTrack from '@/utility/useCurrentTrack'; import useCurrentTrack from '@/utility/useCurrentTrack';
import LyricsLine from './LyricsLine'; import LyricsLine from './LyricsLine';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { useTypedSelector } from '@/store';
import { NOW_PLAYING_POPOVER_HEIGHT } from '@/screens/Music/overlays/NowPlaying'; import { NOW_PLAYING_POPOVER_HEIGHT } from '@/screens/Music/overlays/NowPlaying';
import LyricsProgress, { LyricsProgressProps } from './LyricsProgress'; import LyricsProgress, { LyricsProgressProps } from './LyricsProgress';
type LyricsLine = Lyrics['Lyrics'][number];
const styles = StyleSheet.create({ const styles = StyleSheet.create({
lyricsContainerFull: { lyricsContainerFull: {
padding: 40, padding: 40,
@@ -42,9 +38,7 @@ export default function LyricsRenderer({ size = 'full' }: LyricsRendererProps) {
const scrollViewRef = useRef<Animated.ScrollView>(null); const scrollViewRef = useRef<Animated.ScrollView>(null);
const lineLayoutsRef = useRef(new Map<number, LayoutRectangle>()); const lineLayoutsRef = useRef(new Map<number, LayoutRectangle>());
const { position } = useProgress(100); const { position } = useProgress(100);
const { track: trackPlayerTrack } = useCurrentTrack(); const { track, albumTrack } = useCurrentTrack();
const tracks = useTypedSelector((state) => state.music.tracks.entities);
const track = useMemo(() => tracks[trackPlayerTrack?.backendId], [trackPlayerTrack?.backendId, tracks]);
const navigation = useNavigation(); const navigation = useNavigation();
// We will be using isUserScrolling to prevent lyrics controller scroll lyrics view // We will be using isUserScrolling to prevent lyrics controller scroll lyrics view
@@ -83,12 +77,12 @@ export default function LyricsRenderer({ size = 'full' }: LyricsRendererProps) {
const handleScrollBeginDrag = useCallback(() => isUserScrolling.current = true, []); const handleScrollBeginDrag = useCallback(() => isUserScrolling.current = true, []);
const handleScrollEndDrag = useCallback(() => isUserScrolling.current = false, []); const handleScrollEndDrag = useCallback(() => isUserScrolling.current = false, []);
if (!track) { if (!track || !albumTrack) {
return null; return null;
} }
// GUARD: If the track has no lyrics, close the modal // GUARD: If the track has no lyrics, close the modal
if (!track.HasLyrics || !track.Lyrics) { if (!albumTrack.HasLyrics || !albumTrack.Lyrics) {
navigation.goBack(); navigation.goBack();
return null; return null;
} }
@@ -107,18 +101,18 @@ export default function LyricsRenderer({ size = 'full' }: LyricsRendererProps) {
> >
<LyricsProgress <LyricsProgress
start={0} start={0}
end={track.Lyrics.Lyrics[0].Start - TIME_OFFSET} end={albumTrack.Lyrics.Lyrics[0].Start - TIME_OFFSET}
position={currentTime} position={currentTime}
index={-1} index={-1}
onActive={handleActive} onActive={handleActive}
onLayout={handleLayoutChange} onLayout={handleLayoutChange}
/> />
{track.Lyrics.Lyrics.map((lyrics, i) => { {albumTrack.Lyrics.Lyrics.map((lyrics, i) => {
const props: LyricsProgressProps = { const props: LyricsProgressProps = {
start: lyrics.Start - TIME_OFFSET, start: lyrics.Start - TIME_OFFSET,
end: track.Lyrics!.Lyrics.length === i + 1 end: albumTrack.Lyrics!.Lyrics.length === i + 1
? track.RunTimeTicks ? track.RunTimeTicks
: track.Lyrics!.Lyrics[i + 1]?.Start - TIME_OFFSET : albumTrack.Lyrics!.Lyrics[i + 1]?.Start - TIME_OFFSET
, ,
position: currentTime, position: currentTime,
onLayout: handleLayoutChange, onLayout: handleLayoutChange,

View File

@@ -52,18 +52,18 @@ export default function MediaInformation() {
<WaveformIcon fill={styles.icon.color} height={16} width={16} /> <WaveformIcon fill={styles.icon.color} height={16} width={16} />
<Info> <Info>
<Label numberOfLines={1} overflow> <Label numberOfLines={1} overflow>
{track.isDirectPlay ? t('direct-play') : t('transcoded')} {albumTrack.Codec?.isDirectPlay ? t('direct-play') : t('transcoded')}
</Label> </Label>
<Label numberOfLines={1}> <Label numberOfLines={1}>
{track.isDirectPlay {albumTrack.Codec?.isDirectPlay
? mediaStream?.Codec.toUpperCase() ? mediaStream?.Codec.toUpperCase()
: track.contentType?.replace('audio/', '').toUpperCase() : albumTrack.Codec?.contentType?.replace('audio/', '').toUpperCase()
} }
</Label> </Label>
{mediaStream && ( {mediaStream && (
<> <>
<Label numberOfLines={1}> <Label numberOfLines={1}>
{((track.isDirectPlay ? mediaStream.BitRate : track.bitRate) / 1000) {((albumTrack.Codec?.isDirectPlay ? mediaStream.BitRate : track.bitRate) / 1000)
.toFixed(0)} .toFixed(0)}
{t('kbps')} {t('kbps')}
</Label> </Label>

View File

@@ -1,15 +1,61 @@
import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit'; import { AsyncThunkPayloadCreator, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { Album, AlbumTrack, Playlist } from './types'; import { Album, AlbumTrack, CodecMetadata, Lyrics, Playlist } from './types';
import { AsyncThunkAPI } from '..'; import type { AsyncThunkAPI } from '..';
import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album'; import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album';
import { retrieveAllPlaylists, retrievePlaylistTracks } from '@/utility/JellyfinApi/playlist'; import { retrieveAllPlaylists, retrievePlaylistTracks } from '@/utility/JellyfinApi/playlist';
import { searchItem } from '@/utility/JellyfinApi/search'; import { searchItem } from '@/utility/JellyfinApi/search';
import { retrieveTrackLyrics } from '@/utility/JellyfinApi/lyrics';
import { retrieveTrackCodecMetadata } from '@/utility/JellyfinApi/track';
export const albumAdapter = createEntityAdapter<Album, string>({ export const albumAdapter = createEntityAdapter<Album, string>({
selectId: album => album.Id, selectId: album => album.Id,
sortComparer: (a, b) => a.Name.localeCompare(b.Name), sortComparer: (a, b) => a.Name.localeCompare(b.Name),
}); });
/**
* Fetch lyrics for a given track
*/
export const fetchLyricsByTrack = createAsyncThunk<Lyrics, string, AsyncThunkAPI>(
'/track/lyrics',
retrieveTrackLyrics,
);
/**
* Fetch codec metadata for a given track
*/
export const fetchCodecMetadataByTrack = createAsyncThunk<CodecMetadata, string, AsyncThunkAPI>(
'/track/codecMetadata',
retrieveTrackCodecMetadata,
);
/** A generic type for any action that retrieves tracks */
type AlbumTrackPayloadCreator = AsyncThunkPayloadCreator<AlbumTrack[], string, AsyncThunkAPI>;
/**
* This is a wrapper that postprocesses any tracks, so that we can also support
* lyrics, codec metadata and potential other applications.
*/
export const postProcessTracks = function(creator: AlbumTrackPayloadCreator): AlbumTrackPayloadCreator {
// Return a new payload creator
return async (args, thunkAPI) => {
// Retrieve the tracks using the original creator
const tracks = await creator(args, thunkAPI);
// GUARD: Check if we've retrieved any tracks
if (Array.isArray(tracks)) {
// If so, attempt to retrieve lyrics for the tracks that have them
tracks.filter((t) => t.HasLyrics)
.forEach((t) => thunkAPI.dispatch(fetchLyricsByTrack(t.Id)));
// Also, retrieve codec metadata
tracks.forEach((t) => thunkAPI.dispatch(fetchCodecMetadataByTrack(t.Id)));
}
return tracks;
};
};
/** /**
* Fetch all albums available on the jellyfin server * Fetch all albums available on the jellyfin server
*/ */
@@ -36,7 +82,7 @@ export const trackAdapter = createEntityAdapter<AlbumTrack, string>({
*/ */
export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>( export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
'/tracks/byAlbum', '/tracks/byAlbum',
retrieveAlbumTracks, postProcessTracks(retrieveAlbumTracks),
); );
export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>( export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>(
@@ -100,5 +146,5 @@ export const fetchAllPlaylists = createAsyncThunk<Playlist[], undefined, AsyncTh
*/ */
export const fetchTracksByPlaylist = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>( export const fetchTracksByPlaylist = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
'/tracks/byPlaylist', '/tracks/byPlaylist',
retrievePlaylistTracks, postProcessTracks(retrievePlaylistTracks),
); );

View File

@@ -9,7 +9,9 @@ import {
fetchAllPlaylists, fetchAllPlaylists,
fetchTracksByPlaylist, fetchTracksByPlaylist,
fetchAlbum, fetchAlbum,
fetchSimilarAlbums fetchSimilarAlbums,
fetchCodecMetadataByTrack,
fetchLyricsByTrack
} from './actions'; } from './actions';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { Album, AlbumTrack, Playlist } from './types'; import { Album, AlbumTrack, Playlist } from './types';
@@ -162,6 +164,16 @@ const music = createSlice({
// Reset any caches we have when a new server is set // Reset any caches we have when a new server is set
builder.addCase(setJellyfinCredentials, () => initialState); builder.addCase(setJellyfinCredentials, () => initialState);
/**
* Fetch track metadata
*/
builder.addCase(fetchCodecMetadataByTrack.fulfilled, (state, { payload, meta }) => {
state.tracks.entities[meta.arg].Codec = payload;
});
builder.addCase(fetchLyricsByTrack.fulfilled, (state, { payload, meta }) => {
state.tracks.entities[meta.arg].Lyrics = payload;
});
} }
}); });

View File

@@ -1,5 +1,3 @@
import {Lyrics} from '@/utility/JellyfinApi/lyrics.ts';
export interface UserData { export interface UserData {
PlaybackPositionTicks: number; PlaybackPositionTicks: number;
PlayCount: number; PlayCount: number;
@@ -72,6 +70,34 @@ export interface Album {
PrimaryImageItemId?: string; PrimaryImageItemId?: string;
} }
export interface CodecMetadata {
contentType?: string;
isDirectPlay: boolean;
}
export interface LyricMetadata {
Artist: string
Album: string
Title: string
Author: string
Length: number
By: string
Offset: number
Creator: string
Version: string
IsSynced: boolean
}
export interface LyricData {
Text: string
Start: number
}
export interface Lyrics {
Metadata: LyricMetadata;
Lyrics: LyricData[]
}
export interface AlbumTrack { export interface AlbumTrack {
Name: string; Name: string;
ServerId: string; ServerId: string;
@@ -95,7 +121,8 @@ export interface AlbumTrack {
LocationType: string; LocationType: string;
MediaType: string; MediaType: string;
HasLyrics: boolean; HasLyrics: boolean;
Lyrics: Lyrics | null; Lyrics?: Lyrics;
Codec?: CodecMetadata;
MediaStreams: MediaStream[]; MediaStreams: MediaStream[];
} }

View File

@@ -1,6 +1,5 @@
import { Album, AlbumTrack } from '@/store/music/types'; import { Album, AlbumTrack } from '@/store/music/types';
import { fetchApi } from './lib'; import { fetchApi } from './lib';
import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics.ts';
const albumOptions = { const albumOptions = {
SortBy: 'AlbumArtist,SortName', SortBy: 'AlbumArtist,SortName',
@@ -73,5 +72,5 @@ export async function retrieveAlbumTracks(ItemId: string) {
const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString(); const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`) return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`)
.then((data) => retrieveAndInjectLyricsToTracks(data.Items)); .then((d) => d.Items);
} }

View File

@@ -1,48 +1,6 @@
import { Lyrics } from '@/store/music/types';
import { fetchApi } from './lib'; import { fetchApi } from './lib';
import {AlbumTrack} from '@/store/music/types.ts';
interface Metadata { export async function retrieveTrackLyrics(trackId: string): Promise<Lyrics> {
Artist: string return fetchApi<Lyrics>(`/Audio/${trackId}/Lyrics`);
Album: string
Title: string
Author: string
Length: number
By: string
Offset: number
Creator: string
Version: string
IsSynced: boolean
}
interface LyricData {
Text: string
Start: number
}
export interface Lyrics {
Metadata: Metadata
Lyrics: LyricData[]
}
async function retrieveTrackLyrics(trackId: string): Promise<Lyrics | null> {
return fetchApi<Lyrics>(`/Audio/${trackId}/Lyrics`)
.catch((e) => {
console.error('Error on fetching track lyrics: ', e);
return null;
});
}
export async function retrieveAndInjectLyricsToTracks(tracks: AlbumTrack[]): Promise<AlbumTrack[]> {
return Promise.all(tracks.map(async (track) => {
if (!track.HasLyrics) {
track.Lyrics = null;
return track;
}
track.Lyrics = await retrieveTrackLyrics(track.Id);
return track;
}));
} }

View File

@@ -1,6 +1,5 @@
import { AlbumTrack, Playlist } from '@/store/music/types'; import { AlbumTrack, Playlist } from '@/store/music/types';
import { asyncFetchStore, fetchApi } from './lib'; import { asyncFetchStore, fetchApi } from './lib';
import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics.ts';
const playlistOptions = { const playlistOptions = {
SortBy: 'SortName', SortBy: 'SortName',
@@ -35,5 +34,5 @@ export async function retrievePlaylistTracks(ItemId: string) {
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString(); const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`) return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`)
.then((d) => retrieveAndInjectLyricsToTracks(d.Items)); .then((d) => d.Items);
} }

View File

@@ -1,9 +1,7 @@
import { AlbumTrack } from '@/store/music/types'; import { AlbumTrack, CodecMetadata } from '@/store/music/types';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { Track } from 'react-native-track-player'; import { Track } from 'react-native-track-player';
import { fetchApi, getImage } from './lib'; import { asyncFetchStore, fetchApi, getImage } from './lib';
import store from '@/store';
import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics';
const trackOptionsOsOverrides: Record<typeof Platform.OS, Record<string, string>> = { const trackOptionsOsOverrides: Record<typeof Platform.OS, Record<string, string>> = {
ios: { ios: {
@@ -30,7 +28,7 @@ const baseTrackOptions: Record<string, string> = {
* Generate the track streaming url from the trackId * Generate the track streaming url from the trackId
*/ */
export function generateTrackUrl(trackId: string) { export function generateTrackUrl(trackId: string) {
const credentials = store.getState().settings.credentials; const credentials = asyncFetchStore().getState().settings.credentials;
const trackOptions = { const trackOptions = {
...baseTrackOptions, ...baseTrackOptions,
UserId: credentials?.user_id || '', UserId: credentials?.user_id || '',
@@ -52,8 +50,6 @@ export async function generateTrack(track: AlbumTrack): Promise<Track> {
// Also construct the URL for the stream // Also construct the URL for the stream
const url = generateTrackUrl(track.Id); const url = generateTrackUrl(track.Id);
const response = await fetch(url, { method: 'HEAD' });
return { return {
url, url,
backendId: track.Id, backendId: track.Id,
@@ -62,10 +58,6 @@ export async function generateTrack(track: AlbumTrack): Promise<Track> {
album: track.Album, album: track.Album,
duration: track.RunTimeTicks, duration: track.RunTimeTicks,
artwork: getImage(track.Id), artwork: getImage(track.Id),
hasLyrics: track.HasLyrics,
lyrics: track.Lyrics,
contentType: response.headers.get('Content-Type') || undefined,
isDirectPlay: response.headers.has('Content-Length'),
bitRate: baseTrackOptions.audioBitRate, bitRate: baseTrackOptions.audioBitRate,
}; };
} }
@@ -84,5 +76,15 @@ const trackParams = {
*/ */
export async function retrieveAllTracks() { export async function retrieveAllTracks() {
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`) return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`)
.then((d) => retrieveAndInjectLyricsToTracks(d.Items)); .then((d) => d.Items);
} }
export async function retrieveTrackCodecMetadata(trackId: string): Promise<CodecMetadata> {
const url = generateTrackUrl(trackId);
const response = await fetch(url, { method: 'HEAD' });
return {
contentType: response.headers.get('Content-Type') || undefined,
isDirectPlay: response.headers.has('Content-Length'),
};
}