Compare commits

...

17 Commits

Author SHA1 Message Date
Lei Nelissen
287b64c356 chore: release v2.1.0 2023-04-23 23:51:53 +02:00
Lei Nelissen
e116e95236 fix: reign in padding on album view a bit 2023-04-23 23:37:48 +02:00
Lei Nelissen
c8283fc580 feat: finish offsets on new navigation views 2023-04-23 23:31:35 +02:00
Lei Nelissen
81b9ba683a fix: make similar albums translateable 2023-04-23 22:06:39 +02:00
Lei Nelissen
913d185b46 fix: padding in similar scrollwheel 2023-04-23 01:29:59 +02:00
Lei Nelissen
1d97830f83 fix: contentInset doesn't behave on Android 2023-04-23 01:25:43 +02:00
Lei Nelissen
6ccfd19dea fix: linter issues 2023-04-23 01:15:07 +02:00
Lei Nelissen
2e816f4a71 fix: correctly calculate amount of minutes when an hour is present 2023-04-23 01:14:56 +02:00
Lei Nelissen
4ff071d0c8 fix: only show similar albums if there are any 2023-04-23 01:07:56 +02:00
Lei Nelissen
dba87247d8 feat: add extra metadata to the album view 2023-04-23 01:04:30 +02:00
Lei Nelissen
c3c32ae565 feat: show artist in playlist view 2023-04-22 23:41:41 +02:00
Lei Nelissen
1d7db11328 fix: also add navigator padding when playing the first track in a queue 2023-04-22 23:41:25 +02:00
Lei Nelissen
1a5e4aee12 feat: add blurview to headers as well 2023-04-22 23:31:37 +02:00
Lei Nelissen
e2c1c0300f fix: keep album views in search tab when navigating from search results 2023-04-22 22:31:54 +02:00
Lei Nelissen
7601408d49 feat: update tab bars with blurview 2023-04-22 21:58:27 +02:00
Lei Nelissen
4509ef1ec6 fix: remove padding from Modal 2023-04-22 20:52:30 +02:00
Lei Nelissen
dcd3f595ed chore: update changelog 2023-04-12 11:50:35 +02:00
45 changed files with 880 additions and 533 deletions

View File

@@ -52,6 +52,12 @@ module.exports = {
'react/prop-types': 'off', 'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'error' 'error'
],
'react/jsx-no-literals': [
'error',
{
ignoreProps: true
}
] ]
}, },
settings: { settings: {

View File

@@ -1,3 +1,12 @@
## [2.0.5](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.4...v2.0.5) (2023-04-12)
### Bug Fixes
* crash when fast-image fails to load an image ([67499b1](https://github.com/leinelissen/jellyfin-audio-player/commit/67499b11037779bf33bb557fff69114cd519c78e))
## [2.0.4](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.3...v2.0.4) (2023-04-11) ## [2.0.4](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.3...v2.0.4) (2023-04-11)

View File

@@ -138,8 +138,8 @@ android {
applicationId "nl.moeilijkedingen.jellyfinaudioplayer" applicationId "nl.moeilijkedingen.jellyfinaudioplayer"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 17 versionCode 18
versionName "2.0.5" versionName "2.1.0"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
if (isNewArchitectureEnabled()) { if (isNewArchitectureEnabled()) {

View File

@@ -68,6 +68,14 @@ Generate beta build
### android build
```sh
[bundle exec] fastlane android build
```
---- ----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.

View File

@@ -606,7 +606,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63; CURRENT_PROJECT_VERSION = 64;
DEVELOPMENT_TEAM = 238P3C58WC; DEVELOPMENT_TEAM = 238P3C58WC;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = ( GCC_PREPROCESSOR_DEFINITIONS = (
@@ -643,7 +643,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63; CURRENT_PROJECT_VERSION = 64;
DEVELOPMENT_TEAM = 238P3C58WC; DEVELOPMENT_TEAM = 238P3C58WC;
INFOPLIST_FILE = Fintunes/Info.plist; INFOPLIST_FILE = Fintunes/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -799,7 +799,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63; CURRENT_PROJECT_VERSION = 64;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 238P3C58WC; DEVELOPMENT_TEAM = 238P3C58WC;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -832,7 +832,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 63; CURRENT_PROJECT_VERSION = 64;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 238P3C58WC; DEVELOPMENT_TEAM = 238P3C58WC;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.0.5</string> <string>2.1.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>63</string> <string>64</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>

17
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{ {
"name": "fintunes", "name": "fintunes",
"version": "2.0.5", "version": "2.1.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fintunes", "name": "fintunes",
"version": "2.0.5", "version": "2.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@react-native-async-storage/async-storage": "^1.17.11", "@react-native-async-storage/async-storage": "^1.17.11",
"@react-native-community/blur": "^4.3.0", "@react-native-community/blur": "^4.3.0",
"@react-native-community/netinfo": "^9.3.6", "@react-native-community/netinfo": "^9.3.6",
"@react-navigation/bottom-tabs": "^6.4.0", "@react-navigation/bottom-tabs": "^6.4.0",
"@react-navigation/elements": "^1.3.17",
"@react-navigation/native": "^6.0.13", "@react-navigation/native": "^6.0.13",
"@react-navigation/native-stack": "^6.9.1", "@react-navigation/native-stack": "^6.9.1",
"@react-navigation/stack": "^6.3.4", "@react-navigation/stack": "^6.3.4",
@@ -3403,9 +3404,9 @@
} }
}, },
"node_modules/@react-navigation/elements": { "node_modules/@react-navigation/elements": {
"version": "1.3.6", "version": "1.3.17",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz",
"integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w==", "integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA==",
"peerDependencies": { "peerDependencies": {
"@react-navigation/native": "^6.0.0", "@react-navigation/native": "^6.0.0",
"react": "*", "react": "*",
@@ -19014,9 +19015,9 @@
} }
}, },
"@react-navigation/elements": { "@react-navigation/elements": {
"version": "1.3.6", "version": "1.3.17",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz",
"integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w==" "integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA=="
}, },
"@react-navigation/native": { "@react-navigation/native": {
"version": "6.0.13", "version": "6.0.13",

View File

@@ -1,6 +1,6 @@
{ {
"name": "fintunes", "name": "fintunes",
"version": "2.0.5", "version": "2.1.0",
"main": "src/index.js", "main": "src/index.js",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -8,7 +8,7 @@
"ios": "react-native run-ios --scheme \"Fintunes\"", "ios": "react-native run-ios --scheme \"Fintunes\"",
"start": "react-native start", "start": "react-native start",
"test": "jest", "test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx && tsc --noEmit", "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
"build:ios": "react-native bundle --entry-file='index.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'", "build:ios": "react-native bundle --entry-file='index.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
@@ -17,6 +17,7 @@
"@react-native-community/blur": "^4.3.0", "@react-native-community/blur": "^4.3.0",
"@react-native-community/netinfo": "^9.3.6", "@react-native-community/netinfo": "^9.3.6",
"@react-navigation/bottom-tabs": "^6.4.0", "@react-navigation/bottom-tabs": "^6.4.0",
"@react-navigation/elements": "^1.3.17",
"@react-navigation/native": "^6.0.13", "@react-navigation/native": "^6.0.13",
"@react-navigation/native-stack": "^6.9.1", "@react-navigation/native-stack": "^6.9.1",
"@react-navigation/stack": "^6.3.4", "@react-navigation/stack": "^6.3.4",

View File

@@ -109,7 +109,7 @@ export function ColoredBlurView(props: PropsWithChildren<BlurViewProps>) {
} /> } />
) : ( ) : (
<View {...props} style={[ props.style, { <View {...props} style={[ props.style, {
backgroundColor: scheme === 'light' ? '#f6f6f6f6' : '#333333f6', backgroundColor: scheme === 'light' ? '#f6f6f6fb' : '#333333fb',
} ]} /> } ]} />
); );
} }

View File

@@ -2,23 +2,35 @@ import React, { useCallback, useRef } from 'react';
import { Platform, TextInput, TextInputProps } from 'react-native'; import { Platform, TextInput, TextInputProps } from 'react-native';
import styled, { css } from 'styled-components/native'; import styled, { css } from 'styled-components/native';
import useDefaultStyles from './Colors'; import useDefaultStyles from './Colors';
import { Gap } from './Utility';
export interface InputProps extends TextInputProps { export interface InputProps extends TextInputProps {
icon?: React.ReactNode; icon?: React.ReactNode;
} }
const Container = styled.Pressable` const Container = styled.Pressable<{ hasIcon?: boolean }>`
position: relative;
margin: 6px 0; margin: 6px 0;
border-radius: 8px; border-radius: 12px;
display: flex; display: flex;
flex-direction: row;
align-items: center;
${Platform.select({ ${Platform.select({
ios: css`padding: 12px;`, ios: css`padding: 12px;`,
android: css`padding: 4px 12px;`, android: css`padding: 4px 12px;`,
})} })}
${({ hasIcon }) => hasIcon && css`
padding-left: 36px;
`}
`;
const IconWrapper = styled.View`
position: absolute;
left: 0;
top: 0;
bottom: 0;
display: flex;
justify-content: center;
padding-left: 12px;
`; `;
function Input({ icon = null, style, testID, ...rest }: InputProps) { function Input({ icon = null, style, testID, ...rest }: InputProps) {
@@ -28,12 +40,17 @@ function Input({ icon = null, style, testID, ...rest }: InputProps) {
const handlePress = useCallback(() => inputRef.current?.focus(), []); const handlePress = useCallback(() => inputRef.current?.focus(), []);
return ( return (
<Container style={[defaultStyles.input, style]} onPress={handlePress} testID={`${testID}-container`} accessible={false}> <Container
style={[defaultStyles.input, style]}
onPress={handlePress}
testID={`${testID}-container`}
accessible={false}
hasIcon={!!icon}
>
{icon && ( {icon && (
<> <IconWrapper>
{icon} {icon}
<Gap size={8} /> </IconWrapper>
</>
)} )}
<TextInput <TextInput
{...rest} {...rest}

View File

@@ -14,7 +14,6 @@ const Background = styled.View`
const Container = styled.View<Pick<Props, 'fullSize'>>` const Container = styled.View<Pick<Props, 'fullSize'>>`
margin: auto 20px; margin: auto 20px;
padding: 4px;
border-radius: 12px; border-radius: 12px;
flex: 0 0 auto; flex: 0 0 auto;
background: salmon; background: salmon;

View File

@@ -0,0 +1,100 @@
import React, { ForwardedRef, Ref, forwardRef } from 'react';
import { useHeaderHeight } from '@react-navigation/elements';
import { FlatList, FlatListProps, ScrollView, ScrollViewProps, SectionList, SectionListProps } from 'react-native';
import useCurrentTrack from '../utility/useCurrentTrack';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
declare module 'react' {
function forwardRef<T, P = {}>(
render: (props: P, ref: React.Ref<T>) => React.ReactElement | null
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}
/**
* A wrapper for ScrollView that takes any paddings, margins and insets into
* account that result from the bottom tabs, potential NowPlaying overlay and header.
*/
export function SafeScrollView({
contentContainerStyle,
...props
}: ScrollViewProps) {
const { top, bottom } = useNavigationOffsets();
return (
<ScrollView
contentContainerStyle={[
contentContainerStyle,
{ paddingTop: top, paddingBottom: bottom },
]}
scrollIndicatorInsets={{ top: top / 2, bottom: bottom / 2 + 5 }}
{...props}
/>
);
}
/**
* A wrapper for ScrollView that takes any paddings, margins and insets into
* account that result from the bottom tabs, potential NowPlaying overlay and header.
*/
function BareSafeSectionList<I, S>({
contentContainerStyle,
...props
}: SectionListProps<I, S>, ref: ForwardedRef<SectionList<I, S>>) {
const { top, bottom } = useNavigationOffsets();
return (
<SectionList
contentContainerStyle={[
{ paddingTop: top, paddingBottom: bottom },
contentContainerStyle,
]}
scrollIndicatorInsets={{ top: top / 2, bottom: bottom / 2 + 5 }}
ref={ref}
{...props}
/>
);
}
export const SafeSectionList = forwardRef(BareSafeSectionList);
/**
* A wrapper for ScrollView that takes any paddings, margins and insets into
* account that result from the bottom tabs, potential NowPlaying overlay and header.
*/
function BareSafeFlatList<I>({
contentContainerStyle,
...props
}: FlatListProps<I>, ref: ForwardedRef<FlatList<I>>) {
const { top, bottom } = useNavigationOffsets();
return (
<FlatList
contentContainerStyle={[
{ paddingTop: top, paddingBottom: bottom },
contentContainerStyle,
]}
scrollIndicatorInsets={{ top, bottom }}
ref={ref}
{...props}
/>
);
}
export const SafeFlatList = forwardRef(BareSafeFlatList);
/**
* A hook that returns the correct offset that should be applied to any Views
* that are wrapped in a NavigationView, in order to account for overlays,
* headers and bottom tabs.
*/
export function useNavigationOffsets({ includeOverlay = true } = {} as { includeOverlay?: boolean }) {
const headerHeight = useHeaderHeight();
const bottomBarHeight = useBottomTabBarHeight();
const { track } = useCurrentTrack();
return {
top: headerHeight,
bottom: (track && includeOverlay ? 68 : 0) + bottomBarHeight || 0,
};
}

View File

@@ -15,7 +15,8 @@ export function Text(props: PropsWithChildren<TextProps>) {
export const Header = styled(Text)` export const Header = styled(Text)`
margin: 0 0 6px 0; margin: 0 0 6px 0;
font-size: 28px; font-size: 28px;
font-weight: 400; font-weight: 500;
letter-spacing: -0.3px;
`; `;
export const SubHeader = styled(Text)` export const SubHeader = styled(Text)`
@@ -24,3 +25,9 @@ export const SubHeader = styled(Text)`
font-weight: 400; font-weight: 400;
opacity: 0.5; opacity: 0.5;
`; `;
export const Paragraph = styled(Text)`
opacity: 0.5;
font-size: 12px;
line-height: 20px;
`;

View File

@@ -59,5 +59,7 @@
"you-are-offline-message": "You are currently offline. You can only play previously downloaded music.", "you-are-offline-message": "You are currently offline. You can only play previously downloaded music.",
"playing-on": "Playing on", "playing-on": "Playing on",
"local-playback": "Local playback", "local-playback": "Local playback",
"streaming": "Streaming" "streaming": "Streaming",
"total-duration": "Total duration",
"similar-albums": "Similar albums"
} }

View File

@@ -59,5 +59,7 @@
"you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen.", "you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen.",
"playing-on": "Speelt af op", "playing-on": "Speelt af op",
"local-playback": "Lokaal afspelen", "local-playback": "Lokaal afspelen",
"streaming": "Streamen" "streaming": "Streamen",
"total-duration": "Totale duur",
"similar-albums": "Vergelijkbare albums"
} }

View File

@@ -57,4 +57,6 @@ export type LocaleKeys = 'play-next'
| 'you-are-offline-message' | 'you-are-offline-message'
| 'playing-on' | 'playing-on'
| 'local-playback' | 'local-playback'
| 'streaming' | 'streaming'
| 'total-duration'
| 'similar-albums'

View File

@@ -1,7 +1,6 @@
import useDefaultStyles from 'components/Colors'; import useDefaultStyles from 'components/Colors';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { FlatListProps, View } from 'react-native'; import { FlatListProps, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
import formatBytes from 'utility/formatBytes'; import formatBytes from 'utility/formatBytes';
@@ -17,6 +16,7 @@ import { Text } from 'components/Typography';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
import { useGetImage } from 'utility/JellyfinApi'; import { useGetImage } from 'utility/JellyfinApi';
import { ShadowWrapper } from 'components/Shadow'; import { ShadowWrapper } from 'components/Shadow';
import { SafeFlatList } from 'components/SafeNavigatorView';
const DownloadedTrack = styled.View` const DownloadedTrack = styled.View`
flex: 1 0 auto; flex: 1 0 auto;
@@ -82,7 +82,7 @@ function Downloads() {
]} ]}
numberOfLines={1} numberOfLines={1}
> >
{t('total-download-size')}: {formatBytes(totalDownloadSize)} {t('total-download-size')}{': '}{formatBytes(totalDownloadSize)}
</Text> </Text>
<Button <Button
icon={TrashIcon} icon={TrashIcon}
@@ -151,10 +151,10 @@ function Downloads() {
return ( return (
<SafeAreaView style={{ flex: 1 }}> <SafeAreaView style={{ flex: 1 }}>
{ListHeaderComponent} {ListHeaderComponent}
<FlatList <SafeFlatList
data={ids} data={ids}
style={{ flex: 1, paddingTop: 12 }} style={{ flex: 1, paddingTop: 12 }}
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }} contentContainerStyle={{ flexGrow: 1 }}
renderItem={renderItem} renderItem={renderItem}
/> />
</SafeAreaView> </SafeAreaView>

View File

@@ -1,18 +1,20 @@
import React from 'react'; import React from 'react';
import { createStackNavigator } from '@react-navigation/stack'; import { createStackNavigator } from '@react-navigation/stack';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { MusicStackParams } from './types';
import Albums from './stacks/Albums';
import Album from './stacks/Album';
import RecentAlbums from './stacks/RecentAlbums';
import { THEME_COLOR } from 'CONSTANTS'; import { THEME_COLOR } from 'CONSTANTS';
import { t } from '@localisation'; import { t } from '@localisation';
import useDefaultStyles from 'components/Colors'; import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import Playlists from './stacks/Playlists'; import { StackParams } from 'screens/types';
import Playlist from './stacks/Playlist';
import NowPlaying from './overlays/NowPlaying'; import NowPlaying from './overlays/NowPlaying';
const Stack = createStackNavigator<MusicStackParams>(); import RecentAlbums from './stacks/RecentAlbums';
import Albums from './stacks/Albums';
import Album from './stacks/Album';
import Playlists from './stacks/Playlists';
import Playlist from './stacks/Playlist';
import { StyleSheet } from 'react-native';
const Stack = createStackNavigator<StackParams>();
function MusicStack() { function MusicStack() {
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
@@ -23,8 +25,10 @@ function MusicStack() {
headerTintColor: THEME_COLOR, headerTintColor: THEME_COLOR,
headerTitleStyle: defaultStyles.stackHeader, headerTitleStyle: defaultStyles.stackHeader,
cardStyle: defaultStyles.view, cardStyle: defaultStyles.view,
headerTransparent: true,
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
}}> }}>
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums') }} /> <Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums'), headerShown: false }} />
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} /> <Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} /> <Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
<Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} /> <Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />

View File

@@ -15,15 +15,15 @@ import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { calculateProgressTranslation } from 'components/Progresstrack'; import { calculateProgressTranslation } from 'components/Progresstrack';
import { THEME_COLOR } from 'CONSTANTS'; import { THEME_COLOR } from 'CONSTANTS';
import { MusicNavigationProp } from 'screens/Music/types'; import { NavigationProp } from 'screens/types';
import { ShadowWrapper } from 'components/Shadow'; import { ShadowWrapper } from 'components/Shadow';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
const NOW_PLAYING_POPOVER_MARGIN = 6; const NOW_PLAYING_POPOVER_MARGIN = 6;
const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN; const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN;
const PopoverPosition = css` const PopoverPosition = css`
position: absolute; position: absolute;
bottom: ${NOW_PLAYING_POPOVER_MARGIN}px;
left: ${NOW_PLAYING_POPOVER_MARGIN}px; left: ${NOW_PLAYING_POPOVER_MARGIN}px;
right: ${NOW_PLAYING_POPOVER_MARGIN}px; right: ${NOW_PLAYING_POPOVER_MARGIN}px;
border-radius: 8px; border-radius: 8px;
@@ -111,10 +111,11 @@ function NowPlaying() {
const { index, track } = useCurrentTrack(); const { index, track } = useCurrentTrack();
const { buffered, position } = useProgress(); const { buffered, position } = useProgress();
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
const tabBarHeight = useBottomTabBarHeight();
const previousBuffered = usePrevious(buffered); const previousBuffered = usePrevious(buffered);
const previousPosition = usePrevious(position); const previousPosition = usePrevious(position);
const navigation = useNavigation<MusicNavigationProp>(); const navigation = useNavigation<NavigationProp>();
const bufferAnimation = useRef(new Animated.Value(0)); const bufferAnimation = useRef(new Animated.Value(0));
const progressAnimation = useRef(new Animated.Value(0)); const progressAnimation = useRef(new Animated.Value(0));
@@ -164,7 +165,7 @@ function NowPlaying() {
} }
return ( return (
<Container> <Container style={{ bottom: tabBarHeight + NOW_PLAYING_POPOVER_MARGIN }}>
{/** TODO: Fix shadow overflow on Android */} {/** TODO: Fix shadow overflow on Android */}
{Platform.OS === 'ios' ? ( {Platform.OS === 'ios' ? (
<ShadowOverlay pointerEvents='none'> <ShadowOverlay pointerEvents='none'>

View File

@@ -1,14 +1,54 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { MusicStackParams } from '../types'; import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
import { useRoute, RouteProp } from '@react-navigation/native';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
import TrackListView from './components/TrackListView'; import TrackListView from './components/TrackListView';
import { fetchTracksByAlbum } from 'store/music/actions'; import { fetchAlbum, fetchTracksByAlbum } from 'store/music/actions';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS'; import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import { t } from '@localisation'; import { t } from '@localisation';
import { NavigationProp, StackParams } from 'screens/types';
import { SubHeader, Text } from 'components/Typography';
import { ScrollView } from 'react-native-gesture-handler';
import { useGetImage } from 'utility/JellyfinApi';
import styled from 'styled-components';
import { Dimensions, Pressable } from 'react-native';
import AlbumImage from './components/AlbumImage';
type Route = RouteProp<MusicStackParams, 'Album'>; type Route = RouteProp<StackParams, 'Album'>;
const Screen = Dimensions.get('screen');
const Cover = styled(AlbumImage)`
height: ${Screen.width / 2.8};
width: ${Screen.width / 2.8};
border-radius: 12px;
margin-bottom: 8px;
`;
function SimilarAlbum({ id }: { id: string }) {
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
const album = useTypedSelector((state) => state.music.albums.entities[id]);
const handlePress = useCallback(() => {
album && navigation.push('Album', { id, album });
}, [id, album, navigation]);
return (
<Pressable
style={({ pressed }) => ({
opacity: pressed ? 0.5 : 1.0,
width: Screen.width / 2.8,
marginRight: 12
})}
onPress={handlePress}
>
<Cover key={id} source={{ uri: getImage(id) }} />
<Text numberOfLines={1} style={{ fontSize: 13, marginBottom: 2 }}>{album?.Name}</Text>
<Text numberOfLines={1} style={{ opacity: 0.5, fontSize: 13 }}>{album?.Artists.join(', ')}</Text>
</Pressable>
);
}
const Album: React.FC = () => { const Album: React.FC = () => {
const { params: { id } } = useRoute<Route>(); const { params: { id } } = useRoute<Route>();
@@ -19,7 +59,10 @@ const Album: React.FC = () => {
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]); const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
// Define a function for refreshing this entity // Define a function for refreshing this entity
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]); const refresh = useCallback(() => {
dispatch(fetchTracksByAlbum(id));
dispatch(fetchAlbum(id));
}, [id, dispatch]);
// Auto-fetch the track data periodically // Auto-fetch the track data periodically
useEffect(() => { useEffect(() => {
@@ -39,7 +82,21 @@ const Album: React.FC = () => {
shuffleButtonText={t('shuffle-album')} shuffleButtonText={t('shuffle-album')}
downloadButtonText={t('download-album')} downloadButtonText={t('download-album')}
deleteButtonText={t('delete-album')} deleteButtonText={t('delete-album')}
/> >
{album?.Overview ? (
<Text style={{ opacity: 0.5, lineHeight: 20, fontSize: 12, paddingBottom: 24 }}>{album?.Overview}</Text>
) : null}
{album?.Similar?.length ? (
<>
<SubHeader>{t('similar-albums')}</SubHeader>
<ScrollView horizontal style={{ marginLeft: -24, marginRight: -24, marginTop: 8 }} contentContainerStyle={{ paddingHorizontal: 24 }} showsHorizontalScrollIndicator={false}>
{album.Similar.map((id) => (
<SimilarAlbum id={id} key={id} />
))}
</ScrollView>
</>
) : null}
</TrackListView>
); );
}; };

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react'; import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react';
import { useGetImage } from 'utility/JellyfinApi'; import { useGetImage } from 'utility/JellyfinApi';
import { MusicNavigationProp } from '../types'; import { SectionList, View } from 'react-native';
import { SafeAreaView, SectionList, View } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
@@ -17,6 +16,8 @@ import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { Album } from 'store/music/types'; import { Album } from 'store/music/types';
import { Text } from 'components/Typography'; import { Text } from 'components/Typography';
import { ShadowWrapper } from 'components/Shadow'; import { ShadowWrapper } from 'components/Shadow';
import { NavigationProp } from 'screens/types';
import { SafeSectionList } from 'components/SafeNavigatorView';
const HeadingHeight = 50; const HeadingHeight = 50;
@@ -87,7 +88,7 @@ const Albums: React.FC = () => {
// Initialise helpers // Initialise helpers
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigation = useNavigation<MusicNavigationProp>(); const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage(); const getImage = useGetImage();
const listRef = useRef<SectionList<EntityId[]>>(null); const listRef = useRef<SectionList<EntityId[]>>(null);
@@ -168,9 +169,9 @@ const Albums: React.FC = () => {
}); });
return ( return (
<SafeAreaView> <>
<AlphabetScroller onSelect={selectLetter} /> <AlphabetScroller onSelect={selectLetter} />
<SectionList <SafeSectionList
sections={sections} sections={sections}
refreshing={isLoading} refreshing={isLoading}
onRefresh={retrieveData} onRefresh={retrieveData}
@@ -180,7 +181,7 @@ const Albums: React.FC = () => {
renderSectionHeader={generateSection} renderSectionHeader={generateSection}
renderItem={generateItem} renderItem={generateItem}
/> />
</SafeAreaView> </>
); );
}; };

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { MusicStackParams } from '../types';
import { useRoute, RouteProp } from '@react-navigation/native'; import { useRoute, RouteProp } from '@react-navigation/native';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
import TrackListView from './components/TrackListView'; import TrackListView from './components/TrackListView';
@@ -7,8 +6,9 @@ import { fetchTracksByPlaylist } from 'store/music/actions';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS'; import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import { t } from '@localisation'; import { t } from '@localisation';
import { StackParams } from 'screens/types';
type Route = RouteProp<MusicStackParams, 'Album'>; type Route = RouteProp<StackParams, 'Album'>;
const Playlist: React.FC = () => { const Playlist: React.FC = () => {
const { params: { id } } = useRoute<Route>(); const { params: { id } } = useRoute<Route>();
@@ -39,6 +39,7 @@ const Playlist: React.FC = () => {
shuffleButtonText={t('shuffle-playlist')} shuffleButtonText={t('shuffle-playlist')}
downloadButtonText={t('download-playlist')} downloadButtonText={t('download-playlist')}
deleteButtonText={t('delete-playlist')} deleteButtonText={t('delete-playlist')}
itemDisplayStyle='playlist'
/> />
); );
}; };

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react'; import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi'; import { useGetImage } from 'utility/JellyfinApi';
import { MusicNavigationProp } from '../types'; import { Text, View, FlatList, ListRenderItem, RefreshControl } from 'react-native';
import { Text, View, FlatList, ListRenderItem } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
@@ -11,6 +10,8 @@ import TouchableHandler from 'components/TouchableHandler';
import AlbumImage, { AlbumItem } from './components/AlbumImage'; import AlbumImage, { AlbumItem } from './components/AlbumImage';
import { EntityId } from '@reduxjs/toolkit'; import { EntityId } from '@reduxjs/toolkit';
import useDefaultStyles from 'components/Colors'; import useDefaultStyles from 'components/Colors';
import { NavigationProp } from 'screens/types';
import { SafeFlatList, useNavigationOffsets } from 'components/SafeNavigatorView';
interface GeneratedAlbumItemProps { interface GeneratedAlbumItemProps {
id: ReactText; id: ReactText;
@@ -34,6 +35,8 @@ const GeneratedPlaylistItem = React.memo(function GeneratedPlaylistItem(props: G
}); });
const Playlists: React.FC = () => { const Playlists: React.FC = () => {
const offsets = useNavigationOffsets();
// Retrieve data from store // Retrieve data from store
const { entities, ids } = useTypedSelector((state) => state.music.playlists); const { entities, ids } = useTypedSelector((state) => state.music.playlists);
const isLoading = useTypedSelector((state) => state.music.playlists.isLoading); const isLoading = useTypedSelector((state) => state.music.playlists.isLoading);
@@ -41,7 +44,7 @@ const Playlists: React.FC = () => {
// Initialise helpers // Initialise helpers
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigation = useNavigation<MusicNavigationProp>(); const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage(); const getImage = useGetImage();
const listRef = useRef<FlatList<EntityId>>(null); const listRef = useRef<FlatList<EntityId>>(null);
@@ -93,10 +96,11 @@ const Playlists: React.FC = () => {
}); });
return ( return (
<FlatList <SafeFlatList
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={retrieveData} progressViewOffset={offsets.top} />
}
data={ids} data={ids}
refreshing={isLoading}
onRefresh={retrieveData}
getItemLayout={getItemLayout} getItemLayout={getItemLayout}
ref={listRef} ref={listRef}
keyExtractor={(item, index) => `${item}_${index}`} keyExtractor={(item, index) => `${item}_${index}`}

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useGetImage } from 'utility/JellyfinApi'; import { useGetImage } from 'utility/JellyfinApi';
import { MusicNavigationProp } from '../types'; import { Text, SafeAreaView, StyleSheet } from 'react-native';
import { Text, SafeAreaView, FlatList, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
import { fetchRecentAlbums } from 'store/music/actions'; import { fetchRecentAlbums } from 'store/music/actions';
@@ -17,6 +16,8 @@ import { Album } from 'store/music/types';
import Divider from 'components/Divider'; import Divider from 'components/Divider';
import styled from 'styled-components/native'; import styled from 'styled-components/native';
import { ShadowWrapper } from 'components/Shadow'; import { ShadowWrapper } from 'components/Shadow';
import { NavigationProp } from 'screens/types';
import { SafeFlatList } from 'components/SafeNavigatorView';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
columnWrapper: { columnWrapper: {
@@ -31,7 +32,7 @@ const HeaderContainer = styled.View`
`; `;
const NavigationHeader: React.FC = () => { const NavigationHeader: React.FC = () => {
const navigation = useNavigation<MusicNavigationProp>(); const navigation = useNavigation<NavigationProp>();
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]); const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]); const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]);
@@ -59,7 +60,7 @@ const RecentAlbums: React.FC = () => {
// Initialise helpers // Initialise helpers
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigation = useNavigation<MusicNavigationProp>(); const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage(); const getImage = useGetImage();
// Set callbacks // Set callbacks
@@ -71,7 +72,7 @@ const RecentAlbums: React.FC = () => {
return ( return (
<SafeAreaView> <SafeAreaView>
<FlatList <SafeFlatList
data={recentAlbums as string[]} data={recentAlbums as string[]}
refreshing={isLoading} refreshing={isLoading}
onRefresh={retrieveData} onRefresh={retrieveData}

View File

@@ -27,7 +27,7 @@ function AlbumImage(props: FastImageProps) {
if (!props.source || hasError) { if (!props.source || hasError) {
return ( return (
<Container source={colorScheme === 'light' ? require('assets/images/empty-album-light.png') : require('assets/images/empty-album-dark.png')} /> <Container {...props} source={colorScheme === 'light' ? require('assets/images/empty-album-light.png') : require('assets/images/empty-album-dark.png')} />
); );
} }

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from 'react'; import React, { PropsWithChildren, useCallback, useMemo } from 'react';
import { ScrollView, RefreshControl, StyleSheet, View } from 'react-native'; import { Platform, RefreshControl, StyleSheet, View } from 'react-native';
import { useGetImage } from 'utility/JellyfinApi'; import { useGetImage } from 'utility/JellyfinApi';
import styled, { css } from 'styled-components/native'; import styled, { css } from 'styled-components/native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@@ -14,7 +14,7 @@ import useDefaultStyles from 'components/Colors';
import usePlayTracks from 'utility/usePlayTracks'; import usePlayTracks from 'utility/usePlayTracks';
import { EntityId } from '@reduxjs/toolkit'; import { EntityId } from '@reduxjs/toolkit';
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow'; import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
import { MusicNavigationProp } from 'screens/Music/types'; import { NavigationProp } from 'screens/types';
import DownloadIcon from 'components/DownloadIcon'; import DownloadIcon from 'components/DownloadIcon';
import CloudDownArrow from 'assets/icons/cloud-down-arrow.svg'; import CloudDownArrow from 'assets/icons/cloud-down-arrow.svg';
import Trash from 'assets/icons/trash.svg'; import Trash from 'assets/icons/trash.svg';
@@ -25,6 +25,8 @@ import { Text } from 'components/Typography';
import CoverImage from 'components/CoverImage'; import CoverImage from 'components/CoverImage';
import ticksToDuration from 'utility/ticksToDuration'; import ticksToDuration from 'utility/ticksToDuration';
import { t } from '@localisation';
import { SafeScrollView, useNavigationOffsets } from 'components/SafeNavigatorView';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
index: { index: {
@@ -42,18 +44,23 @@ const AlbumImageContainer = styled.View`
align-items: center; align-items: center;
`; `;
const TrackContainer = styled.View<{ isPlaying: boolean }>` const TrackContainer = styled.View<{ isPlaying: boolean, small?: boolean }>`
padding: 12px 4px; padding: 12px 4px;
flex-direction: row; flex-direction: row;
border-radius: 6px; border-radius: 6px;
align-items: flex-start;
${props => props.isPlaying && css` ${props => props.isPlaying && css`
margin: 0 -12px; margin: 0 -12px;
padding: 12px 16px; padding: 12px 16px;
`} `}
${props => props.small && css`
padding: ${Platform.select({ ios: '8px 4px', android: '4px'})};
`}
`; `;
interface TrackListViewProps { export interface TrackListViewProps extends PropsWithChildren<{}> {
title?: string; title?: string;
artist?: string; artist?: string;
trackIds: EntityId[]; trackIds: EntityId[];
@@ -64,6 +71,7 @@ interface TrackListViewProps {
downloadButtonText: string; downloadButtonText: string;
deleteButtonText: string; deleteButtonText: string;
listNumberingStyle?: 'album' | 'index'; listNumberingStyle?: 'album' | 'index';
itemDisplayStyle?: 'album' | 'playlist';
} }
const TrackListView: React.FC<TrackListViewProps> = ({ const TrackListView: React.FC<TrackListViewProps> = ({
@@ -77,19 +85,27 @@ const TrackListView: React.FC<TrackListViewProps> = ({
downloadButtonText, downloadButtonText,
deleteButtonText, deleteButtonText,
listNumberingStyle = 'album', listNumberingStyle = 'album',
itemDisplayStyle = 'album',
children
}) => { }) => {
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
const offsets = useNavigationOffsets();
// Retrieve state // Retrieve state
const tracks = useTypedSelector((state) => state.music.tracks.entities); const tracks = useTypedSelector((state) => state.music.tracks.entities);
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading); const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds)); const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
const totalDuration = useMemo(() => (
trackIds.reduce<number>((sum, trackId) => (
sum + (tracks[trackId]?.RunTimeTicks || 0)
), 0)
), [trackIds, tracks]);
// Retrieve helpers // Retrieve helpers
const getImage = useGetImage(); const getImage = useGetImage();
const playTracks = usePlayTracks(); const playTracks = usePlayTracks();
const { track: currentTrack } = useCurrentTrack(); const { track: currentTrack } = useCurrentTrack();
const navigation = useNavigation<MusicNavigationProp>(); const navigation = useNavigation<NavigationProp>();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
// Setup callbacks // Setup callbacks
@@ -101,7 +117,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
await TrackPlayer.play(); await TrackPlayer.play();
}, [playTracks, trackIds]); }, [playTracks, trackIds]);
const longPressTrack = useCallback((index: number) => { const longPressTrack = useCallback((index: number) => {
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] }); navigation.navigate('TrackPopupMenu', { trackId: trackIds[index].toString() });
}, [navigation, trackIds]); }, [navigation, trackIds]);
const downloadAllTracks = useCallback(() => { const downloadAllTracks = useCallback(() => {
trackIds.forEach((trackId) => dispatch(queueTrackForDownload(trackId))); trackIds.forEach((trackId) => dispatch(queueTrackForDownload(trackId)));
@@ -111,90 +127,109 @@ const TrackListView: React.FC<TrackListViewProps> = ({
}, [dispatch, downloadedTracks]); }, [dispatch, downloadedTracks]);
return ( return (
<ScrollView <SafeScrollView
style={defaultStyles.view} style={defaultStyles.view}
contentContainerStyle={{ padding: 24, paddingTop: 32, paddingBottom: 64 }}
refreshControl={ refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refresh} /> <RefreshControl refreshing={isLoading} onRefresh={refresh} progressViewOffset={offsets.top} />
} }
> >
<AlbumImageContainer> <View style={{ padding: 24, paddingTop: 32, paddingBottom: 32 }}>
<CoverImage src={getImage(entityId)} /> <AlbumImageContainer>
</AlbumImageContainer> <CoverImage src={getImage(entityId)} />
<Header>{title}</Header> </AlbumImageContainer>
<SubHeader>{artist}</SubHeader> <Header>{title}</Header>
<WrappableButtonRow> <SubHeader>{artist}</SubHeader>
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} testID="play-album" /> <WrappableButtonRow>
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} testID="shuffle-album" /> <WrappableButton title={playButtonText} icon={Play} onPress={playEntity} testID="play-album" />
</WrappableButtonRow> <WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} testID="shuffle-album" />
<View style={{ marginTop: 8 }}> </WrappableButtonRow>
{trackIds.map((trackId, i) => <View style={{ marginTop: 8 }}>
<TouchableHandler {trackIds.map((trackId, i) =>
key={trackId} <TouchableHandler
id={i} key={trackId}
onPress={selectTrack} id={i}
onLongPress={longPressTrack} onPress={selectTrack}
testID={`play-track-${trackId}`} onLongPress={longPressTrack}
> testID={`play-track-${trackId}`}
<TrackContainer
isPlaying={currentTrack?.backendId === trackId || false}
style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
> >
<Text <TrackContainer
style={[ isPlaying={currentTrack?.backendId === trackId || false}
styles.index, style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
{ opacity: 0.25 }, small={itemDisplayStyle === 'playlist'}
currentTrack?.backendId === trackId && styles.activeText
]}
numberOfLines={1}
> >
{listNumberingStyle === 'index'
? i + 1
: tracks[trackId]?.IndexNumber}
</Text>
<Text
style={{
...currentTrack?.backendId === trackId && styles.activeText,
flexShrink: 1,
marginRight: 4,
}}
numberOfLines={1}
>
{tracks[trackId]?.Name}
</Text>
<View style={{ marginLeft: 'auto', flexDirection: 'row' }}>
<Text <Text
style={[ style={[
{ marginRight: 12, opacity: 0.25 }, styles.index,
{ opacity: 0.25 },
currentTrack?.backendId === trackId && styles.activeText currentTrack?.backendId === trackId && styles.activeText
]} ]}
numberOfLines={1} numberOfLines={1}
> >
{ticksToDuration(tracks[trackId]?.RunTimeTicks || 0)} {listNumberingStyle === 'index'
? i + 1
: tracks[trackId]?.IndexNumber}
</Text> </Text>
<DownloadIcon trackId={trackId} fill={currentTrack?.backendId === trackId ? `${THEME_COLOR}44` : undefined} /> <View style={{ flexShrink: 1 }}>
</View> <Text
</TrackContainer> style={{
</TouchableHandler> ...currentTrack?.backendId === trackId && styles.activeText,
)} flexShrink: 1,
<WrappableButtonRow style={{ marginTop: 24 }}> marginRight: 4,
<WrappableButton }}
icon={CloudDownArrow} numberOfLines={1}
title={downloadButtonText} >
onPress={downloadAllTracks} {tracks[trackId]?.Name}
disabled={downloadedTracks.length === trackIds.length} </Text>
testID="download-album" {itemDisplayStyle === 'playlist' && (
/> <Text
<WrappableButton style={{
icon={Trash} ...currentTrack?.backendId === trackId && styles.activeText,
title={deleteButtonText} flexShrink: 1,
onPress={deleteAllTracks} marginRight: 4,
disabled={downloadedTracks.length === 0} opacity:currentTrack?.backendId === trackId ? 0.5 : 0.25,
testID="delete-album" }}
/> numberOfLines={1}
</WrappableButtonRow> >
{tracks[trackId]?.Artists.join(', ')}
</Text>
)}
</View>
<View style={{ marginLeft: 'auto', flexDirection: 'row' }}>
<Text
style={[
{ marginRight: 12, opacity: 0.25 },
currentTrack?.backendId === trackId && styles.activeText
]}
numberOfLines={1}
>
{ticksToDuration(tracks[trackId]?.RunTimeTicks || 0)}
</Text>
<DownloadIcon trackId={trackId} fill={currentTrack?.backendId === trackId ? `${THEME_COLOR}44` : undefined} />
</View>
</TrackContainer>
</TouchableHandler>
)}
<Text style={{ paddingTop: 24, paddingBottom: 12, textAlign: 'center', opacity: 0.5 }}>{t('total-duration')}{': '}{ticksToDuration(totalDuration)}</Text>
<WrappableButtonRow style={{ marginTop: 24 }}>
<WrappableButton
icon={CloudDownArrow}
title={downloadButtonText}
onPress={downloadAllTracks}
disabled={downloadedTracks.length === trackIds.length}
testID="download-album"
/>
<WrappableButton
icon={Trash}
title={deleteButtonText}
onPress={deleteAllTracks}
disabled={downloadedTracks.length === 0}
testID="delete-album"
/>
</WrappableButtonRow>
</View>
{children}
</View> </View>
</ScrollView> </SafeScrollView>
); );
}; };

View File

@@ -1,328 +1,31 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React from 'react';
import Input from 'components/Input'; import { createStackNavigator } from '@react-navigation/stack';
import { ActivityIndicator, Animated, SafeAreaView, View } from 'react-native'; import { THEME_COLOR } from 'CONSTANTS';
import styled from 'styled-components/native';
import { useAppDispatch, useTypedSelector } from 'store';
import Fuse from 'fuse.js';
import { Album, AlbumTrack } from 'store/music/types';
import { FlatList } from 'react-native-gesture-handler';
import TouchableHandler from 'components/TouchableHandler';
import { useNavigation } from '@react-navigation/native';
import { useGetImage } from 'utility/JellyfinApi';
import FastImage from 'react-native-fast-image';
import { t } from '@localisation'; import { t } from '@localisation';
import useDefaultStyles from 'components/Colors'; import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { searchAndFetchAlbums } from 'store/music/actions'; import { StackParams } from 'screens/types';
import { debounce } from 'lodash'; import Search from './stacks/Search';
import { Text } from 'components/Typography'; import Album from 'screens/Music/stacks/Album';
import { MusicNavigationProp } from 'screens/Music/types'; import { StyleSheet } from 'react-native';
import DownloadIcon from 'components/DownloadIcon';
import ChevronRight from 'assets/icons/chevron-right.svg';
import SearchIcon from 'assets/icons/magnifying-glass.svg';
import { ShadowWrapper } from 'components/Shadow';
import { useKeyboardHeight } from 'utility/useKeyboardHeight';
// 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';
const Container = styled(Animated.View)` const Stack = createStackNavigator<StackParams>();
padding: 4px 32px 0 32px;
margin-bottom: 0px;
padding-bottom: 0px;
border-top-width: 0.5px;
`;
const FullSizeContainer = styled.View` function SearchStack() {
flex: 1;
`;
const Loading = styled.View`
position: absolute;
right: 12px;
top: 0;
height: 100%;
flex: 1;
justify-content: center;
`;
const AlbumImage = styled(FastImage)`
border-radius: 4px;
width: 32px;
height: 32px;
margin-right: 10px;
`;
const HalfOpacity = styled.Text`
opacity: 0.5;
margin-top: 2px;
font-size: 12px;
flex: 1 1 auto;
`;
const SearchResult = styled.View`
flex-direction: row;
align-items: center;
padding: 8px 32px;
height: 54px;
`;
const fuseOptions: Fuse.IFuseOptions<Album> = {
keys: ['Name', 'AlbumArtist', 'AlbumArtists', 'Artists'],
threshold: 0.1,
includeScore: true,
fieldNormWeight: 1,
};
type AudioResult = {
type: 'Audio',
id: string;
album: string;
name: string;
};
type AlbumResult = {
type: 'AlbumArtist',
id: string;
album: undefined;
name: undefined;
}
type CombinedResults = (AudioResult | AlbumResult)[];
export default function Search() {
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
// Prepare state for fuse and albums
const [fuseIsReady, setFuseReady] = useState(false);
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 fuse = useRef<Fuse<Album>>();
// Prepare helpers
const navigation = useNavigation<MusicNavigationProp>();
const keyboardHeight = useKeyboardHeight();
const getImage = useGetImage();
const dispatch = useAppDispatch();
/**
* 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
* Fuse instance every time the albums change. This can of course be done
* more intelligently by removing and adding the changed albums, but this is
* an open todo.
*/
useEffect(() => {
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
setFuseReady(true);
}, [albums, setFuseReady]);
/**
* 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]);
/**
* Whenever the search term changes, we gather results from Fuse and assign
* them to state
*/
useEffect(() => {
if (!searchTerm) {
return;
}
const retrieveResults = async () => {
// GUARD: In some extraordinary cases, Fuse might not be presented since
// it is assigned via refs. In this case, we can't handle any searching.
if (!fuse.current) {
return;
}
// First set the immediate results from fuse
const fuseResults = fuse.current.search(searchTerm);
const albums: AlbumResult[] = fuseResults
.map(({ item }) => ({
id: item.Id,
type: 'AlbumArtist',
album: undefined,
name: undefined,
}));
// Assign the preliminary results
setFuseResults(albums);
setLoading(true);
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);
} catch {
// Reset the loading indicator if the network fails
setLoading(false);
}
};
retrieveResults();
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
// Handlers
const selectAlbum = useCallback((id: string) =>
navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]
);
const HeaderComponent = React.useMemo(() => (
<View>
<Container style={[
defaultStyles.border,
defaultStyles.view,
{ transform: [{ translateY: keyboardHeight }]},
]}>
<View>
<Input
value={searchTerm}
onChangeText={setSearchTerm}
style={[defaultStyles.input, { marginBottom: 12 }]}
placeholder={t('search') + '...'}
icon={<SearchIcon width={14} height={14} fill={defaultStyles.textHalfOpacity.color} />}
testID="search-input"
/>
{isLoading && <Loading><ActivityIndicator /></Loading>}
</View>
</Container>
{/* <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={{ paddingHorizontal: 32, paddingBottom: 12, flex: 1, flexDirection: 'row' }}>
<SelectableFilter
text="Artists"
icon={MicrophoneIcon}
active
/>
<SelectableFilter
text="Albums"
icon={AlbumIcon}
active={false}
/>
<SelectableFilter
text="Tracks"
icon={TrackIcon}
active={false}
/>
<SelectableFilter
text="Playlist"
icon={PlaylistIcon}
active={false}
/>
<SelectableFilter
text="Streaming"
icon={StreamIcon}
active={false}
/>
<SelectableFilter
text="Local Playback"
icon={LocalIcon}
active={false}
/>
</View>
</ScrollView> */}
</View>
), [searchTerm, setSearchTerm, defaultStyles, isLoading, keyboardHeight]);
// GUARD: We cannot search for stuff unless Fuse is loaded with results.
// Therefore we delay rendering to when we are certain it's there.
if (!fuseIsReady) {
return null;
}
return ( return (
<SafeAreaView style={{ flex: 1 }}> <Stack.Navigator initialRouteName="Search" screenOptions={{
<FlatList headerTintColor: THEME_COLOR,
style={{ flex: 2 }} headerTitleStyle: defaultStyles.stackHeader,
data={[...jellyfinResults, ...fuseResults]} cardStyle: defaultStyles.view,
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => { headerTransparent: true,
const album = albums[trackAlbum || id]; headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
// GUARD: If the album cannot be found in the store, we }}>
// cannot display it. <Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search'), headerShown: false }} />
if (!album) { <Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
return null; </Stack.Navigator>
}
return (
<TouchableHandler<string> id={album.Id} onPress={selectAlbum} testID={`search-result-${album.Id}`}>
<SearchResult>
<ShadowWrapper>
<AlbumImage source={{ uri: getImage(album.Id) }} style={defaultStyles.imageBackground} />
</ShadowWrapper>
<View style={{ flex: 1 }}>
<Text numberOfLines={1}>
{trackName || album.Name}
</Text>
{(album.AlbumArtist || album.Name) && (
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
{type === 'AlbumArtist'
? `${t('album')}${album.AlbumArtist}`
: `${t('track')}${album.AlbumArtist}${album.Name}`
}
</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>
</TouchableHandler>
);
}}
keyExtractor={(item) => item.id}
extraData={[searchTerm, albums]}
/>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
<FullSizeContainer>
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
</FullSizeContainer>
) : null}
{HeaderComponent}
</SafeAreaView>
); );
} }
export default SearchStack;

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Input from 'components/Input';
import { ActivityIndicator, Animated, SafeAreaView, View } from 'react-native';
import styled from 'styled-components/native';
import { useAppDispatch, useTypedSelector } from 'store';
import Fuse from 'fuse.js';
import { Album, AlbumTrack } from 'store/music/types';
import { FlatList } from 'react-native-gesture-handler';
import TouchableHandler from 'components/TouchableHandler';
import { useNavigation } from '@react-navigation/native';
import { useGetImage } from 'utility/JellyfinApi';
import FastImage from 'react-native-fast-image';
import { t } from '@localisation';
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { searchAndFetchAlbums } from 'store/music/actions';
import { debounce } from 'lodash';
import { Text } from 'components/Typography';
import DownloadIcon from 'components/DownloadIcon';
import ChevronRight from 'assets/icons/chevron-right.svg';
import SearchIcon from 'assets/icons/magnifying-glass.svg';
import { ShadowWrapper } from 'components/Shadow';
import { useKeyboardHeight } from 'utility/useKeyboardHeight';
import { NavigationProp } from 'screens/types';
import { useNavigationOffsets } from 'components/SafeNavigatorView';
// 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';
const SEARCH_INPUT_HEIGHT = 62;
const Container = styled(View)`
padding: 4px 24px 0 24px;
margin-bottom: 0px;
padding-bottom: 0px;
border-top-width: 0.5px;
`;
const FullSizeContainer = styled.View`
flex: 1;
`;
const Loading = styled.View`
position: absolute;
right: 12px;
top: 0;
height: 100%;
flex: 1;
justify-content: center;
`;
const AlbumImage = styled(FastImage)`
border-radius: 4px;
width: 32px;
height: 32px;
margin-right: 10px;
`;
const HalfOpacity = styled.Text`
opacity: 0.5;
margin-top: 2px;
font-size: 12px;
flex: 1 1 auto;
`;
const SearchResult = styled.View`
flex-direction: row;
align-items: center;
padding: 8px 32px;
height: 54px;
`;
const fuseOptions: Fuse.IFuseOptions<Album> = {
keys: ['Name', 'AlbumArtist', 'AlbumArtists', 'Artists'],
threshold: 0.1,
includeScore: true,
fieldNormWeight: 1,
};
type AudioResult = {
type: 'Audio',
id: string;
album: string;
name: string;
};
type AlbumResult = {
type: 'AlbumArtist',
id: string;
album: undefined;
name: undefined;
}
type CombinedResults = (AudioResult | AlbumResult)[];
export default function Search() {
const defaultStyles = useDefaultStyles();
const offsets = useNavigationOffsets({ includeOverlay: false });
// Prepare state for fuse and albums
const [fuseIsReady, setFuseReady] = useState(false);
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 fuse = useRef<Fuse<Album>>();
// Prepare helpers
const navigation = useNavigation<NavigationProp>();
const keyboardHeight = useKeyboardHeight();
const getImage = useGetImage();
const dispatch = useAppDispatch();
/**
* 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
* Fuse instance every time the albums change. This can of course be done
* more intelligently by removing and adding the changed albums, but this is
* an open todo.
*/
useEffect(() => {
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
setFuseReady(true);
}, [albums, setFuseReady]);
/**
* 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]);
/**
* Whenever the search term changes, we gather results from Fuse and assign
* them to state
*/
useEffect(() => {
if (!searchTerm) {
return;
}
const retrieveResults = async () => {
// GUARD: In some extraordinary cases, Fuse might not be presented since
// it is assigned via refs. In this case, we can't handle any searching.
if (!fuse.current) {
return;
}
// First set the immediate results from fuse
const fuseResults = fuse.current.search(searchTerm);
const albums: AlbumResult[] = fuseResults
.map(({ item }) => ({
id: item.Id,
type: 'AlbumArtist',
album: undefined,
name: undefined,
}));
// Assign the preliminary results
setFuseResults(albums);
setLoading(true);
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);
} catch {
// Reset the loading indicator if the network fails
setLoading(false);
}
};
retrieveResults();
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
// Handlers
const selectAlbum = useCallback((id: string) => {
navigation.navigate('Album', { id, album: albums[id] as Album });
}, [navigation, albums]);
const SearchInput = React.useMemo(() => (
<Animated.View style={[
{ position: 'absolute', bottom: offsets.bottom, right: 0, left: 0 },
{ transform: [{ translateY: keyboardHeight }] },
]}>
<ColoredBlurView>
<Container style={[ defaultStyles.border ]}>
<View>
<Input
value={searchTerm}
onChangeText={setSearchTerm}
style={[defaultStyles.view, { marginBottom: 12 }]}
placeholder={t('search') + '...'}
icon={<SearchIcon width={14} height={14} fill={defaultStyles.textHalfOpacity.color} />}
testID="search-input"
/>
{isLoading && <Loading style={{ marginTop: -4 }}><ActivityIndicator /></Loading>}
</View>
</Container>
{/* <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={{ paddingHorizontal: 32, paddingBottom: 12, flex: 1, flexDirection: 'row' }}>
<SelectableFilter
text="Artists"
icon={MicrophoneIcon}
active
/>
<SelectableFilter
text="Albums"
icon={AlbumIcon}
active={false}
/>
<SelectableFilter
text="Tracks"
icon={TrackIcon}
active={false}
/>
<SelectableFilter
text="Playlist"
icon={PlaylistIcon}
active={false}
/>
<SelectableFilter
text="Streaming"
icon={StreamIcon}
active={false}
/>
<SelectableFilter
text="Local Playback"
icon={LocalIcon}
active={false}
/>
</View>
</ScrollView> */}
</ColoredBlurView>
</Animated.View>
), [searchTerm, setSearchTerm, defaultStyles, isLoading, keyboardHeight, offsets]);
// GUARD: We cannot search for stuff unless Fuse is loaded with results.
// Therefore we delay rendering to when we are certain it's there.
if (!fuseIsReady) {
return null;
}
return (
<SafeAreaView style={{ flex: 1 }}>
<FlatList
style={{ flex: 2, }}
contentContainerStyle={{ paddingTop: offsets.top, paddingBottom: offsets.bottom + SEARCH_INPUT_HEIGHT }}
scrollIndicatorInsets={{ top: offsets.top / 2, bottom: offsets.bottom / 2 + 10 + SEARCH_INPUT_HEIGHT }}
data={[...jellyfinResults, ...fuseResults]}
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
const album = albums[trackAlbum || id];
// GUARD: If the album cannot be found in the store, we
// cannot display it.
if (!album) {
return null;
}
return (
<TouchableHandler<string> id={album.Id} onPress={selectAlbum} testID={`search-result-${album.Id}`}>
<SearchResult>
<ShadowWrapper>
<AlbumImage source={{ uri: getImage(album.Id) }} style={defaultStyles.imageBackground} />
</ShadowWrapper>
<View style={{ flex: 1 }}>
<Text numberOfLines={1}>
{trackName || album.Name}
</Text>
{(album.AlbumArtist || album.Name) && (
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
{type === 'AlbumArtist'
? `${t('album')}${album.AlbumArtist}`
: `${t('track')}${album.AlbumArtist}${album.Name}`
}
</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>
</TouchableHandler>
);
}}
keyExtractor={(item) => item.id}
extraData={[searchTerm, albums]}
/>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
<FullSizeContainer>
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
</FullSizeContainer>
) : null}
{SearchInput}
</SafeAreaView>
);
}

View File

@@ -4,15 +4,15 @@ import music from 'store/music';
import { t } from '@localisation'; import { t } from '@localisation';
import Button from 'components/Button'; import Button from 'components/Button';
import styled from 'styled-components/native'; import styled from 'styled-components/native';
import { Text } from 'components/Typography'; import { Paragraph } from 'components/Typography';
import { useAppDispatch } from 'store'; import { useAppDispatch } from 'store';
import { SafeScrollView } from 'components/SafeNavigatorView';
const ClearCache = styled(Button)` const ClearCache = styled(Button)`
margin-top: 16px; margin-top: 16px;
`; `;
const Container = styled.ScrollView` const Container = styled(SafeScrollView)`
padding: 24px; padding: 24px;
`; `;
@@ -28,7 +28,7 @@ export default function CacheSettings() {
return ( return (
<Container> <Container>
<Text>{t('setting-cache-description')}</Text> <Paragraph>{t('setting-cache-description')}</Paragraph>
<ClearCache title={t('reset-cache')} onPress={handleClearCache} /> <ClearCache title={t('reset-cache')} onPress={handleClearCache} />
</Container> </Container>
); );

View File

@@ -6,8 +6,8 @@ import { NavigationProp } from '../..';
import { useTypedSelector } from 'store'; import { useTypedSelector } from 'store';
import { t } from '@localisation'; import { t } from '@localisation';
import Button from 'components/Button'; import Button from 'components/Button';
import { Text } from 'components/Typography'; import { Paragraph } from 'components/Typography';
import { SafeScrollView } from 'components/SafeNavigatorView';
const InputContainer = styled.View` const InputContainer = styled.View`
margin: 10px 0; margin: 10px 0;
@@ -19,7 +19,7 @@ const Input = styled.TextInput`
border-radius: 5px; border-radius: 5px;
`; `;
const Container = styled.ScrollView` const Container = styled(SafeScrollView)`
padding: 24px; padding: 24px;
`; `;
@@ -32,15 +32,15 @@ export default function LibrarySettings() {
return ( return (
<Container> <Container>
<InputContainer> <InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-server-url')}</Text> <Paragraph style={defaultStyles.text}>{t('jellyfin-server-url')}</Paragraph>
<Input placeholder="https://jellyfin.yourserver.com/" value={jellyfin?.uri} editable={false} style={defaultStyles.input} /> <Input placeholder="https://jellyfin.yourserver.com/" value={jellyfin?.uri} editable={false} style={defaultStyles.input} />
</InputContainer> </InputContainer>
<InputContainer> <InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-access-token')}</Text> <Paragraph style={defaultStyles.text}>{t('jellyfin-access-token')}</Paragraph>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={defaultStyles.input} /> <Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={defaultStyles.input} />
</InputContainer> </InputContainer>
<InputContainer> <InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-user-id')}</Text> <Paragraph style={defaultStyles.text}>{t('jellyfin-user-id')}</Paragraph>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={defaultStyles.input} /> <Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={defaultStyles.input} />
</InputContainer> </InputContainer>
<Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} /> <Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} />

View File

@@ -1,4 +1,4 @@
import { Text } from 'components/Typography'; import { Paragraph, Text } from 'components/Typography';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Switch } from 'react-native-gesture-handler'; import { Switch } from 'react-native-gesture-handler';
@@ -9,8 +9,9 @@ import ChevronIcon from 'assets/icons/chevron-right.svg';
import { THEME_COLOR } from 'CONSTANTS'; import { THEME_COLOR } from 'CONSTANTS';
import useDefaultStyles, { DefaultStylesProvider } from 'components/Colors'; import useDefaultStyles, { DefaultStylesProvider } from 'components/Colors';
import { t } from '@localisation'; import { t } from '@localisation';
import { SafeScrollView } from 'components/SafeNavigatorView';
const Container = styled.ScrollView` const Container = styled.View`
padding: 24px; padding: 24px;
`; `;
@@ -25,7 +26,9 @@ const HeaderContainer = styled.View<{ isActive?: boolean }>`
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin: 16px 0 4px 0; padding: 16px 24px;
border-radius: 8px;
overflow: hidden;
${props => props.isActive && css` ${props => props.isActive && css`
background-color: ${THEME_COLOR}; background-color: ${THEME_COLOR};
@@ -37,7 +40,8 @@ const HeaderText = styled(Text)`
`; `;
const ContentContainer = styled.View` const ContentContainer = styled.View`
margin-top: 8px; margin-bottom: 8px;
padding: 8px 24px;
`; `;
const Label = styled(Text)` const Label = styled(Text)`
@@ -87,7 +91,7 @@ function renderHeader(question: Question, index: number, isActive: boolean) {
function renderContent(question: Question) { function renderContent(question: Question) {
return ( return (
<ContentContainer> <ContentContainer>
<Text>{question.content}</Text> <Paragraph>{question.content}</Paragraph>
</ContentContainer> </ContentContainer>
); );
} }
@@ -104,14 +108,17 @@ export default function Sentry() {
}); });
return ( return (
<Container> <SafeScrollView>
<Text>{t('error-reporting-description')}</Text> <Container>
<Text /> <Paragraph>{t('error-reporting-description')}</Paragraph>
<Text>{t('error-reporting-rationale')}</Text> <Paragraph />
<SwitchContainer> <Paragraph>{t('error-reporting-rationale')}</Paragraph>
<Label>{t('error-reporting')}</Label>
<Switch value={isReportingEnabled} onValueChange={toggleSwitch} /> <SwitchContainer>
</SwitchContainer> <Label>{t('error-reporting')}</Label>
<Switch value={isReportingEnabled} onValueChange={toggleSwitch} />
</SwitchContainer>
</Container>
<Accordion <Accordion
sections={questions} sections={questions}
renderHeader={renderHeader} renderHeader={renderHeader}
@@ -120,6 +127,6 @@ export default function Sentry() {
onChange={setActiveSections} onChange={setActiveSections}
underlayColor={defaultStyles.activeBackground.backgroundColor} underlayColor={defaultStyles.activeBackground.backgroundColor}
/> />
</Container> </SafeScrollView>
); );
} }

View File

@@ -1,8 +1,8 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { SafeAreaView, ScrollView } from 'react-native'; import { StyleSheet } from 'react-native';
import Library from './components/Library'; import Library from './components/Library';
import Cache from './components/Cache'; import Cache from './components/Cache';
import useDefaultStyles from 'components/Colors'; import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { t } from '@localisation'; import { t } from '@localisation';
import { createStackNavigator } from '@react-navigation/stack'; import { createStackNavigator } from '@react-navigation/stack';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@@ -10,6 +10,7 @@ import ListButton from 'components/ListButton';
import { THEME_COLOR } from 'CONSTANTS'; import { THEME_COLOR } from 'CONSTANTS';
import Sentry from './components/Sentry'; import Sentry from './components/Sentry';
import { SettingsNavigationProp } from './types'; import { SettingsNavigationProp } from './types';
import { SafeScrollView } from 'components/SafeNavigatorView';
export function SettingsList() { export function SettingsList() {
const navigation = useNavigation<SettingsNavigationProp>(); const navigation = useNavigation<SettingsNavigationProp>();
@@ -18,13 +19,11 @@ export function SettingsList() {
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]); const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);
return ( return (
<ScrollView> <SafeScrollView>
<SafeAreaView> <ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton> <ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton> <ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton> </SafeScrollView>
</SafeAreaView>
</ScrollView>
); );
} }
@@ -34,9 +33,11 @@ export default function Settings() {
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
return ( return (
<Stack.Navigator initialRouteName="SettingList" screenOptions={{ <Stack.Navigator initialRouteName="SettingList" screenOptions={{
headerTintColor: THEME_COLOR, headerTintColor: THEME_COLOR,
headerTitleStyle: defaultStyles.stackHeader headerTitleStyle: defaultStyles.stackHeader,
headerTransparent: true,
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
}}> }}>
<Stack.Screen name="SettingList" component={SettingsList} options={{ headerTitle: t('settings') }} /> <Stack.Screen name="SettingList" component={SettingsList} options={{ headerTitle: t('settings') }} />
<Stack.Screen name="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} /> <Stack.Screen name="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} />

View File

@@ -5,7 +5,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { CompositeNavigationProp } from '@react-navigation/native'; import { CompositeNavigationProp } from '@react-navigation/native';
import { THEME_COLOR } from 'CONSTANTS'; import { THEME_COLOR } from 'CONSTANTS';
import Search from './Search'; import SearchStack from './Search';
import Music from './Music'; import Music from './Music';
import Settings from './Settings'; import Settings from './Settings';
import Downloads from './Downloads'; import Downloads from './Downloads';
@@ -18,13 +18,15 @@ import NotesIcon from 'assets/icons/notes.svg';
import GearIcon from 'assets/icons/gear.svg'; import GearIcon from 'assets/icons/gear.svg';
import DownloadsIcon from 'assets/icons/arrow-down-to-line.svg'; import DownloadsIcon from 'assets/icons/arrow-down-to-line.svg';
import { useTypedSelector } from 'store'; import { useTypedSelector } from 'store';
import { ModalStackParams } from './types';
import { t } from '@localisation'; import { t } from '@localisation';
import ErrorReportingAlert from 'utility/ErrorReportingAlert'; import ErrorReportingAlert from 'utility/ErrorReportingAlert';
import ErrorReportingPopup from './modals/ErrorReportingPopup'; import ErrorReportingPopup from './modals/ErrorReportingPopup';
import Player from './modals/Player'; import Player from './modals/Player';
import { StyleSheet } from 'react-native';
import { ColoredBlurView } from 'components/Colors';
import { StackParams } from './types';
const Stack = createNativeStackNavigator<ModalStackParams>(); const Stack = createNativeStackNavigator<StackParams>();
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
type Screens = { type Screens = {
@@ -48,9 +50,9 @@ function Screens() {
screenOptions={({ route }) => ({ screenOptions={({ route }) => ({
tabBarIcon: function TabBarIcon({ color, size }) { tabBarIcon: function TabBarIcon({ color, size }) {
switch (route.name) { switch (route.name) {
case 'Search': case 'SearchTab':
return <SearchIcon fill={color} height={size - 4} width={size - 4} />; return <SearchIcon fill={color} height={size - 4} width={size - 4} />;
case 'Music': case 'MusicTab':
return <NotesIcon fill={color} height={size} width={size} />; return <NotesIcon fill={color} height={size} width={size} />;
case 'Settings': case 'Settings':
return <GearIcon fill={color} height={size - 1} width={size - 1} />; return <GearIcon fill={color} height={size - 1} width={size - 1} />;
@@ -63,10 +65,15 @@ function Screens() {
tabBarActiveTintColor: THEME_COLOR, tabBarActiveTintColor: THEME_COLOR,
tabBarInactiveTintColor: 'gray', tabBarInactiveTintColor: 'gray',
headerShown: false, headerShown: false,
tabBarShowLabel: false,
tabBarStyle: { position: 'absolute' },
tabBarBackground: () => (
<ColoredBlurView style={StyleSheet.absoluteFill} />
)
})} })}
> >
<Tab.Screen name="Music" component={Music} options={{ tabBarLabel: t('music'), tabBarTestID: 'music-tab' }} /> <Tab.Screen name="MusicTab" component={Music} options={{ tabBarLabel: t('music'), tabBarTestID: 'music-tab' }} />
<Tab.Screen name="Search" component={Search} options={{ tabBarLabel: t('search'), tabBarTestID: 'search-tab' }} /> <Tab.Screen name="SearchTab" component={SearchStack} options={{ tabBarLabel: t('search'), tabBarTestID: 'search-tab' }} />
<Tab.Screen name="Downloads" component={Downloads} options={{ tabBarLabel: t('downloads'), tabBarTestID: 'downloads-tab'}} /> <Tab.Screen name="Downloads" component={Downloads} options={{ tabBarLabel: t('downloads'), tabBarTestID: 'downloads-tab'}} />
<Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings'), tabBarTestID: 'settings-tab' }} /> <Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings'), tabBarTestID: 'settings-tab' }} />
</Tab.Navigator> </Tab.Navigator>

View File

@@ -60,7 +60,6 @@ class CredentialGenerator extends Component<Props> {
return ( return (
<WebView <WebView
source={{ uri: serverUrl as string }} source={{ uri: serverUrl as string }}
style={{ borderRadius: 20 }}
onNavigationStateChange={this.handleStateChange} onNavigationStateChange={this.handleStateChange}
onMessage={this.handleMessage} onMessage={this.handleMessage}
ref={this.ref} ref={this.ref}

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useNavigation, StackActions, useRoute, RouteProp } from '@react-navigation/native'; import { useNavigation, StackActions, useRoute, RouteProp } from '@react-navigation/native';
import { ModalStackParams } from 'screens/types'; import { StackParams } from 'screens/types';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
import { Header, SubHeader } from 'components/Typography'; import { Header, SubHeader } from 'components/Typography';
import styled from 'styled-components/native'; import styled from 'styled-components/native';
@@ -17,7 +17,7 @@ import usePlayTracks from 'utility/usePlayTracks';
import { selectIsDownloaded } from 'store/downloads/selectors'; import { selectIsDownloaded } from 'store/downloads/selectors';
import { useGetImage } from 'utility/JellyfinApi'; import { useGetImage } from 'utility/JellyfinApi';
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>; type Route = RouteProp<StackParams, 'TrackPopupMenu'>;
const Container = styled.View` const Container = styled.View`
padding: 40px; padding: 40px;

View File

@@ -1,9 +1,16 @@
import { StackNavigationProp } from '@react-navigation/stack'; import { StackNavigationProp } from '@react-navigation/stack';
import { Album } from 'store/music/types';
export interface ModalStackParams { export type StackParams = {
[key: string]: Record<string, unknown> | undefined; [key: string]: Record<string, unknown> | undefined;
Albums: undefined;
Album: { id: string, album: Album };
Playlists: undefined;
Playlist: { id: string };
RecentAlbums: undefined;
Search: undefined;
SetJellyfinServer: undefined; SetJellyfinServer: undefined;
TrackPopupMenu: { trackId: string }; TrackPopupMenu: { trackId: string };
} };
export type ModalNavigationProp = StackNavigationProp<ModalStackParams>; export type NavigationProp = StackNavigationProp<StackParams>;

View File

@@ -46,6 +46,14 @@ export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncTh
} }
); );
export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>(
'/albums/single',
async (ItemId, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAlbum(credentials, ItemId) as Promise<Album>;
}
);
type SearchAndFetchResults = { type SearchAndFetchResults = {
albums: Album[]; albums: Album[];
results: (Album | AlbumTrack)[]; results: (Album | AlbumTrack)[];

View File

@@ -7,7 +7,8 @@ import {
searchAndFetchAlbums, searchAndFetchAlbums,
playlistAdapter, playlistAdapter,
fetchAllPlaylists, fetchAllPlaylists,
fetchTracksByPlaylist fetchTracksByPlaylist,
fetchAlbum
} from './actions'; } from './actions';
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit'; import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
import { Album, AlbumTrack, Playlist } from './types'; import { Album, AlbumTrack, Playlist } from './types';
@@ -69,6 +70,15 @@ const music = createSlice({
}); });
builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; }); builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; }); builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; });
/**
* Fetch single album
*/
builder.addCase(fetchAlbum.fulfilled, (state, { payload }) => {
albumAdapter.upsertOne(state.albums, payload);
});
builder.addCase(fetchAlbum.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(fetchAlbum.rejected, (state) => { state.albums.isLoading = false; });
/** /**
* Fetch most recent albums * Fetch most recent albums

View File

@@ -43,6 +43,8 @@ export interface Album {
Tracks?: string[]; Tracks?: string[];
lastRefreshed?: number; lastRefreshed?: number;
DateCreated: string; DateCreated: string;
Overview?: string;
Similar?: string[];
} }
export interface AlbumTrack { export interface AlbumTrack {
@@ -94,4 +96,8 @@ export interface Playlist {
MediaType: string; MediaType: string;
Tracks?: string[]; Tracks?: string[];
lastRefreshed?: number; lastRefreshed?: number;
}
export interface SimilarAlbum {
Id: string;
} }

View File

@@ -5,7 +5,7 @@ import { t } from '@localisation';
import { setReceivedErrorReportingAlert } from 'store/settings/actions'; import { setReceivedErrorReportingAlert } from 'store/settings/actions';
import { setSentryStatus } from './Sentry'; import { setSentryStatus } from './Sentry';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { ModalNavigationProp } from 'screens/types'; import { NavigationProp } from 'screens/types';
/** /**
* This will send out an alert message asking the user if they want to enable * This will send out an alert message asking the user if they want to enable
@@ -13,7 +13,7 @@ import { ModalNavigationProp } from 'screens/types';
*/ */
export default function ErrorReportingAlert() { export default function ErrorReportingAlert() {
const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings); const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings);
const navigation = useNavigation<ModalNavigationProp>(); const navigation = useNavigation<NavigationProp>();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {

View File

@@ -1,6 +1,6 @@
import { Track } from 'react-native-track-player'; import { Track } from 'react-native-track-player';
import { AppState, useTypedSelector } from 'store'; import { AppState, useTypedSelector } from 'store';
import { Album, AlbumTrack } from 'store/music/types'; import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types';
type Credentials = AppState['settings']['jellyfin']; type Credentials = AppState['settings']['jellyfin'];
@@ -86,8 +86,14 @@ export async function retrieveAllAlbums(credentials: Credentials) {
*/ */
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> { export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
const config = generateConfig(credentials); const config = generateConfig(credentials);
const Similar = await fetch(`${credentials?.uri}/Items/${id}/Similar?userId=${credentials?.user_id}&limit=12`, config)
.then(response => response.json() as Promise<{ Items: SimilarAlbum[] }>)
.then((albums) => albums.Items.map((a) => a.Id));
return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config) return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config)
.then(response => response.json()); .then(response => response.json() as Promise<Album>)
.then(album => ({ ...album, Similar }));
} }
const latestAlbumsOptions = { const latestAlbumsOptions = {
@@ -96,7 +102,6 @@ const latestAlbumsOptions = {
SortOrder: 'Ascending', SortOrder: 'Ascending',
}; };
/** /**
* Retrieve the most recently added albums on the Jellyfin server * Retrieve the most recently added albums on the Jellyfin server
*/ */

View File

@@ -1,9 +1,9 @@
function ticksToDuration(ticks: number) { function ticksToDuration(ticks: number) {
const seconds = Math.round(ticks / 10000000); const seconds = Math.round(ticks / 10000000);
const minutes = Math.round(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.round(minutes / 60); const hours = Math.floor(minutes / 60);
return `${hours > 0 ? hours + ':' : ''}${minutes}:${(seconds % 60).toString().padStart(2, '0')}`; return `${hours > 0 ? hours + ':' : ''}${(minutes % 60).toString().padStart(hours > 0 ? 2 : 0, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
} }
export default ticksToDuration; export default ticksToDuration;

View File

@@ -1,6 +1,6 @@
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useRef, useEffect } from 'react'; import { useRef, useEffect } from 'react';
import { Animated, Keyboard, KeyboardEvent } from 'react-native'; import { Animated, Easing, Keyboard, KeyboardEvent } from 'react-native';
/** /**
* This returns an animated height that the keyboard is poking up from the * This returns an animated height that the keyboard is poking up from the
@@ -15,9 +15,10 @@ export const useKeyboardHeight = () => {
useEffect(() => { useEffect(() => {
const keyboardWillShow = (e: KeyboardEvent) => { const keyboardWillShow = (e: KeyboardEvent) => {
Animated.timing(keyboardHeight, { Animated.timing(keyboardHeight, {
duration: e.duration, duration: e.duration - 20,
toValue: tabBarHeight - e.endCoordinates.height, toValue: tabBarHeight - e.endCoordinates.height,
useNativeDriver: true, useNativeDriver: true,
easing: Easing.ease,
}).start(); }).start();
}; };