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',
'@typescript-eslint/no-unused-vars': [
'error'
],
'react/jsx-no-literals': [
'error',
{
ignoreProps: true
}
]
},
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)

View File

@@ -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()) {

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.

View File

@@ -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;

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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',
} ]} />
);
}

View File

@@ -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}

View File

@@ -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;

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)`
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;
`;

View File

@@ -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"
}

View File

@@ -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"
}

View File

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

View File

@@ -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>

View File

@@ -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') }} />

View File

@@ -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'>

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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'
/>
);
};

View File

@@ -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}`}

View File

@@ -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}

View File

@@ -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')} />
);
}

View File

@@ -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>
);
};

View File

@@ -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;

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 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>
);

View File

@@ -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} />

View File

@@ -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>
);
}

View File

@@ -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') }} />

View File

@@ -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>

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>;

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 = {
albums: Album[];
results: (Album | AlbumTrack)[];

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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(() => {

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -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();
};