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',
|
'react/prop-types': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'error'
|
'error'
|
||||||
|
],
|
||||||
|
'react/jsx-no-literals': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
ignoreProps: true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
## [2.0.5](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.4...v2.0.5) (2023-04-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* crash when fast-image fails to load an image ([67499b1](https://github.com/leinelissen/jellyfin-audio-player/commit/67499b11037779bf33bb557fff69114cd519c78e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [2.0.4](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.3...v2.0.4) (2023-04-11)
|
## [2.0.4](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.3...v2.0.4) (2023-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ android {
|
|||||||
applicationId "nl.moeilijkedingen.jellyfinaudioplayer"
|
applicationId "nl.moeilijkedingen.jellyfinaudioplayer"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 17
|
versionCode 18
|
||||||
versionName "2.0.5"
|
versionName "2.1.0"
|
||||||
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
|
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
|
||||||
|
|
||||||
if (isNewArchitectureEnabled()) {
|
if (isNewArchitectureEnabled()) {
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ Generate beta build
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### android build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android build
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
----
|
----
|
||||||
|
|
||||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|||||||
@@ -606,7 +606,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 64;
|
||||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 64;
|
||||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||||
INFOPLIST_FILE = Fintunes/Info.plist;
|
INFOPLIST_FILE = Fintunes/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
@@ -799,7 +799,7 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 64;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -832,7 +832,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 64;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.0.5</string>
|
<string>2.1.0</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>63</string>
|
<string>64</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "fintunes",
|
"name": "fintunes",
|
||||||
"version": "2.0.5",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fintunes",
|
"name": "fintunes",
|
||||||
"version": "2.0.5",
|
"version": "2.1.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "^1.17.11",
|
"@react-native-async-storage/async-storage": "^1.17.11",
|
||||||
"@react-native-community/blur": "^4.3.0",
|
"@react-native-community/blur": "^4.3.0",
|
||||||
"@react-native-community/netinfo": "^9.3.6",
|
"@react-native-community/netinfo": "^9.3.6",
|
||||||
"@react-navigation/bottom-tabs": "^6.4.0",
|
"@react-navigation/bottom-tabs": "^6.4.0",
|
||||||
|
"@react-navigation/elements": "^1.3.17",
|
||||||
"@react-navigation/native": "^6.0.13",
|
"@react-navigation/native": "^6.0.13",
|
||||||
"@react-navigation/native-stack": "^6.9.1",
|
"@react-navigation/native-stack": "^6.9.1",
|
||||||
"@react-navigation/stack": "^6.3.4",
|
"@react-navigation/stack": "^6.3.4",
|
||||||
@@ -3403,9 +3404,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-navigation/elements": {
|
"node_modules/@react-navigation/elements": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.17",
|
||||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz",
|
||||||
"integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w==",
|
"integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@react-navigation/native": "^6.0.0",
|
"@react-navigation/native": "^6.0.0",
|
||||||
"react": "*",
|
"react": "*",
|
||||||
@@ -19014,9 +19015,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@react-navigation/elements": {
|
"@react-navigation/elements": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.17",
|
||||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz",
|
||||||
"integrity": "sha512-pNJ8R9JMga6SXOw6wGVN0tjmE6vegwPmJBL45SEMX2fqTfAk2ykDnlJHodRpHpAgsv0DaI8qX76z3A+aqKSU0w=="
|
"integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA=="
|
||||||
},
|
},
|
||||||
"@react-navigation/native": {
|
"@react-navigation/native": {
|
||||||
"version": "6.0.13",
|
"version": "6.0.13",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fintunes",
|
"name": "fintunes",
|
||||||
"version": "2.0.5",
|
"version": "2.1.0",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"ios": "react-native run-ios --scheme \"Fintunes\"",
|
"ios": "react-native run-ios --scheme \"Fintunes\"",
|
||||||
"start": "react-native start",
|
"start": "react-native start",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
|
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
|
||||||
"build:ios": "react-native bundle --entry-file='index.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'",
|
"build:ios": "react-native bundle --entry-file='index.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@react-native-community/blur": "^4.3.0",
|
"@react-native-community/blur": "^4.3.0",
|
||||||
"@react-native-community/netinfo": "^9.3.6",
|
"@react-native-community/netinfo": "^9.3.6",
|
||||||
"@react-navigation/bottom-tabs": "^6.4.0",
|
"@react-navigation/bottom-tabs": "^6.4.0",
|
||||||
|
"@react-navigation/elements": "^1.3.17",
|
||||||
"@react-navigation/native": "^6.0.13",
|
"@react-navigation/native": "^6.0.13",
|
||||||
"@react-navigation/native-stack": "^6.9.1",
|
"@react-navigation/native-stack": "^6.9.1",
|
||||||
"@react-navigation/stack": "^6.3.4",
|
"@react-navigation/stack": "^6.3.4",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function ColoredBlurView(props: PropsWithChildren<BlurViewProps>) {
|
|||||||
} />
|
} />
|
||||||
) : (
|
) : (
|
||||||
<View {...props} style={[ props.style, {
|
<View {...props} style={[ props.style, {
|
||||||
backgroundColor: scheme === 'light' ? '#f6f6f6f6' : '#333333f6',
|
backgroundColor: scheme === 'light' ? '#f6f6f6fb' : '#333333fb',
|
||||||
} ]} />
|
} ]} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,23 +2,35 @@ import React, { useCallback, useRef } from 'react';
|
|||||||
import { Platform, TextInput, TextInputProps } from 'react-native';
|
import { Platform, TextInput, TextInputProps } from 'react-native';
|
||||||
import styled, { css } from 'styled-components/native';
|
import styled, { css } from 'styled-components/native';
|
||||||
import useDefaultStyles from './Colors';
|
import useDefaultStyles from './Colors';
|
||||||
import { Gap } from './Utility';
|
|
||||||
|
|
||||||
export interface InputProps extends TextInputProps {
|
export interface InputProps extends TextInputProps {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled.Pressable`
|
const Container = styled.Pressable<{ hasIcon?: boolean }>`
|
||||||
|
position: relative;
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
${Platform.select({
|
${Platform.select({
|
||||||
ios: css`padding: 12px;`,
|
ios: css`padding: 12px;`,
|
||||||
android: css`padding: 4px 12px;`,
|
android: css`padding: 4px 12px;`,
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
${({ hasIcon }) => hasIcon && css`
|
||||||
|
padding-left: 36px;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const IconWrapper = styled.View`
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 12px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function Input({ icon = null, style, testID, ...rest }: InputProps) {
|
function Input({ icon = null, style, testID, ...rest }: InputProps) {
|
||||||
@@ -28,12 +40,17 @@ function Input({ icon = null, style, testID, ...rest }: InputProps) {
|
|||||||
const handlePress = useCallback(() => inputRef.current?.focus(), []);
|
const handlePress = useCallback(() => inputRef.current?.focus(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container style={[defaultStyles.input, style]} onPress={handlePress} testID={`${testID}-container`} accessible={false}>
|
<Container
|
||||||
|
style={[defaultStyles.input, style]}
|
||||||
|
onPress={handlePress}
|
||||||
|
testID={`${testID}-container`}
|
||||||
|
accessible={false}
|
||||||
|
hasIcon={!!icon}
|
||||||
|
>
|
||||||
{icon && (
|
{icon && (
|
||||||
<>
|
<IconWrapper>
|
||||||
{icon}
|
{icon}
|
||||||
<Gap size={8} />
|
</IconWrapper>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<TextInput
|
<TextInput
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const Background = styled.View`
|
|||||||
|
|
||||||
const Container = styled.View<Pick<Props, 'fullSize'>>`
|
const Container = styled.View<Pick<Props, 'fullSize'>>`
|
||||||
margin: auto 20px;
|
margin: auto 20px;
|
||||||
padding: 4px;
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
background: salmon;
|
background: salmon;
|
||||||
|
|||||||
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)`
|
export const Header = styled(Text)`
|
||||||
margin: 0 0 6px 0;
|
margin: 0 0 6px 0;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SubHeader = styled(Text)`
|
export const SubHeader = styled(Text)`
|
||||||
@@ -24,3 +25,9 @@ export const SubHeader = styled(Text)`
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const Paragraph = styled(Text)`
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
`;
|
||||||
@@ -59,5 +59,7 @@
|
|||||||
"you-are-offline-message": "You are currently offline. You can only play previously downloaded music.",
|
"you-are-offline-message": "You are currently offline. You can only play previously downloaded music.",
|
||||||
"playing-on": "Playing on",
|
"playing-on": "Playing on",
|
||||||
"local-playback": "Local playback",
|
"local-playback": "Local playback",
|
||||||
"streaming": "Streaming"
|
"streaming": "Streaming",
|
||||||
|
"total-duration": "Total duration",
|
||||||
|
"similar-albums": "Similar albums"
|
||||||
}
|
}
|
||||||
@@ -59,5 +59,7 @@
|
|||||||
"you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen.",
|
"you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen.",
|
||||||
"playing-on": "Speelt af op",
|
"playing-on": "Speelt af op",
|
||||||
"local-playback": "Lokaal afspelen",
|
"local-playback": "Lokaal afspelen",
|
||||||
"streaming": "Streamen"
|
"streaming": "Streamen",
|
||||||
|
"total-duration": "Totale duur",
|
||||||
|
"similar-albums": "Vergelijkbare albums"
|
||||||
}
|
}
|
||||||
@@ -57,4 +57,6 @@ export type LocaleKeys = 'play-next'
|
|||||||
| 'you-are-offline-message'
|
| 'you-are-offline-message'
|
||||||
| 'playing-on'
|
| 'playing-on'
|
||||||
| 'local-playback'
|
| 'local-playback'
|
||||||
| 'streaming'
|
| 'streaming'
|
||||||
|
| 'total-duration'
|
||||||
|
| 'similar-albums'
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import useDefaultStyles from 'components/Colors';
|
import useDefaultStyles from 'components/Colors';
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { FlatListProps, View } from 'react-native';
|
import { FlatListProps, View } from 'react-native';
|
||||||
import { FlatList } from 'react-native-gesture-handler';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useAppDispatch, useTypedSelector } from 'store';
|
import { useAppDispatch, useTypedSelector } from 'store';
|
||||||
import formatBytes from 'utility/formatBytes';
|
import formatBytes from 'utility/formatBytes';
|
||||||
@@ -17,6 +16,7 @@ import { Text } from 'components/Typography';
|
|||||||
import FastImage from 'react-native-fast-image';
|
import FastImage from 'react-native-fast-image';
|
||||||
import { useGetImage } from 'utility/JellyfinApi';
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
import { ShadowWrapper } from 'components/Shadow';
|
import { ShadowWrapper } from 'components/Shadow';
|
||||||
|
import { SafeFlatList } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
const DownloadedTrack = styled.View`
|
const DownloadedTrack = styled.View`
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
@@ -82,7 +82,7 @@ function Downloads() {
|
|||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
|
{t('total-download-size')}{': '}{formatBytes(totalDownloadSize)}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
icon={TrashIcon}
|
icon={TrashIcon}
|
||||||
@@ -151,10 +151,10 @@ function Downloads() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
{ListHeaderComponent}
|
{ListHeaderComponent}
|
||||||
<FlatList
|
<SafeFlatList
|
||||||
data={ids}
|
data={ids}
|
||||||
style={{ flex: 1, paddingTop: 12 }}
|
style={{ flex: 1, paddingTop: 12 }}
|
||||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
|
contentContainerStyle={{ flexGrow: 1 }}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createStackNavigator } from '@react-navigation/stack';
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { MusicStackParams } from './types';
|
|
||||||
import Albums from './stacks/Albums';
|
|
||||||
import Album from './stacks/Album';
|
|
||||||
import RecentAlbums from './stacks/RecentAlbums';
|
|
||||||
import { THEME_COLOR } from 'CONSTANTS';
|
import { THEME_COLOR } from 'CONSTANTS';
|
||||||
import { t } from '@localisation';
|
import { t } from '@localisation';
|
||||||
import useDefaultStyles from 'components/Colors';
|
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||||
import Playlists from './stacks/Playlists';
|
import { StackParams } from 'screens/types';
|
||||||
import Playlist from './stacks/Playlist';
|
|
||||||
import NowPlaying from './overlays/NowPlaying';
|
import NowPlaying from './overlays/NowPlaying';
|
||||||
|
|
||||||
const Stack = createStackNavigator<MusicStackParams>();
|
import RecentAlbums from './stacks/RecentAlbums';
|
||||||
|
import Albums from './stacks/Albums';
|
||||||
|
import Album from './stacks/Album';
|
||||||
|
import Playlists from './stacks/Playlists';
|
||||||
|
import Playlist from './stacks/Playlist';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator<StackParams>();
|
||||||
|
|
||||||
function MusicStack() {
|
function MusicStack() {
|
||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
@@ -23,8 +25,10 @@ function MusicStack() {
|
|||||||
headerTintColor: THEME_COLOR,
|
headerTintColor: THEME_COLOR,
|
||||||
headerTitleStyle: defaultStyles.stackHeader,
|
headerTitleStyle: defaultStyles.stackHeader,
|
||||||
cardStyle: defaultStyles.view,
|
cardStyle: defaultStyles.view,
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
|
||||||
}}>
|
}}>
|
||||||
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums') }} />
|
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums'), headerShown: false }} />
|
||||||
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
|
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
|
||||||
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
|
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
|
||||||
<Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />
|
<Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
|||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { calculateProgressTranslation } from 'components/Progresstrack';
|
import { calculateProgressTranslation } from 'components/Progresstrack';
|
||||||
import { THEME_COLOR } from 'CONSTANTS';
|
import { THEME_COLOR } from 'CONSTANTS';
|
||||||
import { MusicNavigationProp } from 'screens/Music/types';
|
import { NavigationProp } from 'screens/types';
|
||||||
import { ShadowWrapper } from 'components/Shadow';
|
import { ShadowWrapper } from 'components/Shadow';
|
||||||
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
|
|
||||||
const NOW_PLAYING_POPOVER_MARGIN = 6;
|
const NOW_PLAYING_POPOVER_MARGIN = 6;
|
||||||
const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN;
|
const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN;
|
||||||
|
|
||||||
const PopoverPosition = css`
|
const PopoverPosition = css`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: ${NOW_PLAYING_POPOVER_MARGIN}px;
|
|
||||||
left: ${NOW_PLAYING_POPOVER_MARGIN}px;
|
left: ${NOW_PLAYING_POPOVER_MARGIN}px;
|
||||||
right: ${NOW_PLAYING_POPOVER_MARGIN}px;
|
right: ${NOW_PLAYING_POPOVER_MARGIN}px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -111,10 +111,11 @@ function NowPlaying() {
|
|||||||
const { index, track } = useCurrentTrack();
|
const { index, track } = useCurrentTrack();
|
||||||
const { buffered, position } = useProgress();
|
const { buffered, position } = useProgress();
|
||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
|
const tabBarHeight = useBottomTabBarHeight();
|
||||||
const previousBuffered = usePrevious(buffered);
|
const previousBuffered = usePrevious(buffered);
|
||||||
const previousPosition = usePrevious(position);
|
const previousPosition = usePrevious(position);
|
||||||
|
|
||||||
const navigation = useNavigation<MusicNavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
|
|
||||||
const bufferAnimation = useRef(new Animated.Value(0));
|
const bufferAnimation = useRef(new Animated.Value(0));
|
||||||
const progressAnimation = useRef(new Animated.Value(0));
|
const progressAnimation = useRef(new Animated.Value(0));
|
||||||
@@ -164,7 +165,7 @@ function NowPlaying() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container style={{ bottom: tabBarHeight + NOW_PLAYING_POPOVER_MARGIN }}>
|
||||||
{/** TODO: Fix shadow overflow on Android */}
|
{/** TODO: Fix shadow overflow on Android */}
|
||||||
{Platform.OS === 'ios' ? (
|
{Platform.OS === 'ios' ? (
|
||||||
<ShadowOverlay pointerEvents='none'>
|
<ShadowOverlay pointerEvents='none'>
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { MusicStackParams } from '../types';
|
import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
|
||||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
|
||||||
import { useAppDispatch, useTypedSelector } from 'store';
|
import { useAppDispatch, useTypedSelector } from 'store';
|
||||||
import TrackListView from './components/TrackListView';
|
import TrackListView from './components/TrackListView';
|
||||||
import { fetchTracksByAlbum } from 'store/music/actions';
|
import { fetchAlbum, fetchTracksByAlbum } from 'store/music/actions';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||||
import { t } from '@localisation';
|
import { t } from '@localisation';
|
||||||
|
import { NavigationProp, StackParams } from 'screens/types';
|
||||||
|
import { SubHeader, Text } from 'components/Typography';
|
||||||
|
import { ScrollView } from 'react-native-gesture-handler';
|
||||||
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { Dimensions, Pressable } from 'react-native';
|
||||||
|
import AlbumImage from './components/AlbumImage';
|
||||||
|
|
||||||
type Route = RouteProp<MusicStackParams, 'Album'>;
|
type Route = RouteProp<StackParams, 'Album'>;
|
||||||
|
|
||||||
|
const Screen = Dimensions.get('screen');
|
||||||
|
|
||||||
|
const Cover = styled(AlbumImage)`
|
||||||
|
height: ${Screen.width / 2.8};
|
||||||
|
width: ${Screen.width / 2.8};
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function SimilarAlbum({ id }: { id: string }) {
|
||||||
|
const navigation = useNavigation<NavigationProp>();
|
||||||
|
const getImage = useGetImage();
|
||||||
|
const album = useTypedSelector((state) => state.music.albums.entities[id]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
album && navigation.push('Album', { id, album });
|
||||||
|
}, [id, album, navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.5 : 1.0,
|
||||||
|
width: Screen.width / 2.8,
|
||||||
|
marginRight: 12
|
||||||
|
})}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
<Cover key={id} source={{ uri: getImage(id) }} />
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 13, marginBottom: 2 }}>{album?.Name}</Text>
|
||||||
|
<Text numberOfLines={1} style={{ opacity: 0.5, fontSize: 13 }}>{album?.Artists.join(', ')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Album: React.FC = () => {
|
const Album: React.FC = () => {
|
||||||
const { params: { id } } = useRoute<Route>();
|
const { params: { id } } = useRoute<Route>();
|
||||||
@@ -19,7 +59,10 @@ const Album: React.FC = () => {
|
|||||||
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
|
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
|
||||||
|
|
||||||
// Define a function for refreshing this entity
|
// Define a function for refreshing this entity
|
||||||
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]);
|
const refresh = useCallback(() => {
|
||||||
|
dispatch(fetchTracksByAlbum(id));
|
||||||
|
dispatch(fetchAlbum(id));
|
||||||
|
}, [id, dispatch]);
|
||||||
|
|
||||||
// Auto-fetch the track data periodically
|
// Auto-fetch the track data periodically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,7 +82,21 @@ const Album: React.FC = () => {
|
|||||||
shuffleButtonText={t('shuffle-album')}
|
shuffleButtonText={t('shuffle-album')}
|
||||||
downloadButtonText={t('download-album')}
|
downloadButtonText={t('download-album')}
|
||||||
deleteButtonText={t('delete-album')}
|
deleteButtonText={t('delete-album')}
|
||||||
/>
|
>
|
||||||
|
{album?.Overview ? (
|
||||||
|
<Text style={{ opacity: 0.5, lineHeight: 20, fontSize: 12, paddingBottom: 24 }}>{album?.Overview}</Text>
|
||||||
|
) : null}
|
||||||
|
{album?.Similar?.length ? (
|
||||||
|
<>
|
||||||
|
<SubHeader>{t('similar-albums')}</SubHeader>
|
||||||
|
<ScrollView horizontal style={{ marginLeft: -24, marginRight: -24, marginTop: 8 }} contentContainerStyle={{ paddingHorizontal: 24 }} showsHorizontalScrollIndicator={false}>
|
||||||
|
{album.Similar.map((id) => (
|
||||||
|
<SimilarAlbum id={id} key={id} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</TrackListView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react';
|
import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react';
|
||||||
import { useGetImage } from 'utility/JellyfinApi';
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
import { MusicNavigationProp } from '../types';
|
import { SectionList, View } from 'react-native';
|
||||||
import { SafeAreaView, SectionList, View } from 'react-native';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { useAppDispatch, useTypedSelector } from 'store';
|
import { useAppDispatch, useTypedSelector } from 'store';
|
||||||
@@ -17,6 +16,8 @@ import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
|||||||
import { Album } from 'store/music/types';
|
import { Album } from 'store/music/types';
|
||||||
import { Text } from 'components/Typography';
|
import { Text } from 'components/Typography';
|
||||||
import { ShadowWrapper } from 'components/Shadow';
|
import { ShadowWrapper } from 'components/Shadow';
|
||||||
|
import { NavigationProp } from 'screens/types';
|
||||||
|
import { SafeSectionList } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
const HeadingHeight = 50;
|
const HeadingHeight = 50;
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ const Albums: React.FC = () => {
|
|||||||
|
|
||||||
// Initialise helpers
|
// Initialise helpers
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigation = useNavigation<MusicNavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const getImage = useGetImage();
|
const getImage = useGetImage();
|
||||||
const listRef = useRef<SectionList<EntityId[]>>(null);
|
const listRef = useRef<SectionList<EntityId[]>>(null);
|
||||||
|
|
||||||
@@ -168,9 +169,9 @@ const Albums: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView>
|
<>
|
||||||
<AlphabetScroller onSelect={selectLetter} />
|
<AlphabetScroller onSelect={selectLetter} />
|
||||||
<SectionList
|
<SafeSectionList
|
||||||
sections={sections}
|
sections={sections}
|
||||||
refreshing={isLoading}
|
refreshing={isLoading}
|
||||||
onRefresh={retrieveData}
|
onRefresh={retrieveData}
|
||||||
@@ -180,7 +181,7 @@ const Albums: React.FC = () => {
|
|||||||
renderSectionHeader={generateSection}
|
renderSectionHeader={generateSection}
|
||||||
renderItem={generateItem}
|
renderItem={generateItem}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { MusicStackParams } from '../types';
|
|
||||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||||
import { useAppDispatch, useTypedSelector } from 'store';
|
import { useAppDispatch, useTypedSelector } from 'store';
|
||||||
import TrackListView from './components/TrackListView';
|
import TrackListView from './components/TrackListView';
|
||||||
@@ -7,8 +6,9 @@ import { fetchTracksByPlaylist } from 'store/music/actions';
|
|||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||||
import { t } from '@localisation';
|
import { t } from '@localisation';
|
||||||
|
import { StackParams } from 'screens/types';
|
||||||
|
|
||||||
type Route = RouteProp<MusicStackParams, 'Album'>;
|
type Route = RouteProp<StackParams, 'Album'>;
|
||||||
|
|
||||||
const Playlist: React.FC = () => {
|
const Playlist: React.FC = () => {
|
||||||
const { params: { id } } = useRoute<Route>();
|
const { params: { id } } = useRoute<Route>();
|
||||||
@@ -39,6 +39,7 @@ const Playlist: React.FC = () => {
|
|||||||
shuffleButtonText={t('shuffle-playlist')}
|
shuffleButtonText={t('shuffle-playlist')}
|
||||||
downloadButtonText={t('download-playlist')}
|
downloadButtonText={t('download-playlist')}
|
||||||
deleteButtonText={t('delete-playlist')}
|
deleteButtonText={t('delete-playlist')}
|
||||||
|
itemDisplayStyle='playlist'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
|
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
|
||||||
import { useGetImage } from 'utility/JellyfinApi';
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
import { MusicNavigationProp } from '../types';
|
import { Text, View, FlatList, ListRenderItem, RefreshControl } from 'react-native';
|
||||||
import { Text, View, FlatList, ListRenderItem } from 'react-native';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { useAppDispatch, useTypedSelector } from 'store';
|
import { useAppDispatch, useTypedSelector } from 'store';
|
||||||
@@ -11,6 +10,8 @@ import TouchableHandler from 'components/TouchableHandler';
|
|||||||
import AlbumImage, { AlbumItem } from './components/AlbumImage';
|
import AlbumImage, { AlbumItem } from './components/AlbumImage';
|
||||||
import { EntityId } from '@reduxjs/toolkit';
|
import { EntityId } from '@reduxjs/toolkit';
|
||||||
import useDefaultStyles from 'components/Colors';
|
import useDefaultStyles from 'components/Colors';
|
||||||
|
import { NavigationProp } from 'screens/types';
|
||||||
|
import { SafeFlatList, useNavigationOffsets } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
interface GeneratedAlbumItemProps {
|
interface GeneratedAlbumItemProps {
|
||||||
id: ReactText;
|
id: ReactText;
|
||||||
@@ -34,6 +35,8 @@ const GeneratedPlaylistItem = React.memo(function GeneratedPlaylistItem(props: G
|
|||||||
});
|
});
|
||||||
|
|
||||||
const Playlists: React.FC = () => {
|
const Playlists: React.FC = () => {
|
||||||
|
const offsets = useNavigationOffsets();
|
||||||
|
|
||||||
// Retrieve data from store
|
// Retrieve data from store
|
||||||
const { entities, ids } = useTypedSelector((state) => state.music.playlists);
|
const { entities, ids } = useTypedSelector((state) => state.music.playlists);
|
||||||
const isLoading = useTypedSelector((state) => state.music.playlists.isLoading);
|
const isLoading = useTypedSelector((state) => state.music.playlists.isLoading);
|
||||||
@@ -41,7 +44,7 @@ const Playlists: React.FC = () => {
|
|||||||
|
|
||||||
// Initialise helpers
|
// Initialise helpers
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigation = useNavigation<MusicNavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const getImage = useGetImage();
|
const getImage = useGetImage();
|
||||||
const listRef = useRef<FlatList<EntityId>>(null);
|
const listRef = useRef<FlatList<EntityId>>(null);
|
||||||
|
|
||||||
@@ -93,10 +96,11 @@ const Playlists: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<SafeFlatList
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isLoading} onRefresh={retrieveData} progressViewOffset={offsets.top} />
|
||||||
|
}
|
||||||
data={ids}
|
data={ids}
|
||||||
refreshing={isLoading}
|
|
||||||
onRefresh={retrieveData}
|
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
keyExtractor={(item, index) => `${item}_${index}`}
|
keyExtractor={(item, index) => `${item}_${index}`}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useGetImage } from 'utility/JellyfinApi';
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
import { MusicNavigationProp } from '../types';
|
import { Text, SafeAreaView, StyleSheet } from 'react-native';
|
||||||
import { Text, SafeAreaView, FlatList, StyleSheet } from 'react-native';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { useAppDispatch, useTypedSelector } from 'store';
|
import { useAppDispatch, useTypedSelector } from 'store';
|
||||||
import { fetchRecentAlbums } from 'store/music/actions';
|
import { fetchRecentAlbums } from 'store/music/actions';
|
||||||
@@ -17,6 +16,8 @@ import { Album } from 'store/music/types';
|
|||||||
import Divider from 'components/Divider';
|
import Divider from 'components/Divider';
|
||||||
import styled from 'styled-components/native';
|
import styled from 'styled-components/native';
|
||||||
import { ShadowWrapper } from 'components/Shadow';
|
import { ShadowWrapper } from 'components/Shadow';
|
||||||
|
import { NavigationProp } from 'screens/types';
|
||||||
|
import { SafeFlatList } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
columnWrapper: {
|
columnWrapper: {
|
||||||
@@ -31,7 +32,7 @@ const HeaderContainer = styled.View`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const NavigationHeader: React.FC = () => {
|
const NavigationHeader: React.FC = () => {
|
||||||
const navigation = useNavigation<MusicNavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
|
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
|
||||||
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]);
|
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]);
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ const RecentAlbums: React.FC = () => {
|
|||||||
|
|
||||||
// Initialise helpers
|
// Initialise helpers
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigation = useNavigation<MusicNavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const getImage = useGetImage();
|
const getImage = useGetImage();
|
||||||
|
|
||||||
// Set callbacks
|
// Set callbacks
|
||||||
@@ -71,7 +72,7 @@ const RecentAlbums: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView>
|
<SafeAreaView>
|
||||||
<FlatList
|
<SafeFlatList
|
||||||
data={recentAlbums as string[]}
|
data={recentAlbums as string[]}
|
||||||
refreshing={isLoading}
|
refreshing={isLoading}
|
||||||
onRefresh={retrieveData}
|
onRefresh={retrieveData}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function AlbumImage(props: FastImageProps) {
|
|||||||
|
|
||||||
if (!props.source || hasError) {
|
if (!props.source || hasError) {
|
||||||
return (
|
return (
|
||||||
<Container source={colorScheme === 'light' ? require('assets/images/empty-album-light.png') : require('assets/images/empty-album-dark.png')} />
|
<Container {...props} source={colorScheme === 'light' ? require('assets/images/empty-album-light.png') : require('assets/images/empty-album-dark.png')} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { PropsWithChildren, useCallback, useMemo } from 'react';
|
||||||
import { ScrollView, RefreshControl, StyleSheet, View } from 'react-native';
|
import { Platform, RefreshControl, StyleSheet, View } from 'react-native';
|
||||||
import { useGetImage } from 'utility/JellyfinApi';
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
import styled, { css } from 'styled-components/native';
|
import styled, { css } from 'styled-components/native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
@@ -14,7 +14,7 @@ import useDefaultStyles from 'components/Colors';
|
|||||||
import usePlayTracks from 'utility/usePlayTracks';
|
import usePlayTracks from 'utility/usePlayTracks';
|
||||||
import { EntityId } from '@reduxjs/toolkit';
|
import { EntityId } from '@reduxjs/toolkit';
|
||||||
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
|
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
|
||||||
import { MusicNavigationProp } from 'screens/Music/types';
|
import { NavigationProp } from 'screens/types';
|
||||||
import DownloadIcon from 'components/DownloadIcon';
|
import DownloadIcon from 'components/DownloadIcon';
|
||||||
import CloudDownArrow from 'assets/icons/cloud-down-arrow.svg';
|
import CloudDownArrow from 'assets/icons/cloud-down-arrow.svg';
|
||||||
import Trash from 'assets/icons/trash.svg';
|
import Trash from 'assets/icons/trash.svg';
|
||||||
@@ -25,6 +25,8 @@ import { Text } from 'components/Typography';
|
|||||||
|
|
||||||
import CoverImage from 'components/CoverImage';
|
import CoverImage from 'components/CoverImage';
|
||||||
import ticksToDuration from 'utility/ticksToDuration';
|
import ticksToDuration from 'utility/ticksToDuration';
|
||||||
|
import { t } from '@localisation';
|
||||||
|
import { SafeScrollView, useNavigationOffsets } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
index: {
|
index: {
|
||||||
@@ -42,18 +44,23 @@ const AlbumImageContainer = styled.View`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const TrackContainer = styled.View<{ isPlaying: boolean }>`
|
const TrackContainer = styled.View<{ isPlaying: boolean, small?: boolean }>`
|
||||||
padding: 12px 4px;
|
padding: 12px 4px;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
${props => props.isPlaying && css`
|
${props => props.isPlaying && css`
|
||||||
margin: 0 -12px;
|
margin: 0 -12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
`}
|
`}
|
||||||
|
|
||||||
|
${props => props.small && css`
|
||||||
|
padding: ${Platform.select({ ios: '8px 4px', android: '4px'})};
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface TrackListViewProps {
|
export interface TrackListViewProps extends PropsWithChildren<{}> {
|
||||||
title?: string;
|
title?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
trackIds: EntityId[];
|
trackIds: EntityId[];
|
||||||
@@ -64,6 +71,7 @@ interface TrackListViewProps {
|
|||||||
downloadButtonText: string;
|
downloadButtonText: string;
|
||||||
deleteButtonText: string;
|
deleteButtonText: string;
|
||||||
listNumberingStyle?: 'album' | 'index';
|
listNumberingStyle?: 'album' | 'index';
|
||||||
|
itemDisplayStyle?: 'album' | 'playlist';
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrackListView: React.FC<TrackListViewProps> = ({
|
const TrackListView: React.FC<TrackListViewProps> = ({
|
||||||
@@ -77,19 +85,27 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
|||||||
downloadButtonText,
|
downloadButtonText,
|
||||||
deleteButtonText,
|
deleteButtonText,
|
||||||
listNumberingStyle = 'album',
|
listNumberingStyle = 'album',
|
||||||
|
itemDisplayStyle = 'album',
|
||||||
|
children
|
||||||
}) => {
|
}) => {
|
||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
|
const offsets = useNavigationOffsets();
|
||||||
|
|
||||||
// Retrieve state
|
// Retrieve state
|
||||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||||
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
|
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
|
||||||
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
|
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
|
||||||
|
const totalDuration = useMemo(() => (
|
||||||
|
trackIds.reduce<number>((sum, trackId) => (
|
||||||
|
sum + (tracks[trackId]?.RunTimeTicks || 0)
|
||||||
|
), 0)
|
||||||
|
), [trackIds, tracks]);
|
||||||
|
|
||||||
// Retrieve helpers
|
// Retrieve helpers
|
||||||
const getImage = useGetImage();
|
const getImage = useGetImage();
|
||||||
const playTracks = usePlayTracks();
|
const playTracks = usePlayTracks();
|
||||||
const { track: currentTrack } = useCurrentTrack();
|
const { track: currentTrack } = useCurrentTrack();
|
||||||
const navigation = useNavigation<MusicNavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
// Setup callbacks
|
// Setup callbacks
|
||||||
@@ -101,7 +117,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
|||||||
await TrackPlayer.play();
|
await TrackPlayer.play();
|
||||||
}, [playTracks, trackIds]);
|
}, [playTracks, trackIds]);
|
||||||
const longPressTrack = useCallback((index: number) => {
|
const longPressTrack = useCallback((index: number) => {
|
||||||
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
|
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index].toString() });
|
||||||
}, [navigation, trackIds]);
|
}, [navigation, trackIds]);
|
||||||
const downloadAllTracks = useCallback(() => {
|
const downloadAllTracks = useCallback(() => {
|
||||||
trackIds.forEach((trackId) => dispatch(queueTrackForDownload(trackId)));
|
trackIds.forEach((trackId) => dispatch(queueTrackForDownload(trackId)));
|
||||||
@@ -111,90 +127,109 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
|||||||
}, [dispatch, downloadedTracks]);
|
}, [dispatch, downloadedTracks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<SafeScrollView
|
||||||
style={defaultStyles.view}
|
style={defaultStyles.view}
|
||||||
contentContainerStyle={{ padding: 24, paddingTop: 32, paddingBottom: 64 }}
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
|
<RefreshControl refreshing={isLoading} onRefresh={refresh} progressViewOffset={offsets.top} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AlbumImageContainer>
|
<View style={{ padding: 24, paddingTop: 32, paddingBottom: 32 }}>
|
||||||
<CoverImage src={getImage(entityId)} />
|
<AlbumImageContainer>
|
||||||
</AlbumImageContainer>
|
<CoverImage src={getImage(entityId)} />
|
||||||
<Header>{title}</Header>
|
</AlbumImageContainer>
|
||||||
<SubHeader>{artist}</SubHeader>
|
<Header>{title}</Header>
|
||||||
<WrappableButtonRow>
|
<SubHeader>{artist}</SubHeader>
|
||||||
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} testID="play-album" />
|
<WrappableButtonRow>
|
||||||
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} testID="shuffle-album" />
|
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} testID="play-album" />
|
||||||
</WrappableButtonRow>
|
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} testID="shuffle-album" />
|
||||||
<View style={{ marginTop: 8 }}>
|
</WrappableButtonRow>
|
||||||
{trackIds.map((trackId, i) =>
|
<View style={{ marginTop: 8 }}>
|
||||||
<TouchableHandler
|
{trackIds.map((trackId, i) =>
|
||||||
key={trackId}
|
<TouchableHandler
|
||||||
id={i}
|
key={trackId}
|
||||||
onPress={selectTrack}
|
id={i}
|
||||||
onLongPress={longPressTrack}
|
onPress={selectTrack}
|
||||||
testID={`play-track-${trackId}`}
|
onLongPress={longPressTrack}
|
||||||
>
|
testID={`play-track-${trackId}`}
|
||||||
<TrackContainer
|
|
||||||
isPlaying={currentTrack?.backendId === trackId || false}
|
|
||||||
style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
|
|
||||||
>
|
>
|
||||||
<Text
|
<TrackContainer
|
||||||
style={[
|
isPlaying={currentTrack?.backendId === trackId || false}
|
||||||
styles.index,
|
style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
|
||||||
{ opacity: 0.25 },
|
small={itemDisplayStyle === 'playlist'}
|
||||||
currentTrack?.backendId === trackId && styles.activeText
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
>
|
||||||
{listNumberingStyle === 'index'
|
|
||||||
? i + 1
|
|
||||||
: tracks[trackId]?.IndexNumber}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
...currentTrack?.backendId === trackId && styles.activeText,
|
|
||||||
flexShrink: 1,
|
|
||||||
marginRight: 4,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{tracks[trackId]?.Name}
|
|
||||||
</Text>
|
|
||||||
<View style={{ marginLeft: 'auto', flexDirection: 'row' }}>
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
{ marginRight: 12, opacity: 0.25 },
|
styles.index,
|
||||||
|
{ opacity: 0.25 },
|
||||||
currentTrack?.backendId === trackId && styles.activeText
|
currentTrack?.backendId === trackId && styles.activeText
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{ticksToDuration(tracks[trackId]?.RunTimeTicks || 0)}
|
{listNumberingStyle === 'index'
|
||||||
|
? i + 1
|
||||||
|
: tracks[trackId]?.IndexNumber}
|
||||||
</Text>
|
</Text>
|
||||||
<DownloadIcon trackId={trackId} fill={currentTrack?.backendId === trackId ? `${THEME_COLOR}44` : undefined} />
|
<View style={{ flexShrink: 1 }}>
|
||||||
</View>
|
<Text
|
||||||
</TrackContainer>
|
style={{
|
||||||
</TouchableHandler>
|
...currentTrack?.backendId === trackId && styles.activeText,
|
||||||
)}
|
flexShrink: 1,
|
||||||
<WrappableButtonRow style={{ marginTop: 24 }}>
|
marginRight: 4,
|
||||||
<WrappableButton
|
}}
|
||||||
icon={CloudDownArrow}
|
numberOfLines={1}
|
||||||
title={downloadButtonText}
|
>
|
||||||
onPress={downloadAllTracks}
|
{tracks[trackId]?.Name}
|
||||||
disabled={downloadedTracks.length === trackIds.length}
|
</Text>
|
||||||
testID="download-album"
|
{itemDisplayStyle === 'playlist' && (
|
||||||
/>
|
<Text
|
||||||
<WrappableButton
|
style={{
|
||||||
icon={Trash}
|
...currentTrack?.backendId === trackId && styles.activeText,
|
||||||
title={deleteButtonText}
|
flexShrink: 1,
|
||||||
onPress={deleteAllTracks}
|
marginRight: 4,
|
||||||
disabled={downloadedTracks.length === 0}
|
opacity:currentTrack?.backendId === trackId ? 0.5 : 0.25,
|
||||||
testID="delete-album"
|
}}
|
||||||
/>
|
numberOfLines={1}
|
||||||
</WrappableButtonRow>
|
>
|
||||||
|
{tracks[trackId]?.Artists.join(', ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={{ marginLeft: 'auto', flexDirection: 'row' }}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
{ marginRight: 12, opacity: 0.25 },
|
||||||
|
currentTrack?.backendId === trackId && styles.activeText
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{ticksToDuration(tracks[trackId]?.RunTimeTicks || 0)}
|
||||||
|
</Text>
|
||||||
|
<DownloadIcon trackId={trackId} fill={currentTrack?.backendId === trackId ? `${THEME_COLOR}44` : undefined} />
|
||||||
|
</View>
|
||||||
|
</TrackContainer>
|
||||||
|
</TouchableHandler>
|
||||||
|
)}
|
||||||
|
<Text style={{ paddingTop: 24, paddingBottom: 12, textAlign: 'center', opacity: 0.5 }}>{t('total-duration')}{': '}{ticksToDuration(totalDuration)}</Text>
|
||||||
|
<WrappableButtonRow style={{ marginTop: 24 }}>
|
||||||
|
<WrappableButton
|
||||||
|
icon={CloudDownArrow}
|
||||||
|
title={downloadButtonText}
|
||||||
|
onPress={downloadAllTracks}
|
||||||
|
disabled={downloadedTracks.length === trackIds.length}
|
||||||
|
testID="download-album"
|
||||||
|
/>
|
||||||
|
<WrappableButton
|
||||||
|
icon={Trash}
|
||||||
|
title={deleteButtonText}
|
||||||
|
onPress={deleteAllTracks}
|
||||||
|
disabled={downloadedTracks.length === 0}
|
||||||
|
testID="delete-album"
|
||||||
|
/>
|
||||||
|
</WrappableButtonRow>
|
||||||
|
</View>
|
||||||
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</SafeScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,328 +1,31 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React from 'react';
|
||||||
import Input from 'components/Input';
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
import { ActivityIndicator, Animated, SafeAreaView, View } from 'react-native';
|
import { THEME_COLOR } from 'CONSTANTS';
|
||||||
import styled from 'styled-components/native';
|
|
||||||
import { useAppDispatch, useTypedSelector } from 'store';
|
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
import { Album, AlbumTrack } from 'store/music/types';
|
|
||||||
import { FlatList } from 'react-native-gesture-handler';
|
|
||||||
import TouchableHandler from 'components/TouchableHandler';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { useGetImage } from 'utility/JellyfinApi';
|
|
||||||
import FastImage from 'react-native-fast-image';
|
|
||||||
import { t } from '@localisation';
|
import { t } from '@localisation';
|
||||||
import useDefaultStyles from 'components/Colors';
|
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||||
import { searchAndFetchAlbums } from 'store/music/actions';
|
import { StackParams } from 'screens/types';
|
||||||
import { debounce } from 'lodash';
|
import Search from './stacks/Search';
|
||||||
import { Text } from 'components/Typography';
|
import Album from 'screens/Music/stacks/Album';
|
||||||
import { MusicNavigationProp } from 'screens/Music/types';
|
import { StyleSheet } from 'react-native';
|
||||||
import DownloadIcon from 'components/DownloadIcon';
|
|
||||||
import ChevronRight from 'assets/icons/chevron-right.svg';
|
|
||||||
import SearchIcon from 'assets/icons/magnifying-glass.svg';
|
|
||||||
import { ShadowWrapper } from 'components/Shadow';
|
|
||||||
import { useKeyboardHeight } from 'utility/useKeyboardHeight';
|
|
||||||
// import MicrophoneIcon from 'assets/icons/microphone.svg';
|
|
||||||
// import AlbumIcon from 'assets/icons/collection.svg';
|
|
||||||
// import TrackIcon from 'assets/icons/note.svg';
|
|
||||||
// import PlaylistIcon from 'assets/icons/note-list.svg';
|
|
||||||
// import StreamIcon from 'assets/icons/cloud.svg';
|
|
||||||
// import LocalIcon from 'assets/icons/internal-drive.svg';
|
|
||||||
// import SelectableFilter from './components/SelectableFilter';
|
|
||||||
|
|
||||||
const Container = styled(Animated.View)`
|
const Stack = createStackNavigator<StackParams>();
|
||||||
padding: 4px 32px 0 32px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
border-top-width: 0.5px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FullSizeContainer = styled.View`
|
function SearchStack() {
|
||||||
flex: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Loading = styled.View`
|
|
||||||
position: absolute;
|
|
||||||
right: 12px;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AlbumImage = styled(FastImage)`
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
margin-right: 10px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const HalfOpacity = styled.Text`
|
|
||||||
opacity: 0.5;
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 12px;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SearchResult = styled.View`
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 32px;
|
|
||||||
height: 54px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const fuseOptions: Fuse.IFuseOptions<Album> = {
|
|
||||||
keys: ['Name', 'AlbumArtist', 'AlbumArtists', 'Artists'],
|
|
||||||
threshold: 0.1,
|
|
||||||
includeScore: true,
|
|
||||||
fieldNormWeight: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
type AudioResult = {
|
|
||||||
type: 'Audio',
|
|
||||||
id: string;
|
|
||||||
album: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AlbumResult = {
|
|
||||||
type: 'AlbumArtist',
|
|
||||||
id: string;
|
|
||||||
album: undefined;
|
|
||||||
name: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CombinedResults = (AudioResult | AlbumResult)[];
|
|
||||||
|
|
||||||
export default function Search() {
|
|
||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
|
|
||||||
// Prepare state for fuse and albums
|
|
||||||
const [fuseIsReady, setFuseReady] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [isLoading, setLoading] = useState(false);
|
|
||||||
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
|
|
||||||
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
|
|
||||||
const albums = useTypedSelector(state => state.music.albums.entities);
|
|
||||||
const fuse = useRef<Fuse<Album>>();
|
|
||||||
|
|
||||||
// Prepare helpers
|
|
||||||
const navigation = useNavigation<MusicNavigationProp>();
|
|
||||||
const keyboardHeight = useKeyboardHeight();
|
|
||||||
const getImage = useGetImage();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Since it is impractical to have a global fuse variable, we need to
|
|
||||||
* instantiate it for thsi function. With this effect, we generate a new
|
|
||||||
* Fuse instance every time the albums change. This can of course be done
|
|
||||||
* more intelligently by removing and adding the changed albums, but this is
|
|
||||||
* an open todo.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
|
|
||||||
setFuseReady(true);
|
|
||||||
}, [albums, setFuseReady]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function retrieves search results from Jellyfin. It is a seperate
|
|
||||||
* callback, so that we can make sure it is properly debounced and doesn't
|
|
||||||
* cause execessive jank in the interface.
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string, currentResults: CombinedResults) => {
|
|
||||||
// First, query the Jellyfin API
|
|
||||||
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm }));
|
|
||||||
|
|
||||||
// Convert the current results to album ids
|
|
||||||
const albumIds = currentResults.map(item => item.id);
|
|
||||||
|
|
||||||
// Parse the result in correct typescript form
|
|
||||||
const results = (payload as { results: (Album | AlbumTrack)[] }).results;
|
|
||||||
|
|
||||||
// Filter any results that are already displayed
|
|
||||||
const items = results.filter(item => (
|
|
||||||
!(item.Type === 'MusicAlbum' && albumIds.includes(item.Id))
|
|
||||||
// Then convert the results to proper result form
|
|
||||||
)).map((item) => ({
|
|
||||||
type: item.Type,
|
|
||||||
id: item.Id,
|
|
||||||
album: item.Type === 'Audio'
|
|
||||||
? item.AlbumId
|
|
||||||
: undefined,
|
|
||||||
name: item.Type === 'Audio'
|
|
||||||
? item.Name
|
|
||||||
: undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Lastly, we'll merge the two and assign them to the state
|
|
||||||
setJellyfinResults([...items] as CombinedResults);
|
|
||||||
|
|
||||||
// Loading is now complete
|
|
||||||
setLoading(false);
|
|
||||||
}, 50), [dispatch, setJellyfinResults]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whenever the search term changes, we gather results from Fuse and assign
|
|
||||||
* them to state
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!searchTerm) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const retrieveResults = async () => {
|
|
||||||
// GUARD: In some extraordinary cases, Fuse might not be presented since
|
|
||||||
// it is assigned via refs. In this case, we can't handle any searching.
|
|
||||||
if (!fuse.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First set the immediate results from fuse
|
|
||||||
const fuseResults = fuse.current.search(searchTerm);
|
|
||||||
const albums: AlbumResult[] = fuseResults
|
|
||||||
.map(({ item }) => ({
|
|
||||||
id: item.Id,
|
|
||||||
type: 'AlbumArtist',
|
|
||||||
album: undefined,
|
|
||||||
name: undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Assign the preliminary results
|
|
||||||
setFuseResults(albums);
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// Wrap the call in a try/catch block so that we catch any
|
|
||||||
// network issues in search and just use local search if the
|
|
||||||
// network is unavailable
|
|
||||||
fetchJellyfinResults(searchTerm, albums);
|
|
||||||
} catch {
|
|
||||||
// Reset the loading indicator if the network fails
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
retrieveResults();
|
|
||||||
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
const selectAlbum = useCallback((id: string) =>
|
|
||||||
navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]
|
|
||||||
);
|
|
||||||
|
|
||||||
const HeaderComponent = React.useMemo(() => (
|
|
||||||
<View>
|
|
||||||
<Container style={[
|
|
||||||
defaultStyles.border,
|
|
||||||
defaultStyles.view,
|
|
||||||
{ transform: [{ translateY: keyboardHeight }]},
|
|
||||||
]}>
|
|
||||||
<View>
|
|
||||||
<Input
|
|
||||||
value={searchTerm}
|
|
||||||
onChangeText={setSearchTerm}
|
|
||||||
style={[defaultStyles.input, { marginBottom: 12 }]}
|
|
||||||
placeholder={t('search') + '...'}
|
|
||||||
icon={<SearchIcon width={14} height={14} fill={defaultStyles.textHalfOpacity.color} />}
|
|
||||||
testID="search-input"
|
|
||||||
/>
|
|
||||||
{isLoading && <Loading><ActivityIndicator /></Loading>}
|
|
||||||
</View>
|
|
||||||
</Container>
|
|
||||||
{/* <ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View style={{ paddingHorizontal: 32, paddingBottom: 12, flex: 1, flexDirection: 'row' }}>
|
|
||||||
<SelectableFilter
|
|
||||||
text="Artists"
|
|
||||||
icon={MicrophoneIcon}
|
|
||||||
active
|
|
||||||
/>
|
|
||||||
<SelectableFilter
|
|
||||||
text="Albums"
|
|
||||||
icon={AlbumIcon}
|
|
||||||
active={false}
|
|
||||||
/>
|
|
||||||
<SelectableFilter
|
|
||||||
text="Tracks"
|
|
||||||
icon={TrackIcon}
|
|
||||||
active={false}
|
|
||||||
/>
|
|
||||||
<SelectableFilter
|
|
||||||
text="Playlist"
|
|
||||||
icon={PlaylistIcon}
|
|
||||||
active={false}
|
|
||||||
/>
|
|
||||||
<SelectableFilter
|
|
||||||
text="Streaming"
|
|
||||||
icon={StreamIcon}
|
|
||||||
active={false}
|
|
||||||
/>
|
|
||||||
<SelectableFilter
|
|
||||||
text="Local Playback"
|
|
||||||
icon={LocalIcon}
|
|
||||||
active={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ScrollView> */}
|
|
||||||
</View>
|
|
||||||
), [searchTerm, setSearchTerm, defaultStyles, isLoading, keyboardHeight]);
|
|
||||||
|
|
||||||
// GUARD: We cannot search for stuff unless Fuse is loaded with results.
|
|
||||||
// Therefore we delay rendering to when we are certain it's there.
|
|
||||||
if (!fuseIsReady) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<Stack.Navigator initialRouteName="Search" screenOptions={{
|
||||||
<FlatList
|
headerTintColor: THEME_COLOR,
|
||||||
style={{ flex: 2 }}
|
headerTitleStyle: defaultStyles.stackHeader,
|
||||||
data={[...jellyfinResults, ...fuseResults]}
|
cardStyle: defaultStyles.view,
|
||||||
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
|
headerTransparent: true,
|
||||||
const album = albums[trackAlbum || id];
|
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
|
||||||
|
|
||||||
// GUARD: If the album cannot be found in the store, we
|
}}>
|
||||||
// cannot display it.
|
<Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search'), headerShown: false }} />
|
||||||
if (!album) {
|
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
|
||||||
return null;
|
</Stack.Navigator>
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableHandler<string> id={album.Id} onPress={selectAlbum} testID={`search-result-${album.Id}`}>
|
|
||||||
<SearchResult>
|
|
||||||
<ShadowWrapper>
|
|
||||||
<AlbumImage source={{ uri: getImage(album.Id) }} style={defaultStyles.imageBackground} />
|
|
||||||
</ShadowWrapper>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text numberOfLines={1}>
|
|
||||||
{trackName || album.Name}
|
|
||||||
</Text>
|
|
||||||
{(album.AlbumArtist || album.Name) && (
|
|
||||||
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
|
|
||||||
{type === 'AlbumArtist'
|
|
||||||
? `${t('album')} • ${album.AlbumArtist}`
|
|
||||||
: `${t('track')} • ${album.AlbumArtist} — ${album.Name}`
|
|
||||||
}
|
|
||||||
</HalfOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View style={{ marginLeft: 16 }}>
|
|
||||||
<DownloadIcon trackId={id} />
|
|
||||||
</View>
|
|
||||||
<View style={{ marginLeft: 16 }}>
|
|
||||||
<ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} />
|
|
||||||
</View>
|
|
||||||
</SearchResult>
|
|
||||||
</TouchableHandler>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
extraData={[searchTerm, albums]}
|
|
||||||
/>
|
|
||||||
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
|
|
||||||
<FullSizeContainer>
|
|
||||||
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
|
|
||||||
</FullSizeContainer>
|
|
||||||
) : null}
|
|
||||||
{HeaderComponent}
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default SearchStack;
|
||||||
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 { t } from '@localisation';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
import styled from 'styled-components/native';
|
import styled from 'styled-components/native';
|
||||||
import { Text } from 'components/Typography';
|
import { Paragraph } from 'components/Typography';
|
||||||
import { useAppDispatch } from 'store';
|
import { useAppDispatch } from 'store';
|
||||||
|
import { SafeScrollView } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
const ClearCache = styled(Button)`
|
const ClearCache = styled(Button)`
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Container = styled.ScrollView`
|
const Container = styled(SafeScrollView)`
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default function CacheSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Text>{t('setting-cache-description')}</Text>
|
<Paragraph>{t('setting-cache-description')}</Paragraph>
|
||||||
<ClearCache title={t('reset-cache')} onPress={handleClearCache} />
|
<ClearCache title={t('reset-cache')} onPress={handleClearCache} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { NavigationProp } from '../..';
|
|||||||
import { useTypedSelector } from 'store';
|
import { useTypedSelector } from 'store';
|
||||||
import { t } from '@localisation';
|
import { t } from '@localisation';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
import { Text } from 'components/Typography';
|
import { Paragraph } from 'components/Typography';
|
||||||
|
import { SafeScrollView } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
const InputContainer = styled.View`
|
const InputContainer = styled.View`
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
@@ -19,7 +19,7 @@ const Input = styled.TextInput`
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Container = styled.ScrollView`
|
const Container = styled(SafeScrollView)`
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -32,15 +32,15 @@ export default function LibrarySettings() {
|
|||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<InputContainer>
|
<InputContainer>
|
||||||
<Text style={defaultStyles.text}>{t('jellyfin-server-url')}</Text>
|
<Paragraph style={defaultStyles.text}>{t('jellyfin-server-url')}</Paragraph>
|
||||||
<Input placeholder="https://jellyfin.yourserver.com/" value={jellyfin?.uri} editable={false} style={defaultStyles.input} />
|
<Input placeholder="https://jellyfin.yourserver.com/" value={jellyfin?.uri} editable={false} style={defaultStyles.input} />
|
||||||
</InputContainer>
|
</InputContainer>
|
||||||
<InputContainer>
|
<InputContainer>
|
||||||
<Text style={defaultStyles.text}>{t('jellyfin-access-token')}</Text>
|
<Paragraph style={defaultStyles.text}>{t('jellyfin-access-token')}</Paragraph>
|
||||||
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={defaultStyles.input} />
|
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={defaultStyles.input} />
|
||||||
</InputContainer>
|
</InputContainer>
|
||||||
<InputContainer>
|
<InputContainer>
|
||||||
<Text style={defaultStyles.text}>{t('jellyfin-user-id')}</Text>
|
<Paragraph style={defaultStyles.text}>{t('jellyfin-user-id')}</Paragraph>
|
||||||
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={defaultStyles.input} />
|
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={defaultStyles.input} />
|
||||||
</InputContainer>
|
</InputContainer>
|
||||||
<Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} />
|
<Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Text } from 'components/Typography';
|
import { Paragraph, Text } from 'components/Typography';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Switch } from 'react-native-gesture-handler';
|
import { Switch } from 'react-native-gesture-handler';
|
||||||
@@ -9,8 +9,9 @@ import ChevronIcon from 'assets/icons/chevron-right.svg';
|
|||||||
import { THEME_COLOR } from 'CONSTANTS';
|
import { THEME_COLOR } from 'CONSTANTS';
|
||||||
import useDefaultStyles, { DefaultStylesProvider } from 'components/Colors';
|
import useDefaultStyles, { DefaultStylesProvider } from 'components/Colors';
|
||||||
import { t } from '@localisation';
|
import { t } from '@localisation';
|
||||||
|
import { SafeScrollView } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
const Container = styled.ScrollView`
|
const Container = styled.View`
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -25,7 +26,9 @@ const HeaderContainer = styled.View<{ isActive?: boolean }>`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 16px 0 4px 0;
|
padding: 16px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
${props => props.isActive && css`
|
${props => props.isActive && css`
|
||||||
background-color: ${THEME_COLOR};
|
background-color: ${THEME_COLOR};
|
||||||
@@ -37,7 +40,8 @@ const HeaderText = styled(Text)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const ContentContainer = styled.View`
|
const ContentContainer = styled.View`
|
||||||
margin-top: 8px;
|
margin-bottom: 8px;
|
||||||
|
padding: 8px 24px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Label = styled(Text)`
|
const Label = styled(Text)`
|
||||||
@@ -87,7 +91,7 @@ function renderHeader(question: Question, index: number, isActive: boolean) {
|
|||||||
function renderContent(question: Question) {
|
function renderContent(question: Question) {
|
||||||
return (
|
return (
|
||||||
<ContentContainer>
|
<ContentContainer>
|
||||||
<Text>{question.content}</Text>
|
<Paragraph>{question.content}</Paragraph>
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,14 +108,17 @@ export default function Sentry() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<SafeScrollView>
|
||||||
<Text>{t('error-reporting-description')}</Text>
|
<Container>
|
||||||
<Text />
|
<Paragraph>{t('error-reporting-description')}</Paragraph>
|
||||||
<Text>{t('error-reporting-rationale')}</Text>
|
<Paragraph />
|
||||||
<SwitchContainer>
|
<Paragraph>{t('error-reporting-rationale')}</Paragraph>
|
||||||
<Label>{t('error-reporting')}</Label>
|
|
||||||
<Switch value={isReportingEnabled} onValueChange={toggleSwitch} />
|
<SwitchContainer>
|
||||||
</SwitchContainer>
|
<Label>{t('error-reporting')}</Label>
|
||||||
|
<Switch value={isReportingEnabled} onValueChange={toggleSwitch} />
|
||||||
|
</SwitchContainer>
|
||||||
|
</Container>
|
||||||
<Accordion
|
<Accordion
|
||||||
sections={questions}
|
sections={questions}
|
||||||
renderHeader={renderHeader}
|
renderHeader={renderHeader}
|
||||||
@@ -120,6 +127,6 @@ export default function Sentry() {
|
|||||||
onChange={setActiveSections}
|
onChange={setActiveSections}
|
||||||
underlayColor={defaultStyles.activeBackground.backgroundColor}
|
underlayColor={defaultStyles.activeBackground.backgroundColor}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</SafeScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { SafeAreaView, ScrollView } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import Library from './components/Library';
|
import Library from './components/Library';
|
||||||
import Cache from './components/Cache';
|
import Cache from './components/Cache';
|
||||||
import useDefaultStyles from 'components/Colors';
|
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||||
import { t } from '@localisation';
|
import { t } from '@localisation';
|
||||||
import { createStackNavigator } from '@react-navigation/stack';
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
@@ -10,6 +10,7 @@ import ListButton from 'components/ListButton';
|
|||||||
import { THEME_COLOR } from 'CONSTANTS';
|
import { THEME_COLOR } from 'CONSTANTS';
|
||||||
import Sentry from './components/Sentry';
|
import Sentry from './components/Sentry';
|
||||||
import { SettingsNavigationProp } from './types';
|
import { SettingsNavigationProp } from './types';
|
||||||
|
import { SafeScrollView } from 'components/SafeNavigatorView';
|
||||||
|
|
||||||
export function SettingsList() {
|
export function SettingsList() {
|
||||||
const navigation = useNavigation<SettingsNavigationProp>();
|
const navigation = useNavigation<SettingsNavigationProp>();
|
||||||
@@ -18,13 +19,11 @@ export function SettingsList() {
|
|||||||
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);
|
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<SafeScrollView>
|
||||||
<SafeAreaView>
|
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
|
||||||
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
|
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
|
||||||
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
|
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
|
||||||
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
|
</SafeScrollView>
|
||||||
</SafeAreaView>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +33,11 @@ export default function Settings() {
|
|||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator initialRouteName="SettingList" screenOptions={{
|
<Stack.Navigator initialRouteName="SettingList" screenOptions={{
|
||||||
headerTintColor: THEME_COLOR,
|
headerTintColor: THEME_COLOR,
|
||||||
headerTitleStyle: defaultStyles.stackHeader
|
headerTitleStyle: defaultStyles.stackHeader,
|
||||||
|
headerTransparent: true,
|
||||||
|
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
|
||||||
}}>
|
}}>
|
||||||
<Stack.Screen name="SettingList" component={SettingsList} options={{ headerTitle: t('settings') }} />
|
<Stack.Screen name="SettingList" component={SettingsList} options={{ headerTitle: t('settings') }} />
|
||||||
<Stack.Screen name="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} />
|
<Stack.Screen name="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|||||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||||
import { THEME_COLOR } from 'CONSTANTS';
|
import { THEME_COLOR } from 'CONSTANTS';
|
||||||
|
|
||||||
import Search from './Search';
|
import SearchStack from './Search';
|
||||||
import Music from './Music';
|
import Music from './Music';
|
||||||
import Settings from './Settings';
|
import Settings from './Settings';
|
||||||
import Downloads from './Downloads';
|
import Downloads from './Downloads';
|
||||||
@@ -18,13 +18,15 @@ import NotesIcon from 'assets/icons/notes.svg';
|
|||||||
import GearIcon from 'assets/icons/gear.svg';
|
import GearIcon from 'assets/icons/gear.svg';
|
||||||
import DownloadsIcon from 'assets/icons/arrow-down-to-line.svg';
|
import DownloadsIcon from 'assets/icons/arrow-down-to-line.svg';
|
||||||
import { useTypedSelector } from 'store';
|
import { useTypedSelector } from 'store';
|
||||||
import { ModalStackParams } from './types';
|
|
||||||
import { t } from '@localisation';
|
import { t } from '@localisation';
|
||||||
import ErrorReportingAlert from 'utility/ErrorReportingAlert';
|
import ErrorReportingAlert from 'utility/ErrorReportingAlert';
|
||||||
import ErrorReportingPopup from './modals/ErrorReportingPopup';
|
import ErrorReportingPopup from './modals/ErrorReportingPopup';
|
||||||
import Player from './modals/Player';
|
import Player from './modals/Player';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import { ColoredBlurView } from 'components/Colors';
|
||||||
|
import { StackParams } from './types';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<ModalStackParams>();
|
const Stack = createNativeStackNavigator<StackParams>();
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
type Screens = {
|
type Screens = {
|
||||||
@@ -48,9 +50,9 @@ function Screens() {
|
|||||||
screenOptions={({ route }) => ({
|
screenOptions={({ route }) => ({
|
||||||
tabBarIcon: function TabBarIcon({ color, size }) {
|
tabBarIcon: function TabBarIcon({ color, size }) {
|
||||||
switch (route.name) {
|
switch (route.name) {
|
||||||
case 'Search':
|
case 'SearchTab':
|
||||||
return <SearchIcon fill={color} height={size - 4} width={size - 4} />;
|
return <SearchIcon fill={color} height={size - 4} width={size - 4} />;
|
||||||
case 'Music':
|
case 'MusicTab':
|
||||||
return <NotesIcon fill={color} height={size} width={size} />;
|
return <NotesIcon fill={color} height={size} width={size} />;
|
||||||
case 'Settings':
|
case 'Settings':
|
||||||
return <GearIcon fill={color} height={size - 1} width={size - 1} />;
|
return <GearIcon fill={color} height={size - 1} width={size - 1} />;
|
||||||
@@ -63,10 +65,15 @@ function Screens() {
|
|||||||
tabBarActiveTintColor: THEME_COLOR,
|
tabBarActiveTintColor: THEME_COLOR,
|
||||||
tabBarInactiveTintColor: 'gray',
|
tabBarInactiveTintColor: 'gray',
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
tabBarShowLabel: false,
|
||||||
|
tabBarStyle: { position: 'absolute' },
|
||||||
|
tabBarBackground: () => (
|
||||||
|
<ColoredBlurView style={StyleSheet.absoluteFill} />
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Tab.Screen name="Music" component={Music} options={{ tabBarLabel: t('music'), tabBarTestID: 'music-tab' }} />
|
<Tab.Screen name="MusicTab" component={Music} options={{ tabBarLabel: t('music'), tabBarTestID: 'music-tab' }} />
|
||||||
<Tab.Screen name="Search" component={Search} options={{ tabBarLabel: t('search'), tabBarTestID: 'search-tab' }} />
|
<Tab.Screen name="SearchTab" component={SearchStack} options={{ tabBarLabel: t('search'), tabBarTestID: 'search-tab' }} />
|
||||||
<Tab.Screen name="Downloads" component={Downloads} options={{ tabBarLabel: t('downloads'), tabBarTestID: 'downloads-tab'}} />
|
<Tab.Screen name="Downloads" component={Downloads} options={{ tabBarLabel: t('downloads'), tabBarTestID: 'downloads-tab'}} />
|
||||||
<Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings'), tabBarTestID: 'settings-tab' }} />
|
<Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings'), tabBarTestID: 'settings-tab' }} />
|
||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ class CredentialGenerator extends Component<Props> {
|
|||||||
return (
|
return (
|
||||||
<WebView
|
<WebView
|
||||||
source={{ uri: serverUrl as string }}
|
source={{ uri: serverUrl as string }}
|
||||||
style={{ borderRadius: 20 }}
|
|
||||||
onNavigationStateChange={this.handleStateChange}
|
onNavigationStateChange={this.handleStateChange}
|
||||||
onMessage={this.handleMessage}
|
onMessage={this.handleMessage}
|
||||||
ref={this.ref}
|
ref={this.ref}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useNavigation, StackActions, useRoute, RouteProp } from '@react-navigation/native';
|
import { useNavigation, StackActions, useRoute, RouteProp } from '@react-navigation/native';
|
||||||
import { ModalStackParams } from 'screens/types';
|
import { StackParams } from 'screens/types';
|
||||||
import { useAppDispatch, useTypedSelector } from 'store';
|
import { useAppDispatch, useTypedSelector } from 'store';
|
||||||
import { Header, SubHeader } from 'components/Typography';
|
import { Header, SubHeader } from 'components/Typography';
|
||||||
import styled from 'styled-components/native';
|
import styled from 'styled-components/native';
|
||||||
@@ -17,7 +17,7 @@ import usePlayTracks from 'utility/usePlayTracks';
|
|||||||
import { selectIsDownloaded } from 'store/downloads/selectors';
|
import { selectIsDownloaded } from 'store/downloads/selectors';
|
||||||
import { useGetImage } from 'utility/JellyfinApi';
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
|
|
||||||
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
|
type Route = RouteProp<StackParams, 'TrackPopupMenu'>;
|
||||||
|
|
||||||
const Container = styled.View`
|
const Container = styled.View`
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
|
import { Album } from 'store/music/types';
|
||||||
|
|
||||||
export interface ModalStackParams {
|
export type StackParams = {
|
||||||
[key: string]: Record<string, unknown> | undefined;
|
[key: string]: Record<string, unknown> | undefined;
|
||||||
|
Albums: undefined;
|
||||||
|
Album: { id: string, album: Album };
|
||||||
|
Playlists: undefined;
|
||||||
|
Playlist: { id: string };
|
||||||
|
RecentAlbums: undefined;
|
||||||
|
Search: undefined;
|
||||||
SetJellyfinServer: undefined;
|
SetJellyfinServer: undefined;
|
||||||
TrackPopupMenu: { trackId: string };
|
TrackPopupMenu: { trackId: string };
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ModalNavigationProp = StackNavigationProp<ModalStackParams>;
|
export type NavigationProp = StackNavigationProp<StackParams>;
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncTh
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>(
|
||||||
|
'/albums/single',
|
||||||
|
async (ItemId, thunkAPI) => {
|
||||||
|
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||||
|
return retrieveAlbum(credentials, ItemId) as Promise<Album>;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
type SearchAndFetchResults = {
|
type SearchAndFetchResults = {
|
||||||
albums: Album[];
|
albums: Album[];
|
||||||
results: (Album | AlbumTrack)[];
|
results: (Album | AlbumTrack)[];
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
searchAndFetchAlbums,
|
searchAndFetchAlbums,
|
||||||
playlistAdapter,
|
playlistAdapter,
|
||||||
fetchAllPlaylists,
|
fetchAllPlaylists,
|
||||||
fetchTracksByPlaylist
|
fetchTracksByPlaylist,
|
||||||
|
fetchAlbum
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
|
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
|
||||||
import { Album, AlbumTrack, Playlist } from './types';
|
import { Album, AlbumTrack, Playlist } from './types';
|
||||||
@@ -69,6 +70,15 @@ const music = createSlice({
|
|||||||
});
|
});
|
||||||
builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; });
|
builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; });
|
||||||
builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; });
|
builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch single album
|
||||||
|
*/
|
||||||
|
builder.addCase(fetchAlbum.fulfilled, (state, { payload }) => {
|
||||||
|
albumAdapter.upsertOne(state.albums, payload);
|
||||||
|
});
|
||||||
|
builder.addCase(fetchAlbum.pending, (state) => { state.albums.isLoading = true; });
|
||||||
|
builder.addCase(fetchAlbum.rejected, (state) => { state.albums.isLoading = false; });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch most recent albums
|
* Fetch most recent albums
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface Album {
|
|||||||
Tracks?: string[];
|
Tracks?: string[];
|
||||||
lastRefreshed?: number;
|
lastRefreshed?: number;
|
||||||
DateCreated: string;
|
DateCreated: string;
|
||||||
|
Overview?: string;
|
||||||
|
Similar?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlbumTrack {
|
export interface AlbumTrack {
|
||||||
@@ -94,4 +96,8 @@ export interface Playlist {
|
|||||||
MediaType: string;
|
MediaType: string;
|
||||||
Tracks?: string[];
|
Tracks?: string[];
|
||||||
lastRefreshed?: number;
|
lastRefreshed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimilarAlbum {
|
||||||
|
Id: string;
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import { t } from '@localisation';
|
|||||||
import { setReceivedErrorReportingAlert } from 'store/settings/actions';
|
import { setReceivedErrorReportingAlert } from 'store/settings/actions';
|
||||||
import { setSentryStatus } from './Sentry';
|
import { setSentryStatus } from './Sentry';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { ModalNavigationProp } from 'screens/types';
|
import { NavigationProp } from 'screens/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This will send out an alert message asking the user if they want to enable
|
* This will send out an alert message asking the user if they want to enable
|
||||||
@@ -13,7 +13,7 @@ import { ModalNavigationProp } from 'screens/types';
|
|||||||
*/
|
*/
|
||||||
export default function ErrorReportingAlert() {
|
export default function ErrorReportingAlert() {
|
||||||
const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings);
|
const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings);
|
||||||
const navigation = useNavigation<ModalNavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Track } from 'react-native-track-player';
|
import { Track } from 'react-native-track-player';
|
||||||
import { AppState, useTypedSelector } from 'store';
|
import { AppState, useTypedSelector } from 'store';
|
||||||
import { Album, AlbumTrack } from 'store/music/types';
|
import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types';
|
||||||
|
|
||||||
type Credentials = AppState['settings']['jellyfin'];
|
type Credentials = AppState['settings']['jellyfin'];
|
||||||
|
|
||||||
@@ -86,8 +86,14 @@ export async function retrieveAllAlbums(credentials: Credentials) {
|
|||||||
*/
|
*/
|
||||||
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
|
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
|
||||||
const config = generateConfig(credentials);
|
const config = generateConfig(credentials);
|
||||||
|
|
||||||
|
const Similar = await fetch(`${credentials?.uri}/Items/${id}/Similar?userId=${credentials?.user_id}&limit=12`, config)
|
||||||
|
.then(response => response.json() as Promise<{ Items: SimilarAlbum[] }>)
|
||||||
|
.then((albums) => albums.Items.map((a) => a.Id));
|
||||||
|
|
||||||
return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config)
|
return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config)
|
||||||
.then(response => response.json());
|
.then(response => response.json() as Promise<Album>)
|
||||||
|
.then(album => ({ ...album, Similar }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestAlbumsOptions = {
|
const latestAlbumsOptions = {
|
||||||
@@ -96,7 +102,6 @@ const latestAlbumsOptions = {
|
|||||||
SortOrder: 'Ascending',
|
SortOrder: 'Ascending',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve the most recently added albums on the Jellyfin server
|
* Retrieve the most recently added albums on the Jellyfin server
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
function ticksToDuration(ticks: number) {
|
function ticksToDuration(ticks: number) {
|
||||||
const seconds = Math.round(ticks / 10000000);
|
const seconds = Math.round(ticks / 10000000);
|
||||||
const minutes = Math.round(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const hours = Math.round(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
return `${hours > 0 ? hours + ':' : ''}${minutes}:${(seconds % 60).toString().padStart(2, '0')}`;
|
return `${hours > 0 ? hours + ':' : ''}${(minutes % 60).toString().padStart(hours > 0 ? 2 : 0, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ticksToDuration;
|
export default ticksToDuration;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import { Animated, Keyboard, KeyboardEvent } from 'react-native';
|
import { Animated, Easing, Keyboard, KeyboardEvent } from 'react-native';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This returns an animated height that the keyboard is poking up from the
|
* This returns an animated height that the keyboard is poking up from the
|
||||||
@@ -15,9 +15,10 @@ export const useKeyboardHeight = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyboardWillShow = (e: KeyboardEvent) => {
|
const keyboardWillShow = (e: KeyboardEvent) => {
|
||||||
Animated.timing(keyboardHeight, {
|
Animated.timing(keyboardHeight, {
|
||||||
duration: e.duration,
|
duration: e.duration - 20,
|
||||||
toValue: tabBarHeight - e.endCoordinates.height,
|
toValue: tabBarHeight - e.endCoordinates.height,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
|
easing: Easing.ease,
|
||||||
}).start();
|
}).start();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user