Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
287b64c356 | ||
|
|
e116e95236 | ||
|
|
c8283fc580 | ||
|
|
81b9ba683a | ||
|
|
913d185b46 | ||
|
|
1d97830f83 | ||
|
|
6ccfd19dea | ||
|
|
2e816f4a71 | ||
|
|
4ff071d0c8 | ||
|
|
dba87247d8 | ||
|
|
c3c32ae565 | ||
|
|
1d7db11328 | ||
|
|
1a5e4aee12 | ||
|
|
e2c1c0300f | ||
|
|
7601408d49 | ||
|
|
4509ef1ec6 | ||
|
|
dcd3f595ed |
@@ -52,6 +52,12 @@ module.exports = {
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error'
|
||||
],
|
||||
'react/jsx-no-literals': [
|
||||
'error',
|
||||
{
|
||||
ignoreProps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
settings: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -138,8 +138,8 @@ android {
|
||||
applicationId "nl.moeilijkedingen.jellyfinaudioplayer"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 17
|
||||
versionName "2.0.5"
|
||||
versionCode 18
|
||||
versionName "2.1.0"
|
||||
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
|
||||
|
||||
if (isNewArchitectureEnabled()) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -606,7 +606,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 63;
|
||||
CURRENT_PROJECT_VERSION = 64;
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
@@ -643,7 +643,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 63;
|
||||
CURRENT_PROJECT_VERSION = 64;
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
INFOPLIST_FILE = Fintunes/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
@@ -799,7 +799,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 63;
|
||||
CURRENT_PROJECT_VERSION = 64;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -832,7 +832,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 63;
|
||||
CURRENT_PROJECT_VERSION = 64;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.5</string>
|
||||
<string>2.1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>63</string>
|
||||
<string>64</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "fintunes",
|
||||
"version": "2.0.5",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fintunes",
|
||||
"version": "2.0.5",
|
||||
"version": "2.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^1.17.11",
|
||||
"@react-native-community/blur": "^4.3.0",
|
||||
"@react-native-community/netinfo": "^9.3.6",
|
||||
"@react-navigation/bottom-tabs": "^6.4.0",
|
||||
"@react-navigation/elements": "^1.3.17",
|
||||
"@react-navigation/native": "^6.0.13",
|
||||
"@react-navigation/native-stack": "^6.9.1",
|
||||
"@react-navigation/stack": "^6.3.4",
|
||||
@@ -3403,9 +3404,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/elements": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz",
|
||||
"integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w==",
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz",
|
||||
"integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA==",
|
||||
"peerDependencies": {
|
||||
"@react-navigation/native": "^6.0.0",
|
||||
"react": "*",
|
||||
@@ -19014,9 +19015,9 @@
|
||||
}
|
||||
},
|
||||
"@react-navigation/elements": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz",
|
||||
"integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w=="
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz",
|
||||
"integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA=="
|
||||
},
|
||||
"@react-navigation/native": {
|
||||
"version": "6.0.13",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fintunes",
|
||||
"version": "2.0.5",
|
||||
"version": "2.1.0",
|
||||
"main": "src/index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"ios": "react-native run-ios --scheme \"Fintunes\"",
|
||||
"start": "react-native start",
|
||||
"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'",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-native-community/blur": "^4.3.0",
|
||||
"@react-native-community/netinfo": "^9.3.6",
|
||||
"@react-navigation/bottom-tabs": "^6.4.0",
|
||||
"@react-navigation/elements": "^1.3.17",
|
||||
"@react-navigation/native": "^6.0.13",
|
||||
"@react-navigation/native-stack": "^6.9.1",
|
||||
"@react-navigation/stack": "^6.3.4",
|
||||
|
||||
@@ -109,7 +109,7 @@ export function ColoredBlurView(props: PropsWithChildren<BlurViewProps>) {
|
||||
} />
|
||||
) : (
|
||||
<View {...props} style={[ props.style, {
|
||||
backgroundColor: scheme === 'light' ? '#f6f6f6f6' : '#333333f6',
|
||||
backgroundColor: scheme === 'light' ? '#f6f6f6fb' : '#333333fb',
|
||||
} ]} />
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,35 @@ import React, { useCallback, useRef } from 'react';
|
||||
import { Platform, TextInput, TextInputProps } from 'react-native';
|
||||
import styled, { css } from 'styled-components/native';
|
||||
import useDefaultStyles from './Colors';
|
||||
import { Gap } from './Utility';
|
||||
|
||||
export interface InputProps extends TextInputProps {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Container = styled.Pressable`
|
||||
const Container = styled.Pressable<{ hasIcon?: boolean }>`
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
${Platform.select({
|
||||
ios: css`padding: 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) {
|
||||
@@ -28,12 +40,17 @@ function Input({ icon = null, style, testID, ...rest }: InputProps) {
|
||||
const handlePress = useCallback(() => inputRef.current?.focus(), []);
|
||||
|
||||
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 && (
|
||||
<>
|
||||
<IconWrapper>
|
||||
{icon}
|
||||
<Gap size={8} />
|
||||
</>
|
||||
</IconWrapper>
|
||||
)}
|
||||
<TextInput
|
||||
{...rest}
|
||||
|
||||
@@ -14,7 +14,6 @@ const Background = styled.View`
|
||||
|
||||
const Container = styled.View<Pick<Props, 'fullSize'>>`
|
||||
margin: auto 20px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
flex: 0 0 auto;
|
||||
background: salmon;
|
||||
|
||||
100
src/components/SafeNavigatorView.tsx
Normal file
100
src/components/SafeNavigatorView.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ export function Text(props: PropsWithChildren<TextProps>) {
|
||||
export const Header = styled(Text)`
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.3px;
|
||||
`;
|
||||
|
||||
export const SubHeader = styled(Text)`
|
||||
@@ -24,3 +25,9 @@ export const SubHeader = styled(Text)`
|
||||
font-weight: 400;
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
export const Paragraph = styled(Text)`
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
`;
|
||||
@@ -59,5 +59,7 @@
|
||||
"you-are-offline-message": "You are currently offline. You can only play previously downloaded music.",
|
||||
"playing-on": "Playing on",
|
||||
"local-playback": "Local playback",
|
||||
"streaming": "Streaming"
|
||||
"streaming": "Streaming",
|
||||
"total-duration": "Total duration",
|
||||
"similar-albums": "Similar albums"
|
||||
}
|
||||
@@ -59,5 +59,7 @@
|
||||
"you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen.",
|
||||
"playing-on": "Speelt af op",
|
||||
"local-playback": "Lokaal afspelen",
|
||||
"streaming": "Streamen"
|
||||
"streaming": "Streamen",
|
||||
"total-duration": "Totale duur",
|
||||
"similar-albums": "Vergelijkbare albums"
|
||||
}
|
||||
@@ -57,4 +57,6 @@ export type LocaleKeys = 'play-next'
|
||||
| 'you-are-offline-message'
|
||||
| 'playing-on'
|
||||
| 'local-playback'
|
||||
| 'streaming'
|
||||
| 'streaming'
|
||||
| 'total-duration'
|
||||
| 'similar-albums'
|
||||
@@ -1,7 +1,6 @@
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { FlatListProps, View } from 'react-native';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import formatBytes from 'utility/formatBytes';
|
||||
@@ -17,6 +16,7 @@ import { Text } from 'components/Typography';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import { ShadowWrapper } from 'components/Shadow';
|
||||
import { SafeFlatList } from 'components/SafeNavigatorView';
|
||||
|
||||
const DownloadedTrack = styled.View`
|
||||
flex: 1 0 auto;
|
||||
@@ -82,7 +82,7 @@ function Downloads() {
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
|
||||
{t('total-download-size')}{': '}{formatBytes(totalDownloadSize)}
|
||||
</Text>
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
@@ -151,10 +151,10 @@ function Downloads() {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
{ListHeaderComponent}
|
||||
<FlatList
|
||||
<SafeFlatList
|
||||
data={ids}
|
||||
style={{ flex: 1, paddingTop: 12 }}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
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 { t } from '@localisation';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import Playlists from './stacks/Playlists';
|
||||
import Playlist from './stacks/Playlist';
|
||||
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||
import { StackParams } from 'screens/types';
|
||||
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() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
@@ -23,8 +25,10 @@ function MusicStack() {
|
||||
headerTintColor: THEME_COLOR,
|
||||
headerTitleStyle: defaultStyles.stackHeader,
|
||||
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="Album" component={Album} options={{ headerTitle: t('album') }} />
|
||||
<Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />
|
||||
|
||||
@@ -15,15 +15,15 @@ import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { calculateProgressTranslation } from 'components/Progresstrack';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { MusicNavigationProp } from 'screens/Music/types';
|
||||
import { NavigationProp } from 'screens/types';
|
||||
import { ShadowWrapper } from 'components/Shadow';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
|
||||
const NOW_PLAYING_POPOVER_MARGIN = 6;
|
||||
const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN;
|
||||
|
||||
const PopoverPosition = css`
|
||||
position: absolute;
|
||||
bottom: ${NOW_PLAYING_POPOVER_MARGIN}px;
|
||||
left: ${NOW_PLAYING_POPOVER_MARGIN}px;
|
||||
right: ${NOW_PLAYING_POPOVER_MARGIN}px;
|
||||
border-radius: 8px;
|
||||
@@ -111,10 +111,11 @@ function NowPlaying() {
|
||||
const { index, track } = useCurrentTrack();
|
||||
const { buffered, position } = useProgress();
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const previousBuffered = usePrevious(buffered);
|
||||
const previousPosition = usePrevious(position);
|
||||
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
|
||||
const bufferAnimation = useRef(new Animated.Value(0));
|
||||
const progressAnimation = useRef(new Animated.Value(0));
|
||||
@@ -164,7 +165,7 @@ function NowPlaying() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container style={{ bottom: tabBarHeight + NOW_PLAYING_POPOVER_MARGIN }}>
|
||||
{/** TODO: Fix shadow overflow on Android */}
|
||||
{Platform.OS === 'ios' ? (
|
||||
<ShadowOverlay pointerEvents='none'>
|
||||
|
||||
@@ -1,14 +1,54 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { MusicStackParams } from '../types';
|
||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import TrackListView from './components/TrackListView';
|
||||
import { fetchTracksByAlbum } from 'store/music/actions';
|
||||
import { fetchAlbum, fetchTracksByAlbum } from 'store/music/actions';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
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 { params: { id } } = useRoute<Route>();
|
||||
@@ -19,7 +59,10 @@ const Album: React.FC = () => {
|
||||
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@@ -39,7 +82,21 @@ const Album: React.FC = () => {
|
||||
shuffleButtonText={t('shuffle-album')}
|
||||
downloadButtonText={t('download-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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import { MusicNavigationProp } from '../types';
|
||||
import { SafeAreaView, SectionList, View } from 'react-native';
|
||||
import { SectionList, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
@@ -17,6 +16,8 @@ import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||
import { Album } from 'store/music/types';
|
||||
import { Text } from 'components/Typography';
|
||||
import { ShadowWrapper } from 'components/Shadow';
|
||||
import { NavigationProp } from 'screens/types';
|
||||
import { SafeSectionList } from 'components/SafeNavigatorView';
|
||||
|
||||
const HeadingHeight = 50;
|
||||
|
||||
@@ -87,7 +88,7 @@ const Albums: React.FC = () => {
|
||||
|
||||
// Initialise helpers
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
const listRef = useRef<SectionList<EntityId[]>>(null);
|
||||
|
||||
@@ -168,9 +169,9 @@ const Albums: React.FC = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<>
|
||||
<AlphabetScroller onSelect={selectLetter} />
|
||||
<SectionList
|
||||
<SafeSectionList
|
||||
sections={sections}
|
||||
refreshing={isLoading}
|
||||
onRefresh={retrieveData}
|
||||
@@ -180,7 +181,7 @@ const Albums: React.FC = () => {
|
||||
renderSectionHeader={generateSection}
|
||||
renderItem={generateItem}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { MusicStackParams } from '../types';
|
||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import TrackListView from './components/TrackListView';
|
||||
@@ -7,8 +6,9 @@ import { fetchTracksByPlaylist } from 'store/music/actions';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
import { t } from '@localisation';
|
||||
import { StackParams } from 'screens/types';
|
||||
|
||||
type Route = RouteProp<MusicStackParams, 'Album'>;
|
||||
type Route = RouteProp<StackParams, 'Album'>;
|
||||
|
||||
const Playlist: React.FC = () => {
|
||||
const { params: { id } } = useRoute<Route>();
|
||||
@@ -39,6 +39,7 @@ const Playlist: React.FC = () => {
|
||||
shuffleButtonText={t('shuffle-playlist')}
|
||||
downloadButtonText={t('download-playlist')}
|
||||
deleteButtonText={t('delete-playlist')}
|
||||
itemDisplayStyle='playlist'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import { MusicNavigationProp } from '../types';
|
||||
import { Text, View, FlatList, ListRenderItem } from 'react-native';
|
||||
import { Text, View, FlatList, ListRenderItem, RefreshControl } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
@@ -11,6 +10,8 @@ import TouchableHandler from 'components/TouchableHandler';
|
||||
import AlbumImage, { AlbumItem } from './components/AlbumImage';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import { NavigationProp } from 'screens/types';
|
||||
import { SafeFlatList, useNavigationOffsets } from 'components/SafeNavigatorView';
|
||||
|
||||
interface GeneratedAlbumItemProps {
|
||||
id: ReactText;
|
||||
@@ -34,6 +35,8 @@ const GeneratedPlaylistItem = React.memo(function GeneratedPlaylistItem(props: G
|
||||
});
|
||||
|
||||
const Playlists: React.FC = () => {
|
||||
const offsets = useNavigationOffsets();
|
||||
|
||||
// Retrieve data from store
|
||||
const { entities, ids } = useTypedSelector((state) => state.music.playlists);
|
||||
const isLoading = useTypedSelector((state) => state.music.playlists.isLoading);
|
||||
@@ -41,7 +44,7 @@ const Playlists: React.FC = () => {
|
||||
|
||||
// Initialise helpers
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
const listRef = useRef<FlatList<EntityId>>(null);
|
||||
|
||||
@@ -93,10 +96,11 @@ const Playlists: React.FC = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
<SafeFlatList
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isLoading} onRefresh={retrieveData} progressViewOffset={offsets.top} />
|
||||
}
|
||||
data={ids}
|
||||
refreshing={isLoading}
|
||||
onRefresh={retrieveData}
|
||||
getItemLayout={getItemLayout}
|
||||
ref={listRef}
|
||||
keyExtractor={(item, index) => `${item}_${index}`}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import { MusicNavigationProp } from '../types';
|
||||
import { Text, SafeAreaView, FlatList, StyleSheet } from 'react-native';
|
||||
import { Text, SafeAreaView, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import { fetchRecentAlbums } from 'store/music/actions';
|
||||
@@ -17,6 +16,8 @@ import { Album } from 'store/music/types';
|
||||
import Divider from 'components/Divider';
|
||||
import styled from 'styled-components/native';
|
||||
import { ShadowWrapper } from 'components/Shadow';
|
||||
import { NavigationProp } from 'screens/types';
|
||||
import { SafeFlatList } from 'components/SafeNavigatorView';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
columnWrapper: {
|
||||
@@ -31,7 +32,7 @@ const HeaderContainer = styled.View`
|
||||
`;
|
||||
|
||||
const NavigationHeader: React.FC = () => {
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
|
||||
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]);
|
||||
|
||||
@@ -59,7 +60,7 @@ const RecentAlbums: React.FC = () => {
|
||||
|
||||
// Initialise helpers
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
|
||||
// Set callbacks
|
||||
@@ -71,7 +72,7 @@ const RecentAlbums: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<FlatList
|
||||
<SafeFlatList
|
||||
data={recentAlbums as string[]}
|
||||
refreshing={isLoading}
|
||||
onRefresh={retrieveData}
|
||||
|
||||
@@ -27,7 +27,7 @@ function AlbumImage(props: FastImageProps) {
|
||||
|
||||
if (!props.source || hasError) {
|
||||
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')} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { ScrollView, RefreshControl, StyleSheet, View } from 'react-native';
|
||||
import React, { PropsWithChildren, useCallback, useMemo } from 'react';
|
||||
import { Platform, RefreshControl, StyleSheet, View } from 'react-native';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import styled, { css } from 'styled-components/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -14,7 +14,7 @@ import useDefaultStyles from 'components/Colors';
|
||||
import usePlayTracks from 'utility/usePlayTracks';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
|
||||
import { MusicNavigationProp } from 'screens/Music/types';
|
||||
import { NavigationProp } from 'screens/types';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
import CloudDownArrow from 'assets/icons/cloud-down-arrow.svg';
|
||||
import Trash from 'assets/icons/trash.svg';
|
||||
@@ -25,6 +25,8 @@ import { Text } from 'components/Typography';
|
||||
|
||||
import CoverImage from 'components/CoverImage';
|
||||
import ticksToDuration from 'utility/ticksToDuration';
|
||||
import { t } from '@localisation';
|
||||
import { SafeScrollView, useNavigationOffsets } from 'components/SafeNavigatorView';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
index: {
|
||||
@@ -42,18 +44,23 @@ const AlbumImageContainer = styled.View`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const TrackContainer = styled.View<{ isPlaying: boolean }>`
|
||||
const TrackContainer = styled.View<{ isPlaying: boolean, small?: boolean }>`
|
||||
padding: 12px 4px;
|
||||
flex-direction: row;
|
||||
border-radius: 6px;
|
||||
align-items: flex-start;
|
||||
|
||||
${props => props.isPlaying && css`
|
||||
margin: 0 -12px;
|
||||
padding: 12px 16px;
|
||||
`}
|
||||
|
||||
${props => props.small && css`
|
||||
padding: ${Platform.select({ ios: '8px 4px', android: '4px'})};
|
||||
`}
|
||||
`;
|
||||
|
||||
interface TrackListViewProps {
|
||||
export interface TrackListViewProps extends PropsWithChildren<{}> {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
trackIds: EntityId[];
|
||||
@@ -64,6 +71,7 @@ interface TrackListViewProps {
|
||||
downloadButtonText: string;
|
||||
deleteButtonText: string;
|
||||
listNumberingStyle?: 'album' | 'index';
|
||||
itemDisplayStyle?: 'album' | 'playlist';
|
||||
}
|
||||
|
||||
const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
@@ -77,19 +85,27 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
downloadButtonText,
|
||||
deleteButtonText,
|
||||
listNumberingStyle = 'album',
|
||||
itemDisplayStyle = 'album',
|
||||
children
|
||||
}) => {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const offsets = useNavigationOffsets();
|
||||
|
||||
// Retrieve state
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
|
||||
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
|
||||
const totalDuration = useMemo(() => (
|
||||
trackIds.reduce<number>((sum, trackId) => (
|
||||
sum + (tracks[trackId]?.RunTimeTicks || 0)
|
||||
), 0)
|
||||
), [trackIds, tracks]);
|
||||
|
||||
// Retrieve helpers
|
||||
const getImage = useGetImage();
|
||||
const playTracks = usePlayTracks();
|
||||
const { track: currentTrack } = useCurrentTrack();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Setup callbacks
|
||||
@@ -101,7 +117,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
await TrackPlayer.play();
|
||||
}, [playTracks, trackIds]);
|
||||
const longPressTrack = useCallback((index: number) => {
|
||||
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
|
||||
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index].toString() });
|
||||
}, [navigation, trackIds]);
|
||||
const downloadAllTracks = useCallback(() => {
|
||||
trackIds.forEach((trackId) => dispatch(queueTrackForDownload(trackId)));
|
||||
@@ -111,90 +127,109 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
}, [dispatch, downloadedTracks]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
<SafeScrollView
|
||||
style={defaultStyles.view}
|
||||
contentContainerStyle={{ padding: 24, paddingTop: 32, paddingBottom: 64 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
|
||||
<RefreshControl refreshing={isLoading} onRefresh={refresh} progressViewOffset={offsets.top} />
|
||||
}
|
||||
>
|
||||
<AlbumImageContainer>
|
||||
<CoverImage src={getImage(entityId)} />
|
||||
</AlbumImageContainer>
|
||||
<Header>{title}</Header>
|
||||
<SubHeader>{artist}</SubHeader>
|
||||
<WrappableButtonRow>
|
||||
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} testID="play-album" />
|
||||
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} testID="shuffle-album" />
|
||||
</WrappableButtonRow>
|
||||
<View style={{ marginTop: 8 }}>
|
||||
{trackIds.map((trackId, i) =>
|
||||
<TouchableHandler
|
||||
key={trackId}
|
||||
id={i}
|
||||
onPress={selectTrack}
|
||||
onLongPress={longPressTrack}
|
||||
testID={`play-track-${trackId}`}
|
||||
>
|
||||
<TrackContainer
|
||||
isPlaying={currentTrack?.backendId === trackId || false}
|
||||
style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
|
||||
<View style={{ padding: 24, paddingTop: 32, paddingBottom: 32 }}>
|
||||
<AlbumImageContainer>
|
||||
<CoverImage src={getImage(entityId)} />
|
||||
</AlbumImageContainer>
|
||||
<Header>{title}</Header>
|
||||
<SubHeader>{artist}</SubHeader>
|
||||
<WrappableButtonRow>
|
||||
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} testID="play-album" />
|
||||
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} testID="shuffle-album" />
|
||||
</WrappableButtonRow>
|
||||
<View style={{ marginTop: 8 }}>
|
||||
{trackIds.map((trackId, i) =>
|
||||
<TouchableHandler
|
||||
key={trackId}
|
||||
id={i}
|
||||
onPress={selectTrack}
|
||||
onLongPress={longPressTrack}
|
||||
testID={`play-track-${trackId}`}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.index,
|
||||
{ opacity: 0.25 },
|
||||
currentTrack?.backendId === trackId && styles.activeText
|
||||
]}
|
||||
numberOfLines={1}
|
||||
<TrackContainer
|
||||
isPlaying={currentTrack?.backendId === trackId || false}
|
||||
style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
|
||||
small={itemDisplayStyle === 'playlist'}
|
||||
>
|
||||
{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
|
||||
style={[
|
||||
{ marginRight: 12, opacity: 0.25 },
|
||||
styles.index,
|
||||
{ opacity: 0.25 },
|
||||
currentTrack?.backendId === trackId && styles.activeText
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{ticksToDuration(tracks[trackId]?.RunTimeTicks || 0)}
|
||||
{listNumberingStyle === 'index'
|
||||
? i + 1
|
||||
: tracks[trackId]?.IndexNumber}
|
||||
</Text>
|
||||
<DownloadIcon trackId={trackId} fill={currentTrack?.backendId === trackId ? `${THEME_COLOR}44` : undefined} />
|
||||
</View>
|
||||
</TrackContainer>
|
||||
</TouchableHandler>
|
||||
)}
|
||||
<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 style={{ flexShrink: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
...currentTrack?.backendId === trackId && styles.activeText,
|
||||
flexShrink: 1,
|
||||
marginRight: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tracks[trackId]?.Name}
|
||||
</Text>
|
||||
{itemDisplayStyle === 'playlist' && (
|
||||
<Text
|
||||
style={{
|
||||
...currentTrack?.backendId === trackId && styles.activeText,
|
||||
flexShrink: 1,
|
||||
marginRight: 4,
|
||||
opacity:currentTrack?.backendId === trackId ? 0.5 : 0.25,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{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>
|
||||
</ScrollView>
|
||||
</SafeScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,328 +1,31 @@
|
||||
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 React from 'react';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { t } from '@localisation';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import { searchAndFetchAlbums } from 'store/music/actions';
|
||||
import { debounce } from 'lodash';
|
||||
import { Text } from 'components/Typography';
|
||||
import { MusicNavigationProp } from 'screens/Music/types';
|
||||
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';
|
||||
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||
import { StackParams } from 'screens/types';
|
||||
import Search from './stacks/Search';
|
||||
import Album from 'screens/Music/stacks/Album';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
const Container = styled(Animated.View)`
|
||||
padding: 4px 32px 0 32px;
|
||||
margin-bottom: 0px;
|
||||
padding-bottom: 0px;
|
||||
border-top-width: 0.5px;
|
||||
`;
|
||||
const Stack = createStackNavigator<StackParams>();
|
||||
|
||||
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() {
|
||||
function SearchStack() {
|
||||
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 (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<FlatList
|
||||
style={{ flex: 2 }}
|
||||
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}
|
||||
{HeaderComponent}
|
||||
</SafeAreaView>
|
||||
<Stack.Navigator initialRouteName="Search" screenOptions={{
|
||||
headerTintColor: THEME_COLOR,
|
||||
headerTitleStyle: defaultStyles.stackHeader,
|
||||
cardStyle: defaultStyles.view,
|
||||
headerTransparent: true,
|
||||
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
|
||||
|
||||
}}>
|
||||
<Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search'), headerShown: false }} />
|
||||
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchStack;
|
||||
335
src/screens/Search/stacks/Search/index.tsx
Normal file
335
src/screens/Search/stacks/Search/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import music from 'store/music';
|
||||
import { t } from '@localisation';
|
||||
import Button from 'components/Button';
|
||||
import styled from 'styled-components/native';
|
||||
import { Text } from 'components/Typography';
|
||||
import { Paragraph } from 'components/Typography';
|
||||
import { useAppDispatch } from 'store';
|
||||
|
||||
import { SafeScrollView } from 'components/SafeNavigatorView';
|
||||
|
||||
const ClearCache = styled(Button)`
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const Container = styled.ScrollView`
|
||||
const Container = styled(SafeScrollView)`
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function CacheSettings() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Text>{t('setting-cache-description')}</Text>
|
||||
<Paragraph>{t('setting-cache-description')}</Paragraph>
|
||||
<ClearCache title={t('reset-cache')} onPress={handleClearCache} />
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { NavigationProp } from '../..';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { t } from '@localisation';
|
||||
import Button from 'components/Button';
|
||||
import { Text } from 'components/Typography';
|
||||
|
||||
import { Paragraph } from 'components/Typography';
|
||||
import { SafeScrollView } from 'components/SafeNavigatorView';
|
||||
|
||||
const InputContainer = styled.View`
|
||||
margin: 10px 0;
|
||||
@@ -19,7 +19,7 @@ const Input = styled.TextInput`
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
const Container = styled.ScrollView`
|
||||
const Container = styled(SafeScrollView)`
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
@@ -32,15 +32,15 @@ export default function LibrarySettings() {
|
||||
return (
|
||||
<Container>
|
||||
<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} />
|
||||
</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} />
|
||||
</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} />
|
||||
</InputContainer>
|
||||
<Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Text } from 'components/Typography';
|
||||
import { Paragraph, Text } from 'components/Typography';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 useDefaultStyles, { DefaultStylesProvider } from 'components/Colors';
|
||||
import { t } from '@localisation';
|
||||
import { SafeScrollView } from 'components/SafeNavigatorView';
|
||||
|
||||
const Container = styled.ScrollView`
|
||||
const Container = styled.View`
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
@@ -25,7 +26,9 @@ const HeaderContainer = styled.View<{ isActive?: boolean }>`
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 16px 0 4px 0;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
${props => props.isActive && css`
|
||||
background-color: ${THEME_COLOR};
|
||||
@@ -37,7 +40,8 @@ const HeaderText = styled(Text)`
|
||||
`;
|
||||
|
||||
const ContentContainer = styled.View`
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 24px;
|
||||
`;
|
||||
|
||||
const Label = styled(Text)`
|
||||
@@ -87,7 +91,7 @@ function renderHeader(question: Question, index: number, isActive: boolean) {
|
||||
function renderContent(question: Question) {
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Text>{question.content}</Text>
|
||||
<Paragraph>{question.content}</Paragraph>
|
||||
</ContentContainer>
|
||||
);
|
||||
}
|
||||
@@ -104,14 +108,17 @@ export default function Sentry() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Text>{t('error-reporting-description')}</Text>
|
||||
<Text />
|
||||
<Text>{t('error-reporting-rationale')}</Text>
|
||||
<SwitchContainer>
|
||||
<Label>{t('error-reporting')}</Label>
|
||||
<Switch value={isReportingEnabled} onValueChange={toggleSwitch} />
|
||||
</SwitchContainer>
|
||||
<SafeScrollView>
|
||||
<Container>
|
||||
<Paragraph>{t('error-reporting-description')}</Paragraph>
|
||||
<Paragraph />
|
||||
<Paragraph>{t('error-reporting-rationale')}</Paragraph>
|
||||
|
||||
<SwitchContainer>
|
||||
<Label>{t('error-reporting')}</Label>
|
||||
<Switch value={isReportingEnabled} onValueChange={toggleSwitch} />
|
||||
</SwitchContainer>
|
||||
</Container>
|
||||
<Accordion
|
||||
sections={questions}
|
||||
renderHeader={renderHeader}
|
||||
@@ -120,6 +127,6 @@ export default function Sentry() {
|
||||
onChange={setActiveSections}
|
||||
underlayColor={defaultStyles.activeBackground.backgroundColor}
|
||||
/>
|
||||
</Container>
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { SafeAreaView, ScrollView } from 'react-native';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Library from './components/Library';
|
||||
import Cache from './components/Cache';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||
import { t } from '@localisation';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -10,6 +10,7 @@ import ListButton from 'components/ListButton';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import Sentry from './components/Sentry';
|
||||
import { SettingsNavigationProp } from './types';
|
||||
import { SafeScrollView } from 'components/SafeNavigatorView';
|
||||
|
||||
export function SettingsList() {
|
||||
const navigation = useNavigation<SettingsNavigationProp>();
|
||||
@@ -18,13 +19,11 @@ export function SettingsList() {
|
||||
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<SafeAreaView>
|
||||
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
|
||||
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
|
||||
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
|
||||
</SafeAreaView>
|
||||
</ScrollView>
|
||||
<SafeScrollView>
|
||||
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
|
||||
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
|
||||
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,9 +33,11 @@ export default function Settings() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
|
||||
return (
|
||||
<Stack.Navigator initialRouteName="SettingList" screenOptions={{
|
||||
<Stack.Navigator initialRouteName="SettingList" screenOptions={{
|
||||
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="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
|
||||
import Search from './Search';
|
||||
import SearchStack from './Search';
|
||||
import Music from './Music';
|
||||
import Settings from './Settings';
|
||||
import Downloads from './Downloads';
|
||||
@@ -18,13 +18,15 @@ import NotesIcon from 'assets/icons/notes.svg';
|
||||
import GearIcon from 'assets/icons/gear.svg';
|
||||
import DownloadsIcon from 'assets/icons/arrow-down-to-line.svg';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { ModalStackParams } from './types';
|
||||
import { t } from '@localisation';
|
||||
import ErrorReportingAlert from 'utility/ErrorReportingAlert';
|
||||
import ErrorReportingPopup from './modals/ErrorReportingPopup';
|
||||
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();
|
||||
|
||||
type Screens = {
|
||||
@@ -48,9 +50,9 @@ function Screens() {
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarIcon: function TabBarIcon({ color, size }) {
|
||||
switch (route.name) {
|
||||
case 'Search':
|
||||
case 'SearchTab':
|
||||
return <SearchIcon fill={color} height={size - 4} width={size - 4} />;
|
||||
case 'Music':
|
||||
case 'MusicTab':
|
||||
return <NotesIcon fill={color} height={size} width={size} />;
|
||||
case 'Settings':
|
||||
return <GearIcon fill={color} height={size - 1} width={size - 1} />;
|
||||
@@ -63,10 +65,15 @@ function Screens() {
|
||||
tabBarActiveTintColor: THEME_COLOR,
|
||||
tabBarInactiveTintColor: 'gray',
|
||||
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="Search" component={Search} options={{ tabBarLabel: t('search'), tabBarTestID: 'search-tab' }} />
|
||||
<Tab.Screen name="MusicTab" component={Music} options={{ tabBarLabel: t('music'), tabBarTestID: 'music-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="Settings" component={Settings} options={{ tabBarLabel: t('settings'), tabBarTestID: 'settings-tab' }} />
|
||||
</Tab.Navigator>
|
||||
|
||||
@@ -60,7 +60,6 @@ class CredentialGenerator extends Component<Props> {
|
||||
return (
|
||||
<WebView
|
||||
source={{ uri: serverUrl as string }}
|
||||
style={{ borderRadius: 20 }}
|
||||
onNavigationStateChange={this.handleStateChange}
|
||||
onMessage={this.handleMessage}
|
||||
ref={this.ref}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback } from 'react';
|
||||
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 { Header, SubHeader } from 'components/Typography';
|
||||
import styled from 'styled-components/native';
|
||||
@@ -17,7 +17,7 @@ import usePlayTracks from 'utility/usePlayTracks';
|
||||
import { selectIsDownloaded } from 'store/downloads/selectors';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
|
||||
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
|
||||
type Route = RouteProp<StackParams, 'TrackPopupMenu'>;
|
||||
|
||||
const Container = styled.View`
|
||||
padding: 40px;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { Album } from 'store/music/types';
|
||||
|
||||
export interface ModalStackParams {
|
||||
export type StackParams = {
|
||||
[key: string]: Record<string, unknown> | undefined;
|
||||
Albums: undefined;
|
||||
Album: { id: string, album: Album };
|
||||
Playlists: undefined;
|
||||
Playlist: { id: string };
|
||||
RecentAlbums: undefined;
|
||||
Search: undefined;
|
||||
SetJellyfinServer: undefined;
|
||||
TrackPopupMenu: { trackId: string };
|
||||
}
|
||||
};
|
||||
|
||||
export type ModalNavigationProp = StackNavigationProp<ModalStackParams>;
|
||||
export type NavigationProp = StackNavigationProp<StackParams>;
|
||||
|
||||
@@ -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 = {
|
||||
albums: Album[];
|
||||
results: (Album | AlbumTrack)[];
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
searchAndFetchAlbums,
|
||||
playlistAdapter,
|
||||
fetchAllPlaylists,
|
||||
fetchTracksByPlaylist
|
||||
fetchTracksByPlaylist,
|
||||
fetchAlbum
|
||||
} from './actions';
|
||||
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
|
||||
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.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
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface Album {
|
||||
Tracks?: string[];
|
||||
lastRefreshed?: number;
|
||||
DateCreated: string;
|
||||
Overview?: string;
|
||||
Similar?: string[];
|
||||
}
|
||||
|
||||
export interface AlbumTrack {
|
||||
@@ -94,4 +96,8 @@ export interface Playlist {
|
||||
MediaType: string;
|
||||
Tracks?: string[];
|
||||
lastRefreshed?: number;
|
||||
}
|
||||
|
||||
export interface SimilarAlbum {
|
||||
Id: string;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { t } from '@localisation';
|
||||
import { setReceivedErrorReportingAlert } from 'store/settings/actions';
|
||||
import { setSentryStatus } from './Sentry';
|
||||
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
|
||||
@@ -13,7 +13,7 @@ import { ModalNavigationProp } from 'screens/types';
|
||||
*/
|
||||
export default function ErrorReportingAlert() {
|
||||
const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings);
|
||||
const navigation = useNavigation<ModalNavigationProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Track } from 'react-native-track-player';
|
||||
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'];
|
||||
|
||||
@@ -86,8 +86,14 @@ export async function retrieveAllAlbums(credentials: Credentials) {
|
||||
*/
|
||||
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
|
||||
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)
|
||||
.then(response => response.json());
|
||||
.then(response => response.json() as Promise<Album>)
|
||||
.then(album => ({ ...album, Similar }));
|
||||
}
|
||||
|
||||
const latestAlbumsOptions = {
|
||||
@@ -96,7 +102,6 @@ const latestAlbumsOptions = {
|
||||
SortOrder: 'Ascending',
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve the most recently added albums on the Jellyfin server
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
function ticksToDuration(ticks: number) {
|
||||
const seconds = Math.round(ticks / 10000000);
|
||||
const minutes = Math.round(seconds / 60);
|
||||
const hours = Math.round(minutes / 60);
|
||||
const minutes = Math.floor(seconds / 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
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
|
||||
@@ -15,9 +15,10 @@ export const useKeyboardHeight = () => {
|
||||
useEffect(() => {
|
||||
const keyboardWillShow = (e: KeyboardEvent) => {
|
||||
Animated.timing(keyboardHeight, {
|
||||
duration: e.duration,
|
||||
duration: e.duration - 20,
|
||||
toValue: tabBarHeight - e.endCoordinates.height,
|
||||
useNativeDriver: true,
|
||||
easing: Easing.ease,
|
||||
}).start();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user