Compare commits

...

22 Commits

Author SHA1 Message Date
Lei Nelissen
55494caf4f Fix Android issues 2022-01-02 21:14:05 +01:00
Lei Nelissen
b7d16a174b Release new version 2022-01-02 19:30:54 +01:00
Lei Nelissen
3c98bf92eb Merge pull request #67 from leinelissen/feature/downloads
Downloads
2022-01-02 19:30:08 +01:00
Lei Nelissen
ebd46970d9 Fix linting errors 2022-01-02 19:29:20 +01:00
Lei Nelissen
cc0dfc2528 Allow for retring individual tracks 2022-01-02 19:16:12 +01:00
Lei Nelissen
74ddc58f83 Allow retries for failed tracks 2022-01-02 18:11:30 +01:00
Lei Nelissen
95a008d8af Add buttons for deleting downloaded tracks from playlist or album 2022-01-02 17:46:50 +01:00
Lei Nelissen
db1c9d8af9 Adjust transport container so we can actually download non-support audio files 2022-01-02 17:21:24 +01:00
Lei Nelissen
c28c1a851d Only render search message if nothing is found 2022-01-02 17:21:02 +01:00
Lei Nelissen
d2fb4a4aea Basic download implementation 2022-01-02 02:28:52 +01:00
Lei Nelissen
284d23d30b Merge pull request #66 from leinelissen/feature/playlists
Add playlists
2022-01-01 22:58:47 +01:00
Lei Nelissen
464747d0c4 Limit tsc output to our files rather than libraries 2022-01-01 22:44:15 +01:00
Lei Nelissen
419ce99d08 Add linting to GitHub actions 2022-01-01 22:37:58 +01:00
Lei Nelissen
4e6a758d83 Fix linting and typescript errors 2022-01-01 22:36:05 +01:00
Lei Nelissen
4ecf978505 Add Dutch translations 2022-01-01 21:58:06 +01:00
Lei Nelissen
5d96a67336 Make playlist view full-height 2022-01-01 21:57:18 +01:00
Lei Nelissen
6a4b5618aa Resolve track pop-up spacing 2022-01-01 21:55:32 +01:00
Lei Nelissen
75e8ece60a Add playlists 2022-01-01 19:09:21 +01:00
Lei Nelissen
4460bdf7f9 (1) Automatically focus on text field in search
(2) Restyle no results message
2022-01-01 14:27:08 +01:00
Lei Nelissen
9b41a0e62f Contextually disable previous and next buttons 2022-01-01 14:10:56 +01:00
Lei Nelissen
93c9ba7498 Adjust modal display with new react-navigation look 2022-01-01 14:09:52 +01:00
Lei Nelissen
9668ba9c7c Upgrade all dependencies
(1) react-native-track-player to v2
(2) react-navigation to v6
(3) react-native to v0.66.4
2021-12-31 15:04:37 +01:00
74 changed files with 23189 additions and 5990 deletions

13
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Lint
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Install Node dependencies
run: npm install
- name: Run linter
run: npm run lint

200
android/gradlew.bat vendored
View File

@@ -1,100 +1,100 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -6,8 +6,10 @@ import { name as appName } from './app.json';
import PlaybackService from './src/utility/PlaybackService';
import { setupSentry } from 'utility/Sentry';
import { enableScreens } from 'react-native-screens';
import { patchTrackPlayer } from 'utility/AddedTrackEvents';
setupSentry();
enableScreens();
patchTrackPlayer();
AppRegistry.registerComponent(appName, () => App);
TrackPlayer.registerPlaybackService(() => PlaybackService);

View File

@@ -179,7 +179,7 @@
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
BDE784ECF29EF861DBFF49D7 /* [CP] Copy Pods Resources */,
476A07969B11CA4E09819AC0 /* [CP] Embed Pods Frameworks */,
A02366876E56A727F566EC3A /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -203,7 +203,7 @@
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
B9FB8FC65CEFF9AFAC71127E /* [CP] Copy Pods Resources */,
1DC46C84C90B4D84A18AC142 /* Upload Debug Symbols to Sentry */,
E8FF56077B675D4D92D5CC25 /* [CP] Embed Pods Frameworks */,
2917566AA57EE087FC9FCCE9 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -305,7 +305,27 @@
shellPath = /bin/sh;
shellScript = "export SENTRY_PROPERTIES=sentry.properties\n../node_modules/@sentry/cli/bin/sentry-cli upload-dsym";
};
476A07969B11CA4E09819AC0 /* [CP] Embed Pods Frameworks */ = {
2917566AA57EE087FC9FCCE9 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-JellyfinAudioPlayer/Pods-JellyfinAudioPlayer-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-DoubleConversion/double-conversion.framework/double-conversion",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/double-conversion.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinAudioPlayer/Pods-JellyfinAudioPlayer-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
A02366876E56A727F566EC3A /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -405,26 +425,6 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E8FF56077B675D4D92D5CC25 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-JellyfinAudioPlayer/Pods-JellyfinAudioPlayer-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-DoubleConversion/double-conversion.framework/double-conversion",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/double-conversion.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinAudioPlayer/Pods-JellyfinAudioPlayer-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
FD10A7F022414F080027D42C /* Start Packager */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -503,6 +503,10 @@
INFOPLIST_FILE = JellyfinAudioPlayerTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
LIBRARY_SEARCH_PATHS = (
"$(SDKROOT)/usr/lib/swift",
"$(inherited)",
);
OTHER_LDFLAGS = (
"-ObjC",
"-lc++",
@@ -526,6 +530,10 @@
INFOPLIST_FILE = JellyfinAudioPlayerTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
LIBRARY_SEARCH_PATHS = (
"$(SDKROOT)/usr/lib/swift",
"$(inherited)",
);
OTHER_LDFLAGS = (
"-ObjC",
"-lc++",
@@ -555,6 +563,10 @@
);
INFOPLIST_FILE = JellyfinAudioPlayer/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LIBRARY_SEARCH_PATHS = (
"$(SDKROOT)/usr/lib/swift",
"$(inherited)",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -581,6 +593,10 @@
DEVELOPMENT_TEAM = 238P3C58WC;
INFOPLIST_FILE = JellyfinAudioPlayer/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LIBRARY_SEARCH_PATHS = (
"$(SDKROOT)/usr/lib/swift",
"$(inherited)",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -629,7 +645,7 @@
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -648,6 +664,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = (
"$(SDKROOT)/usr/lib/swift",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
@@ -692,7 +709,7 @@
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -704,6 +721,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = (
"$(SDKROOT)/usr/lib/swift",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",

View File

@@ -11,6 +11,7 @@
#import <FlipperKitNetworkPlugin/FlipperKitNetworkPlugin.h>
#import <SKIOSNetworkPlugin/SKIOSNetworkAdapter.h>
#import <FlipperKitReactPlugin/FlipperKitReactPlugin.h>
#import <RNFSManager.h>
static void InitializeFlipper(UIApplication *application) {
FlipperClient *client = [FlipperClient sharedClient];
@@ -59,4 +60,9 @@ static void InitializeFlipper(UIApplication *application) {
#endif
}
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler
{
[RNFSManager setCompletionHandlerForIdentifier:identifier completionHandler:completionHandler];
}
@end

View File

@@ -1,24 +1,38 @@
platform :ios, '11.0'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
require_relative '../node_modules/react-native/scripts/react_native_pods'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
platform :ios, '12.0'
target 'JellyfinAudioPlayer' do
config = use_native_modules!
use_react_native!(:path => config["reactNativePath"])
use_react_native!(
:path => config[:reactNativePath],
# to enable hermes on iOS, change `false` to `true` and then install pods
:hermes_enabled => false
)
target 'JellyfinAudioPlayerTests' do
inherit! :complete
# Pods for testing
end
# Enables Flipper.
#
# Note that if you have use_frameworks! enabled, Flipper will not work and
# you should disable these next few lines.
use_flipper!
# you should disable the next line.
use_flipper!()
post_install do |installer|
flipper_post_install(installer)
installer.pods_project.build_configurations.each do |config|
config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
react_native_post_install(installer)
__apply_Xcode_12_5_M1_post_install_workaround(installer)
installer.aggregate_targets.each do |aggregate_target|
aggregate_target.user_project.native_targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['LIBRARY_SEARCH_PATHS'] = ['$(SDKROOT)/usr/lib/swift', '$(inherited)']
end
end
aggregate_target.user_project.save
end
end
end

View File

@@ -2,14 +2,14 @@ PODS:
- boost (1.76.0)
- CocoaAsyncSocket (7.6.5)
- DoubleConversion (1.1.6)
- FBLazyVector (0.66.1)
- FBReactNativeSpec (0.66.1):
- FBLazyVector (0.66.4)
- FBReactNativeSpec (0.66.4):
- RCT-Folly (= 2021.06.28.00-v2)
- RCTRequired (= 0.66.1)
- RCTTypeSafety (= 0.66.1)
- React-Core (= 0.66.1)
- React-jsi (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- RCTRequired (= 0.66.4)
- RCTTypeSafety (= 0.66.4)
- React-Core (= 0.66.4)
- React-jsi (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- Flipper (0.99.0):
- Flipper-Folly (~> 2.6)
- Flipper-RSocket (~> 1.4)
@@ -94,268 +94,271 @@ PODS:
- DoubleConversion
- fmt (~> 6.2.1)
- glog
- RCTRequired (0.66.1)
- RCTTypeSafety (0.66.1):
- FBLazyVector (= 0.66.1)
- RCTRequired (0.66.4)
- RCTTypeSafety (0.66.4):
- FBLazyVector (= 0.66.4)
- RCT-Folly (= 2021.06.28.00-v2)
- RCTRequired (= 0.66.1)
- React-Core (= 0.66.1)
- React (0.66.1):
- React-Core (= 0.66.1)
- React-Core/DevSupport (= 0.66.1)
- React-Core/RCTWebSocket (= 0.66.1)
- React-RCTActionSheet (= 0.66.1)
- React-RCTAnimation (= 0.66.1)
- React-RCTBlob (= 0.66.1)
- React-RCTImage (= 0.66.1)
- React-RCTLinking (= 0.66.1)
- React-RCTNetwork (= 0.66.1)
- React-RCTSettings (= 0.66.1)
- React-RCTText (= 0.66.1)
- React-RCTVibration (= 0.66.1)
- React-callinvoker (0.66.1)
- React-Core (0.66.1):
- RCTRequired (= 0.66.4)
- React-Core (= 0.66.4)
- React (0.66.4):
- React-Core (= 0.66.4)
- React-Core/DevSupport (= 0.66.4)
- React-Core/RCTWebSocket (= 0.66.4)
- React-RCTActionSheet (= 0.66.4)
- React-RCTAnimation (= 0.66.4)
- React-RCTBlob (= 0.66.4)
- React-RCTImage (= 0.66.4)
- React-RCTLinking (= 0.66.4)
- React-RCTNetwork (= 0.66.4)
- React-RCTSettings (= 0.66.4)
- React-RCTText (= 0.66.4)
- React-RCTVibration (= 0.66.4)
- React-callinvoker (0.66.4)
- React-Core (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default (= 0.66.1)
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-Core/Default (= 0.66.4)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/CoreModulesHeaders (0.66.1):
- React-Core/CoreModulesHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/Default (0.66.1):
- React-Core/Default (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/DevSupport (0.66.1):
- React-Core/DevSupport (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default (= 0.66.1)
- React-Core/RCTWebSocket (= 0.66.1)
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-jsinspector (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-Core/Default (= 0.66.4)
- React-Core/RCTWebSocket (= 0.66.4)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-jsinspector (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTActionSheetHeaders (0.66.1):
- React-Core/RCTActionSheetHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTAnimationHeaders (0.66.1):
- React-Core/RCTAnimationHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTBlobHeaders (0.66.1):
- React-Core/RCTBlobHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTImageHeaders (0.66.1):
- React-Core/RCTImageHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTLinkingHeaders (0.66.1):
- React-Core/RCTLinkingHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTNetworkHeaders (0.66.1):
- React-Core/RCTNetworkHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTSettingsHeaders (0.66.1):
- React-Core/RCTSettingsHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTTextHeaders (0.66.1):
- React-Core/RCTTextHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTVibrationHeaders (0.66.1):
- React-Core/RCTVibrationHeaders (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-Core/RCTWebSocket (0.66.1):
- React-Core/RCTWebSocket (0.66.4):
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default (= 0.66.1)
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsiexecutor (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-Core/Default (= 0.66.4)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsiexecutor (= 0.66.4)
- React-perflogger (= 0.66.4)
- Yoga
- React-CoreModules (0.66.1):
- FBReactNativeSpec (= 0.66.1)
- React-CoreModules (0.66.4):
- FBReactNativeSpec (= 0.66.4)
- RCT-Folly (= 2021.06.28.00-v2)
- RCTTypeSafety (= 0.66.1)
- React-Core/CoreModulesHeaders (= 0.66.1)
- React-jsi (= 0.66.1)
- React-RCTImage (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- React-cxxreact (0.66.1):
- RCTTypeSafety (= 0.66.4)
- React-Core/CoreModulesHeaders (= 0.66.4)
- React-jsi (= 0.66.4)
- React-RCTImage (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- React-cxxreact (0.66.4):
- boost (= 1.76.0)
- DoubleConversion
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-callinvoker (= 0.66.1)
- React-jsi (= 0.66.1)
- React-jsinspector (= 0.66.1)
- React-logger (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-runtimeexecutor (= 0.66.1)
- React-jsi (0.66.1):
- React-callinvoker (= 0.66.4)
- React-jsi (= 0.66.4)
- React-jsinspector (= 0.66.4)
- React-logger (= 0.66.4)
- React-perflogger (= 0.66.4)
- React-runtimeexecutor (= 0.66.4)
- React-jsi (0.66.4):
- boost (= 1.76.0)
- DoubleConversion
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-jsi/Default (= 0.66.1)
- React-jsi/Default (0.66.1):
- React-jsi/Default (= 0.66.4)
- React-jsi/Default (0.66.4):
- boost (= 1.76.0)
- DoubleConversion
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-jsiexecutor (0.66.1):
- React-jsiexecutor (0.66.4):
- DoubleConversion
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-jsinspector (0.66.1)
- React-logger (0.66.1):
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-perflogger (= 0.66.4)
- React-jsinspector (0.66.4)
- React-logger (0.66.4):
- glog
- react-native-airplay-button (1.1.0):
- React-Core
- react-native-flipper (0.127.0):
- React-Core
- react-native-safe-area-context (3.3.2):
- React-Core
- react-native-slider (3.0.3):
- React
- react-native-track-player (1.2.7):
- react-native-slider (4.1.12):
- React-Core
- react-native-webview (11.14.1):
- react-native-track-player (2.1.2):
- React-Core
- React-perflogger (0.66.1)
- React-RCTActionSheet (0.66.1):
- React-Core/RCTActionSheetHeaders (= 0.66.1)
- React-RCTAnimation (0.66.1):
- FBReactNativeSpec (= 0.66.1)
- SwiftAudioEx (= 0.14.5)
- react-native-webview (11.15.0):
- React-Core
- React-perflogger (0.66.4)
- React-RCTActionSheet (0.66.4):
- React-Core/RCTActionSheetHeaders (= 0.66.4)
- React-RCTAnimation (0.66.4):
- FBReactNativeSpec (= 0.66.4)
- RCT-Folly (= 2021.06.28.00-v2)
- RCTTypeSafety (= 0.66.1)
- React-Core/RCTAnimationHeaders (= 0.66.1)
- React-jsi (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- React-RCTBlob (0.66.1):
- FBReactNativeSpec (= 0.66.1)
- RCTTypeSafety (= 0.66.4)
- React-Core/RCTAnimationHeaders (= 0.66.4)
- React-jsi (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- React-RCTBlob (0.66.4):
- FBReactNativeSpec (= 0.66.4)
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/RCTBlobHeaders (= 0.66.1)
- React-Core/RCTWebSocket (= 0.66.1)
- React-jsi (= 0.66.1)
- React-RCTNetwork (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- React-RCTImage (0.66.1):
- FBReactNativeSpec (= 0.66.1)
- React-Core/RCTBlobHeaders (= 0.66.4)
- React-Core/RCTWebSocket (= 0.66.4)
- React-jsi (= 0.66.4)
- React-RCTNetwork (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- React-RCTImage (0.66.4):
- FBReactNativeSpec (= 0.66.4)
- RCT-Folly (= 2021.06.28.00-v2)
- RCTTypeSafety (= 0.66.1)
- React-Core/RCTImageHeaders (= 0.66.1)
- React-jsi (= 0.66.1)
- React-RCTNetwork (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- React-RCTLinking (0.66.1):
- FBReactNativeSpec (= 0.66.1)
- React-Core/RCTLinkingHeaders (= 0.66.1)
- React-jsi (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- React-RCTNetwork (0.66.1):
- FBReactNativeSpec (= 0.66.1)
- RCTTypeSafety (= 0.66.4)
- React-Core/RCTImageHeaders (= 0.66.4)
- React-jsi (= 0.66.4)
- React-RCTNetwork (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- React-RCTLinking (0.66.4):
- FBReactNativeSpec (= 0.66.4)
- React-Core/RCTLinkingHeaders (= 0.66.4)
- React-jsi (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- React-RCTNetwork (0.66.4):
- FBReactNativeSpec (= 0.66.4)
- RCT-Folly (= 2021.06.28.00-v2)
- RCTTypeSafety (= 0.66.1)
- React-Core/RCTNetworkHeaders (= 0.66.1)
- React-jsi (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- React-RCTSettings (0.66.1):
- FBReactNativeSpec (= 0.66.1)
- RCTTypeSafety (= 0.66.4)
- React-Core/RCTNetworkHeaders (= 0.66.4)
- React-jsi (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- React-RCTSettings (0.66.4):
- FBReactNativeSpec (= 0.66.4)
- RCT-Folly (= 2021.06.28.00-v2)
- RCTTypeSafety (= 0.66.1)
- React-Core/RCTSettingsHeaders (= 0.66.1)
- React-jsi (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- React-RCTText (0.66.1):
- React-Core/RCTTextHeaders (= 0.66.1)
- React-RCTVibration (0.66.1):
- FBReactNativeSpec (= 0.66.1)
- RCTTypeSafety (= 0.66.4)
- React-Core/RCTSettingsHeaders (= 0.66.4)
- React-jsi (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- React-RCTText (0.66.4):
- React-Core/RCTTextHeaders (= 0.66.4)
- React-RCTVibration (0.66.4):
- FBReactNativeSpec (= 0.66.4)
- RCT-Folly (= 2021.06.28.00-v2)
- React-Core/RCTVibrationHeaders (= 0.66.1)
- React-jsi (= 0.66.1)
- ReactCommon/turbomodule/core (= 0.66.1)
- React-runtimeexecutor (0.66.1):
- React-jsi (= 0.66.1)
- ReactCommon/turbomodule/core (0.66.1):
- React-Core/RCTVibrationHeaders (= 0.66.4)
- React-jsi (= 0.66.4)
- ReactCommon/turbomodule/core (= 0.66.4)
- React-runtimeexecutor (0.66.4):
- React-jsi (= 0.66.4)
- ReactCommon/turbomodule/core (0.66.4):
- DoubleConversion
- glog
- RCT-Folly (= 2021.06.28.00-v2)
- React-callinvoker (= 0.66.1)
- React-Core (= 0.66.1)
- React-cxxreact (= 0.66.1)
- React-jsi (= 0.66.1)
- React-logger (= 0.66.1)
- React-perflogger (= 0.66.1)
- React-callinvoker (= 0.66.4)
- React-Core (= 0.66.4)
- React-cxxreact (= 0.66.4)
- React-jsi (= 0.66.4)
- React-logger (= 0.66.4)
- React-perflogger (= 0.66.4)
- RNCAsyncStorage (1.12.1):
- React-Core
- RNCMaskedView (0.1.11):
@@ -366,11 +369,13 @@ PODS:
- React-Core
- SDWebImage (~> 5.11.1)
- SDWebImageWebPCoder (~> 0.8.4)
- RNGestureHandler (1.10.3):
- RNFS (2.18.0):
- React
- RNGestureHandler (2.1.0):
- React-Core
- RNLocalize (2.1.5):
- RNLocalize (2.1.7):
- React-Core
- RNReanimated (2.2.3):
- RNReanimated (2.3.1):
- DoubleConversion
- FBLazyVector
- FBReactNativeSpec
@@ -396,15 +401,14 @@ PODS:
- React-RCTNetwork
- React-RCTSettings
- React-RCTText
- React-RCTVibration
- ReactCommon/turbomodule/core
- Yoga
- RNScreens (3.8.0):
- RNScreens (3.10.1):
- React-Core
- React-RCTImage
- RNSentry (2.6.2):
- RNSentry (3.2.10):
- React-Core
- Sentry (= 7.1.4)
- Sentry (= 7.7.0)
- RNSVG (12.2.0):
- React-Core
- SDWebImage (5.11.1):
@@ -413,9 +417,10 @@ PODS:
- SDWebImageWebPCoder (0.8.4):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10)
- Sentry (7.1.4):
- Sentry/Core (= 7.1.4)
- Sentry/Core (7.1.4)
- Sentry (7.7.0):
- Sentry/Core (= 7.7.0)
- Sentry/Core (7.7.0)
- SwiftAudioEx (0.14.5)
- Yoga (1.14.0)
- YogaKit (1.18.1):
- Yoga (~> 1.14)
@@ -462,6 +467,7 @@ DEPENDENCIES:
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- react-native-airplay-button (from `../node_modules/react-native-airplay-button`)
- react-native-flipper (from `../node_modules/react-native-flipper`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
- react-native-track-player (from `../node_modules/react-native-track-player`)
@@ -482,6 +488,7 @@ DEPENDENCIES:
- "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)"
- "RNCPicker (from `../node_modules/@react-native-community/picker`)"
- RNFastImage (from `../node_modules/react-native-fast-image`)
- RNFS (from `../node_modules/react-native-fs`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNLocalize (from `../node_modules/react-native-localize`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
@@ -509,6 +516,7 @@ SPEC REPOS:
- SDWebImage
- SDWebImageWebPCoder
- Sentry
- SwiftAudioEx
- YogaKit
EXTERNAL SOURCES:
@@ -548,6 +556,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/logger"
react-native-airplay-button:
:path: "../node_modules/react-native-airplay-button"
react-native-flipper:
:path: "../node_modules/react-native-flipper"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-slider:
@@ -588,6 +598,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/picker"
RNFastImage:
:path: "../node_modules/react-native-fast-image"
RNFS:
:path: "../node_modules/react-native-fs"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNLocalize:
@@ -607,8 +619,8 @@ SPEC CHECKSUMS:
boost: a7c83b31436843459a1961bfd74b96033dc77234
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
FBLazyVector: 500821d196c3d1bd10e7e828bc93ce075234080f
FBReactNativeSpec: 74c869e2cffa2ffec685cd1bac6788c021da6005
FBLazyVector: e5569e42a1c79ca00521846c223173a57aca1fe1
FBReactNativeSpec: fe08c1cd7e2e205718d77ad14b34957cce949b58
Flipper: 30e8eeeed6abdc98edaf32af0cda2f198be4b733
Flipper-Boost-iOSX: fd1e2b8cbef7e662a122412d7ac5f5bea715403c
Flipper-DoubleConversion: 57ffbe81ef95306cc9e69c4aa3aeeeeb58a6a28c
@@ -624,50 +636,53 @@ SPEC CHECKSUMS:
libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc
OpenSSL-Universal: 1aa4f6a6ee7256b83db99ec1ccdaa80d10f9af9b
RCT-Folly: a21c126816d8025b547704b777a2ba552f3d9fa9
RCTRequired: 3cc065b52aa18db729268b9bd78a2feffb4d0f91
RCTTypeSafety: 3c4fc37d5dea452d2ef17324db5504ec2f05083a
React: 4a00720816c52a213424442954acb7e4b724804a
React-callinvoker: 911fc6570538f3bb5c61edf9dc907c1beb4355bf
React-Core: e134d3a5d7b2a1a731589be776e20dbb14868f27
React-CoreModules: 2f8588b2aa47e7fef27125c8eaaabda963b3ac62
React-cxxreact: 8f1382538cad0cc8b8eafca6d66268828e353bea
React-jsi: 9fe1854d2c0486216acebd5db3c38b4ccb23ca0b
React-jsiexecutor: db2f6e22a534d466fc0e34e622df47d9d20bab2f
React-jsinspector: 8c0517dee5e8c70cd6c3066f20213ff7ce54f176
React-logger: bfddd3418dc1d45b77b822958f3e31422e2c179b
react-native-airplay-button: 517a0248e8d8e744a42250f67bead5175a774ece
react-native-safe-area-context: 5cf05f49df9d17261e40e518481f2e334c6cd4b5
react-native-slider: b733e17fdd31186707146debf1f04b5d94aa1a93
react-native-track-player: 878d6c66e4ae3a69f6a534c57c6c34a42e9b033f
react-native-webview: 16054e7a26009b7d5944a18e19f450e26bd4c969
React-perflogger: fcac6090a80e3d967791b4c7f1b1a017f9d4a398
React-RCTActionSheet: caf5913d9f9e605f5467206cf9d1caa6d47d7ad6
React-RCTAnimation: 6539e3bf594f6a529cd861985ba6548286ae1ead
React-RCTBlob: 6e2e999d28b15fd03ed533f164ce33e0fcde571a
React-RCTImage: c6bbb10eedb6b840c4474f2108b864173b83de15
React-RCTLinking: 8fda9bb8fdb104e78110a903a9a77754318c7d11
React-RCTNetwork: 2b26daad93830501cf14aab03eac04e304f942d3
React-RCTSettings: 89c0dcee7adb706c749383596f57c1e882a27843
React-RCTText: 71734fce8e6cb854daeb4a5eec182c303ea58473
React-RCTVibration: 6600b5eed7c0fda4a433fa1198d1cb2690151791
React-runtimeexecutor: 33a949a51bec5f8a3c9e8d8092deb259600d761e
ReactCommon: 620442811dc6f707b4bf5e3b27d4f19c12d5a821
RNCAsyncStorage: cb9a623793918c6699586281f0b51cbc38f046f9
RNCMaskedView: f127cd9652acfa31b91dcff613e07ba18b774db6
RCTRequired: 4bf86c70714490bca4bf2696148638284622644b
RCTTypeSafety: c475a7059eb77935fa53d2c17db299893f057d5d
React: f64af14e3f2c50f6f2c91a5fd250e4ff1b3c3459
React-callinvoker: b74e4ae80287780dcdf0cab262bcb581eeef56e7
React-Core: 3eb7432bad96ff1d25aebc1defbae013fee2fd0e
React-CoreModules: ad9e1fd5650e16666c57a08328df86fd7e480cb9
React-cxxreact: 02633ff398cf7e91a2c1e12590d323c4a4b8668a
React-jsi: 805c41a927d6499fb811772acb971467d9204633
React-jsiexecutor: 94ce921e1d8ce7023366873ec371f3441383b396
React-jsinspector: d0374f7509d407d2264168b6d0fad0b54e300b85
React-logger: 933f80c97c633ee8965d609876848148e3fef438
react-native-airplay-button: 90c7ba52402c8e92342003b8a1ff78dfb4357a9e
react-native-flipper: b9e2e817604af8da0d5a9ba20a8516e780e30f3c
react-native-safe-area-context: 584dc04881deb49474363f3be89e4ca0e854c057
react-native-slider: 6e9b86e76cce4b9e35b3403193a6432ed07e0c81
react-native-track-player: 23dd515aacf1d36a0e522ef7fdbc55f13f26d4fb
react-native-webview: e89bf2dba26a04cda967814df3ed1be99f291233
React-perflogger: 93075d8931c32cd1fce8a98c15d2d5ccc4d891bd
React-RCTActionSheet: 7d3041e6761b4f3044a37079ddcb156575fb6d89
React-RCTAnimation: 743e88b55ac62511ae5c2e22803d4f503f2a3a13
React-RCTBlob: bee3a2f98fa7fc25c957c8643494244f74bea0a0
React-RCTImage: 19fc9e29b06cc38611c553494f8d3040bf78c24e
React-RCTLinking: dc799503979c8c711126d66328e7ce8f25c2848f
React-RCTNetwork: 417e4e34cf3c19eaa5fd4e9eb20180d662a799ce
React-RCTSettings: 4df89417265af26501a7e0e9192a34d3d9848dff
React-RCTText: f8a21c3499ab322326290fa9b701ae29aa093aa5
React-RCTVibration: e3ffca672dd3772536cb844274094b0e2c31b187
React-runtimeexecutor: dec32ee6f2e2a26e13e58152271535fadff5455a
ReactCommon: 57b69f6383eafcbd7da625bfa6003810332313c4
RNCAsyncStorage: b03032fdbdb725bea0bd9e5ec5a7272865ae7398
RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489
RNCPicker: 914b557e20b3b8317b084aca9ff4b4edb95f61e4
RNFastImage: 1f2cab428712a4baaf78d6169eaec7f622556dd7
RNGestureHandler: a479ebd5ed4221a810967000735517df0d2db211
RNLocalize: 13a971fd888f2933504a745983f781dc52feeab6
RNReanimated: b04ef2a4f0cb61b062bbcf033f84a9e470f4f60b
RNScreens: 6e1ea5787989f92b0671049b808aef64fa1ef98c
RNSentry: 68644ef607b780551cc555f084869764f2566652
RNFS: 3ab21fa6c56d65566d1fb26c2228e2b6132e5e32
RNGestureHandler: e5c7cab5f214503dcefd6b2b0cefb050e1f51c4a
RNLocalize: f567ea0e35116a641cdffe6683b0d212d568f32a
RNReanimated: da3860204e5660c0dd66739936732197d359d753
RNScreens: 522705f2e5c9d27efb17f24aceb2bf8335bc7b8e
RNSentry: 04bb48bfdd435f5b218cf363f89e6419e9a2460c
RNSVG: 4ecc2e8f38b6ebe7889909570c26f3abe8059767
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: f93010f3f6c031e2f8fb3081ca4ee6966c539815
Sentry: 1d3eb1a25f8c5333c88dd5603904a6d461cd9fcf
Yoga: 2b4a01651f42a32f82e6cef3830a3ba48088237f
Sentry: e58e062056a061ae1145e22ad3dff6e506bff177
SwiftAudioEx: bfaff9894c885aded7edfb0793e25165d55053d4
Yoga: e7dc4e71caba6472ff48ad7d234389b91dadc280
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a
PODFILE CHECKSUM: 640720093b3030dd16ab5dd407cf5fd8c4f7f7c8
PODFILE CHECKSUM: 41246a700cf7cc0f2e61f418d64861444b3079b1
COCOAPODS: 1.11.2

26229
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "JellyfinAudioPlayer",
"version": "0.1.5",
"version": "0.2.0",
"main": "src/index.js",
"private": true,
"scripts": {
@@ -8,68 +8,71 @@
"ios": "react-native run-ios --scheme \"Jellyfin Player\"",
"start": "react-native start",
"test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
"build:ios": "react-native bundle --entry-file='index.ts' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'"
},
"dependencies": {
"@react-native-community/async-storage": "^1.12.1",
"@react-native-community/masked-view": "^0.1.11",
"@react-native-community/picker": "^1.8.1",
"@react-native-community/slider": "^3.0.3",
"@react-navigation/bottom-tabs": "^5.11.15",
"@react-navigation/native": "^5.9.8",
"@react-navigation/stack": "^5.14.9",
"@reduxjs/toolkit": "^1.6.2",
"@sentry/react-native": "^2.6.2",
"@types/lodash": "^4.14.176",
"date-fns": "^2.25.0",
"fuse.js": "^6.4.6",
"@react-native-community/slider": "^4.1.12",
"@react-navigation/bottom-tabs": "^6.0.9",
"@react-navigation/native": "^6.0.6",
"@react-navigation/stack": "^6.0.11",
"@reduxjs/toolkit": "^1.7.1",
"@sentry/react-native": "^3.2.10",
"@types/lodash": "^4.14.178",
"date-fns": "^2.28.0",
"events": "^3.3.0",
"fuse.js": "^6.5.3",
"i18n-js": "^3.8.0",
"lodash": "^4.17.21",
"react": "^17.0.2",
"react-native": "^0.66.1",
"react-native": "^0.66.4",
"react-native-airplay-button": "^1.1.0",
"react-native-collapsible": "^1.6.0",
"react-native-dotenv": "^2.6.2",
"react-native-dotenv": "^3.3.1",
"react-native-fast-image": "^8.5.11",
"react-native-gesture-handler": "^1.10.3",
"react-native-localize": "^2.1.5",
"react-native-reanimated": "^2.2.3",
"react-native-flipper": "^0.127.0",
"react-native-fs": "^2.18.0",
"react-native-gesture-handler": "^2.1.0",
"react-native-localize": "^2.1.7",
"react-native-safe-area-context": "^3.3.2",
"react-native-screens": "^3.8.0",
"react-native-screens": "^3.10.1",
"react-native-svg": "^12.2.0",
"react-native-svg-transformer": "^0.14.3",
"react-native-track-player": "^1.2.7",
"react-native-webview": "^11.14.1",
"react-native-svg-transformer": "^1.0.0",
"react-native-track-player": "^2.1.2",
"react-native-webview": "^11.15.0",
"react-redux": "^7.2.6",
"redux": "^4.1.1",
"redux": "^4.1.2",
"redux-flipper": "^2.0.1",
"redux-logger": "^3.0.6",
"redux-persist": "^6.0.0",
"styled-components": "^5.3.3"
},
"devDependencies": {
"@babel/core": "^7.15.8",
"@babel/runtime": "^7.15.4",
"@react-native-community/eslint-config": "^2.0.0",
"@sentry/cli": "^1.70.1",
"@babel/core": "^7.16.7",
"@babel/runtime": "^7.16.7",
"@react-native-community/eslint-config": "^3.0.1",
"@sentry/cli": "^1.71.0",
"@types/i18n-js": "^3.8.2",
"@types/jest": "^26.0.24",
"@types/react-native": "^0.66.1",
"@types/react-redux": "^7.1.20",
"@types/jest": "^27.4.0",
"@types/react-native": "^0.66.10",
"@types/react-redux": "^7.1.21",
"@types/react-test-renderer": "^17.0.1",
"@types/redux-logger": "^3.0.9",
"@types/styled-components": "^5.1.15",
"@types/styled-components-react-native": "^5.1.2",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"babel-jest": "^26.6.3",
"@types/styled-components": "^5.1.19",
"@types/styled-components-react-native": "^5.1.3",
"@typescript-eslint/eslint-plugin": "^5.8.1",
"@typescript-eslint/parser": "^5.8.1",
"babel-jest": "^27.4.5",
"babel-plugin-module-resolver": "^4.1.0",
"eslint": "^7.32.0",
"eslint-plugin-react-hooks": "^4.2.0",
"jest": "^26.6.3",
"eslint": "^8.5.0",
"eslint-plugin-react-hooks": "^4.3.0",
"jest": "^27.4.5",
"metro-react-native-babel-preset": "^0.66.2",
"react-test-renderer": "^17.0.2",
"typescript": "^4.4.4"
"typescript": "^4.5.4"
},
"jest": {
"preset": "react-native",
@@ -81,5 +84,8 @@
"json",
"node"
]
},
"overrides": {
"@types/react-native": "^0.66.10"
}
}

View File

@@ -1,3 +1,4 @@
export const ALBUM_CACHE_AMOUNT_OF_DAYS = 7;
export const PLAYLIST_CACHE_AMOUNT_OF_DAYS = 7;
export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ ';
export const THEME_COLOR = '#FF3C00';

View File

@@ -0,0 +1,3 @@
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.74512 9.09863C8 9.09863 8.20215 9.01953 8.36035 8.85254L11.9639 5.22266C12.1572 5.0293 12.2451 4.81836 12.2451 4.57227C12.2451 4.33496 12.1484 4.10645 11.9639 3.93066L8.36035 0.265625C8.20215 0.0898438 8 0.00195312 7.74512 0.00195312C7.27051 0.00195312 6.89258 0.397461 6.89258 0.880859C6.89258 1.11816 6.98047 1.31152 7.12988 1.47852L9.23047 3.53516C8.81738 3.47363 8.39551 3.43848 7.97363 3.43848C3.62305 3.43848 0.142578 6.91895 0.142578 11.2783C0.142578 15.6377 3.64941 19.1445 8 19.1445C12.3594 19.1445 15.8574 15.6377 15.8574 11.2783C15.8574 10.751 15.4883 10.373 14.9609 10.373C14.4512 10.373 14.1084 10.751 14.1084 11.2783C14.1084 14.6709 11.3926 17.3955 8 17.3955C4.61621 17.3955 1.8916 14.6709 1.8916 11.2783C1.8916 7.85938 4.58984 5.15234 7.97363 5.15234C8.54492 5.15234 9.07227 5.19629 9.53809 5.27539L7.13867 7.64844C6.98047 7.80664 6.89258 8 6.89258 8.2373C6.89258 8.7207 7.27051 9.09863 7.74512 9.09863Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18.7334C14.9658 18.7334 19.0791 14.6289 19.0791 9.6543C19.0791 4.68848 14.9658 0.575195 9.99121 0.575195C5.02539 0.575195 0.920898 4.68848 0.920898 9.6543C0.920898 14.6289 5.03418 18.7334 10 18.7334ZM10 16.9492C5.95703 16.9492 2.71387 13.6973 2.71387 9.6543C2.71387 5.61133 5.94824 2.36816 9.99121 2.36816C14.0342 2.36816 17.2861 5.61133 17.2949 9.6543C17.2949 13.6973 14.043 16.9492 10 16.9492ZM9.98242 12.335C10.1758 12.335 10.3428 12.2559 10.4834 12.124L13.3838 9.20605C13.542 9.05664 13.5947 8.88965 13.5947 8.71387C13.5947 8.35352 13.3398 8.07227 12.9707 8.07227C12.7861 8.07227 12.6191 8.14258 12.4873 8.27441L11.9336 8.82812L10.5449 10.4014L10.6416 8.90723V5.24219C10.6416 4.85547 10.3691 4.57422 9.98242 4.57422C9.58691 4.57422 9.32324 4.85547 9.32324 5.24219V8.90723L9.42871 10.4102L8.01367 8.81934L7.50391 8.27441C7.38086 8.13379 7.22266 8.07227 7.02051 8.07227C6.65137 8.07227 6.3877 8.33594 6.3877 8.72266C6.3877 8.87207 6.4668 9.07422 6.58984 9.19727L9.49023 12.124C9.63086 12.2646 9.79785 12.335 9.98242 12.335ZM6.7832 14.2598H13.208C13.5859 14.2598 13.8584 13.9785 13.8584 13.6006C13.8584 13.2314 13.5859 12.959 13.208 12.959H6.7832C6.40527 12.959 6.13281 13.2314 6.13281 13.6006C6.13281 13.9785 6.40527 14.2598 6.7832 14.2598Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="21" viewBox="0 0 24 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.63672 15.2002H10.9453V9.82129C10.9453 9.25 11.4287 8.78418 12 8.78418C12.5713 8.78418 13.0459 9.25 13.0459 9.82129V15.2002H18.8643C21.4658 15.2002 23.2324 13.6006 23.2324 11.4033C23.2324 9.58398 22.1689 7.98438 20.4199 7.27246C20.4287 3.2998 17.5635 0.43457 13.8809 0.43457C11.543 0.43457 9.78516 1.63867 8.68652 3.22949C6.58594 2.71094 4.10742 4.30176 4.01953 6.78027C2.00684 7.13184 0.767578 8.90723 0.767578 10.9902C0.767578 13.3193 2.70996 15.2002 5.63672 15.2002ZM12 20.5791C12.2109 20.5791 12.4131 20.5088 12.624 20.2979L15.5771 17.4502C15.7266 17.3008 15.8057 17.1426 15.8057 16.9316C15.8057 16.5186 15.4717 16.2197 15.0674 16.2197C14.8652 16.2197 14.6631 16.3076 14.5225 16.4658L13.3008 17.7666L12.7295 18.417L12.8174 17.1162V15.0859C12.8174 14.6465 12.4482 14.2773 12 14.2773C11.543 14.2773 11.1738 14.6465 11.1738 15.0859V17.1162L11.2617 18.417L10.6992 17.7666L9.46875 16.4658C9.32812 16.3076 9.11719 16.2197 8.91504 16.2197C8.51074 16.2197 8.18555 16.5186 8.18555 16.9316C8.18555 17.1426 8.27344 17.3008 8.42285 17.4502L11.376 20.2979C11.5869 20.5088 11.7803 20.5791 12 20.5791Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.3281 15.6592C21.0703 15.6592 23.2324 13.6465 23.2324 11.1328C23.2324 9.26074 22.1689 7.59961 20.4199 6.87012C20.4287 2.89746 17.5635 0.0322266 13.8809 0.0322266C11.543 0.0322266 9.78516 1.23633 8.68652 2.83594C6.4541 2.23828 4.10742 3.89941 4.01953 6.37793C2.00684 6.73828 0.767578 8.54004 0.767578 10.7461C0.767578 13.418 3.10547 15.6504 6.18164 15.6504L18.3281 15.6592ZM18.3281 13.9014H6.19043C4.09863 13.9014 2.54297 12.4424 2.54297 10.7461C2.54297 8.98828 3.62402 7.6875 5.41699 7.6875C5.54883 7.6875 5.60156 7.61719 5.59277 7.49414C5.54004 4.88379 7.41211 3.9873 9.30176 4.58496C9.41602 4.62012 9.48633 4.59375 9.53906 4.49707C10.4092 2.96777 11.6924 1.78125 13.8721 1.78125C16.6318 1.78125 18.6006 3.96973 18.7324 6.52734C18.7588 7.00195 18.7236 7.51172 18.6885 7.93359C18.6709 8.05664 18.7236 8.12695 18.8379 8.14453C20.4287 8.45215 21.457 9.57715 21.457 11.1328C21.457 12.6709 20.0947 13.9014 18.3281 13.9014ZM12.0088 9.4541C12.4658 9.4541 12.7207 9.19922 12.7471 8.70703L12.8701 5.75391C12.8965 5.24414 12.5098 4.88379 12 4.88379C11.4814 4.88379 11.1123 5.23535 11.1387 5.75391L11.2529 8.71582C11.2793 9.19043 11.5342 9.4541 12.0088 9.4541ZM12 12.2402C12.5537 12.2402 12.9932 11.8447 12.9932 11.3174C12.9932 10.7725 12.5625 10.3857 12 10.3857C11.4375 10.3857 11.0068 10.7812 11.0068 11.3174C11.0068 11.8447 11.4463 12.2402 12 12.2402Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

3
src/assets/cloud.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
<path d="M18.3281 15.6592C21.0703 15.6592 23.2324 13.6465 23.2324 11.1328C23.2324 9.26074 22.1689 7.59961 20.4199 6.87012C20.4287 2.89746 17.5635 0.0322266 13.8809 0.0322266C11.543 0.0322266 9.78516 1.23633 8.68652 2.83594C6.4541 2.23828 4.10742 3.89941 4.01953 6.37793C2.00684 6.73828 0.767578 8.54004 0.767578 10.7461C0.767578 13.418 3.10547 15.6504 6.18164 15.6504L18.3281 15.6592ZM18.3281 13.9014H6.19043C4.09863 13.9014 2.54297 12.4424 2.54297 10.7461C2.54297 8.98828 3.62402 7.6875 5.41699 7.6875C5.54883 7.6875 5.60156 7.61719 5.59277 7.49414C5.54004 4.88379 7.41211 3.9873 9.30176 4.58496C9.41602 4.62012 9.48633 4.59375 9.53906 4.49707C10.4092 2.96777 11.6924 1.78125 13.8721 1.78125C16.6318 1.78125 18.6006 3.96973 18.7324 6.52734C18.7588 7.00195 18.7236 7.51172 18.6885 7.93359C18.6709 8.05664 18.7236 8.12695 18.8379 8.14453C20.4287 8.45215 21.457 9.57715 21.457 11.1328C21.457 12.6709 20.0947 13.9014 18.3281 13.9014Z" />
</svg>

After

Width:  |  Height:  |  Size: 1003 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 22 17" xmlns="http://www.w3.org/2000/svg">
<path d="M0.400391 12.2139C0.400391 14.5781 2.17578 16.3535 4.71582 16.3535H17.2842C19.8242 16.3535 21.5996 14.5781 21.5996 12.2139C21.5996 11.502 21.3975 10.8604 21.1514 10.2803L18.1279 3.19629C17.5127 1.74609 16.291 0.981445 14.6562 0.981445H7.35254C5.70898 0.981445 4.4873 1.74609 3.88086 3.19629L0.875 10.2451C0.620117 10.834 0.400391 11.4844 0.400391 12.2139ZM3.57324 8.26758L5.48926 3.5918C5.78809 2.83594 6.46484 2.44043 7.37012 2.44043H14.6299C15.5439 2.44043 16.2207 2.83594 16.5195 3.5918L18.4355 8.26758C18.084 8.15332 17.6973 8.08301 17.2842 8.08301H4.71582C4.30273 8.08301 3.9248 8.15332 3.57324 8.26758ZM2.08789 12.2139C2.08789 10.8164 3.13379 9.77051 4.71582 9.77051H17.2842C18.8662 9.77051 19.9121 10.8164 19.9121 12.2139C19.9121 13.7607 18.8662 14.6572 17.2842 14.6572H4.71582C3.13379 14.6572 2.08789 13.6201 2.08789 12.2139ZM8.45117 13.084C8.45117 13.3828 8.68848 13.6113 8.9873 13.6113C9.27734 13.6113 9.50586 13.3828 9.50586 13.084V11.3525C9.50586 11.0625 9.27734 10.8252 8.9873 10.8252C8.68848 10.8252 8.45117 11.0625 8.45117 11.3525V13.084ZM10.4727 13.084C10.4727 13.3828 10.7012 13.6113 11 13.6113C11.29 13.6113 11.5273 13.3828 11.5273 13.084V11.3525C11.5273 11.0625 11.29 10.8252 11 10.8252C10.7012 10.8252 10.4727 11.0625 10.4727 11.3525V13.084ZM12.4854 13.084C12.4854 13.3828 12.7227 13.6113 13.0215 13.6113C13.3115 13.6113 13.5488 13.3828 13.5488 13.084V11.3525C13.5488 11.0625 13.3115 10.8252 13.0215 10.8252C12.7227 10.8252 12.4854 11.0625 12.4854 11.3525V13.084ZM14.5068 13.084C14.5068 13.3828 14.7441 13.6113 15.043 13.6113C15.333 13.6113 15.5703 13.3828 15.5703 13.084V11.3525C15.5703 11.0625 15.333 10.8252 15.043 10.8252C14.7441 10.8252 14.5068 11.0625 14.5068 11.3525V13.084ZM16.5283 13.084C16.5283 13.3828 16.7656 13.6113 17.0645 13.6113C17.3545 13.6113 17.583 13.3828 17.583 13.084V11.3525C17.583 11.0625 17.3545 10.8252 17.0645 10.8252C16.7656 10.8252 16.5283 11.0625 16.5283 11.3525V13.084Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

3
src/assets/trash.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.84277 22.4785H18.166C19.3701 22.4785 20.0732 21.8369 20.126 20.6416L20.6797 7.94141H21.8926C22.3408 7.94141 22.6836 7.58984 22.6836 7.15039C22.6836 6.71094 22.332 6.37695 21.8926 6.37695H18.0781V5.05859C18.0781 3.65234 17.1729 2.81738 15.6611 2.81738H12.3213C10.8096 2.81738 9.9043 3.65234 9.9043 5.05859V6.37695H6.10742C5.66797 6.37695 5.31641 6.71973 5.31641 7.15039C5.31641 7.59863 5.66797 7.94141 6.10742 7.94141H7.3291L7.8916 20.6416C7.93555 21.8369 8.63867 22.4785 9.84277 22.4785ZM11.7324 5.1377C11.7324 4.74219 12.0049 4.4873 12.4443 4.4873H15.5469C15.9863 4.4873 16.2588 4.74219 16.2588 5.1377V6.37695H11.7324V5.1377ZM11.1787 19.7803C10.8271 19.7803 10.5811 19.5518 10.5723 19.2002L10.3086 9.86621C10.2998 9.51465 10.5459 9.27734 10.915 9.27734C11.2666 9.27734 11.5127 9.50586 11.5215 9.85742L11.7852 19.1914C11.8027 19.543 11.5566 19.7803 11.1787 19.7803ZM14 19.7803C13.6309 19.7803 13.3848 19.5518 13.3848 19.2002V9.85742C13.3848 9.51465 13.6309 9.27734 14 9.27734C14.3691 9.27734 14.624 9.51465 14.624 9.85742V19.2002C14.624 19.5518 14.3691 19.7803 14 19.7803ZM16.8213 19.7891C16.4434 19.7891 16.1973 19.543 16.2148 19.2002L16.4785 9.85742C16.4873 9.50586 16.7334 9.27734 17.085 9.27734C17.4541 9.27734 17.7002 9.51465 17.6914 9.86621L17.4277 19.2002C17.4189 19.5518 17.1729 19.7891 16.8213 19.7891Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { Provider } from 'react-redux';
import TrackPlayer from 'react-native-track-player';
import TrackPlayer, { Capability } from 'react-native-track-player';
import { PersistGate } from 'redux-persist/integration/react';
import Routes from '../screens';
import store, { persistedStore } from 'store';
@@ -12,7 +12,6 @@ import {
import { useColorScheme } from 'react-native';
import { ColorSchemeContext, themes } from './Colors';
// import ErrorReportingAlert from 'utility/ErrorReportingAlert';
import PlayerStateUpdater from './PlayerStateUpdater';
export default function App(): JSX.Element {
const colorScheme = useColorScheme();
@@ -24,12 +23,12 @@ export default function App(): JSX.Element {
await TrackPlayer.setupPlayer();
await TrackPlayer.updateOptions({
capabilities: [
TrackPlayer.CAPABILITY_PLAY,
TrackPlayer.CAPABILITY_PAUSE,
TrackPlayer.CAPABILITY_SKIP_TO_NEXT,
TrackPlayer.CAPABILITY_SKIP_TO_PREVIOUS,
TrackPlayer.CAPABILITY_STOP,
TrackPlayer.CAPABILITY_SEEK_TO,
Capability.Play,
Capability.Pause,
Capability.SkipToNext,
Capability.SkipToPrevious,
Capability.Stop,
Capability.SeekTo,
]
});
}
@@ -42,7 +41,6 @@ export default function App(): JSX.Element {
<ColorSchemeContext.Provider value={theme}>
<NavigationContainer theme={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Routes />
<PlayerStateUpdater />
</NavigationContainer>
</ColorSchemeContext.Provider>
</PersistGate>

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState } from 'react';
import { SvgProps } from 'react-native-svg';
import {
PressableProps, ViewProps,
PressableProps, ViewProps, View,
} from 'react-native';
import { THEME_COLOR } from 'CONSTANTS';
import styled, { css } from 'styled-components/native';
@@ -13,7 +13,6 @@ interface ButtonProps extends PressableProps {
style?: ViewProps['style'];
}
const BaseButton = styled.Pressable`
padding: 16px;
border-radius: 8px;
@@ -21,6 +20,10 @@ const BaseButton = styled.Pressable`
align-items: center;
justify-content: center;
flex-grow: 1;
${(props) => props.disabled && css`
opacity: 0.25;
`}
`;
const ButtonText = styled.Text<{ active?: boolean }>`
@@ -32,8 +35,8 @@ const ButtonText = styled.Text<{ active?: boolean }>`
`}
`;
export default function Button(props: ButtonProps) {
const { icon: Icon, title, ...rest } = props;
const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
const { icon: Icon, title, disabled, ...rest } = props;
const defaultStyles = useDefaultStyles();
const [isPressed, setPressed] = useState(false);
const handlePressIn = useCallback(() => setPressed(true), []);
@@ -42,6 +45,9 @@ export default function Button(props: ButtonProps) {
return (
<BaseButton
{...rest}
disabled={disabled}
// @ts-expect-error styled-components has outdated react-native typings
ref={ref}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
@@ -51,8 +57,8 @@ export default function Button(props: ButtonProps) {
>
{Icon &&
<Icon
width={12}
height={12}
width={14}
height={14}
fill={isPressed ? '#fff' : THEME_COLOR}
style={{
marginRight: 8,
@@ -62,4 +68,6 @@ export default function Button(props: ButtonProps) {
<ButtonText active={isPressed}>{title}</ButtonText>
</BaseButton>
);
}
});
export default Button;

View File

@@ -28,7 +28,7 @@ function generateStyles(scheme: ColorSchemeName) {
backgroundColor: scheme === 'dark' ? '#333' : '#ddd',
},
modal: {
backgroundColor: scheme === 'dark' ? '#222222ee' : '#eeeeeeee',
backgroundColor: scheme === 'dark' ? '#22222200' : '#eeeeee00',
},
modalInner: {
backgroundColor: scheme === 'dark' ? '#000' : '#fff',

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useTypedSelector } from 'store';
import CloudIcon from 'assets/cloud.svg';
import CloudExclamationMarkIcon from 'assets/cloud-exclamation-mark.svg';
import InternalDriveIcon from 'assets/internal-drive.svg';
import useDefaultStyles from './Colors';
import { EntityId } from '@reduxjs/toolkit';
import Svg, { Circle, CircleProps } from 'react-native-svg';
import { Animated, Easing } from 'react-native';
interface DownloadIconProps {
trackId: EntityId;
size?: number;
fill?: string;
}
function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
// determine styles
const defaultStyles = useDefaultStyles();
const iconFill = fill || defaultStyles.textHalfOpacity.color;
// Get download icon from state
const entity = useTypedSelector((state) => state.downloads.entities[trackId]);
// Memoize calculations for radius and circumference of the circle
const radius = useMemo(() => size / 2, [size]);
const circumference = useMemo(() => radius * 2 * Math.PI, [radius]);
// Initialize refs for the circle and the animated value
const circleRef = useRef<Circle>(null);
const offsetAnimation = useRef(new Animated.Value(entity?.progress || 0)).current;
// Whenever the progress changes, trigger the animation
useEffect(() => {
Animated.timing(offsetAnimation, {
toValue: (circumference * (1 - (entity?.progress || 0))),
duration: 250,
useNativeDriver: false,
easing: Easing.ease,
}).start();
}, [entity?.progress, offsetAnimation, circumference]);
// On mount, subscribe to changes in the animation value and then
// apply them to the circle using native props
useEffect(() => {
const subscription = offsetAnimation.addListener((offset) => {
// @ts-expect-error undocumented functionality
const setNativeProps = circleRef.current?.setNativeProps as (props: CircleProps) => void | undefined;
setNativeProps?.({ strokeDashoffset: offset.value });
});
return () => offsetAnimation.removeListener(subscription);
}, [offsetAnimation]);
if (!entity) {
return (
<CloudIcon width={size} height={size} fill={iconFill} />
);
}
const { isComplete, isFailed } = entity;
if (isComplete) {
return (
<InternalDriveIcon width={size} height={size} fill={iconFill} />
);
}
if (isFailed) {
return (
<CloudExclamationMarkIcon width={size} height={size} fill={iconFill} />
);
}
if (!isComplete && !isFailed) {
return (
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
<Circle
cx={radius}
cy={radius}
r={radius - 1}
stroke={iconFill}
// @ts-expect-error react-native-svg has outdated react-native typings
ref={circleRef}
strokeWidth={1.5}
strokeDasharray={[ circumference, circumference ]}
strokeDashoffset={circumference}
strokeLinecap='round'
fill='transparent'
/>
</Svg>
);
}
return null;
}
export default DownloadIcon;

View File

@@ -34,6 +34,7 @@ const ListButton: React.FC<TouchableOpacityProps> = ({ children, ...props }) =>
const handlePressOut = useCallback(() => setPressed(false), []);
return (
// @ts-expect-error styled-components has outdated react-native typings
<Container
{...props}
onPressIn={handlePressIn}

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import styled, { css } from 'styled-components/native';
import { SafeAreaView, Pressable } from 'react-native';
import { Pressable } from 'react-native';
import { useNavigation, StackActions } from '@react-navigation/native';
import useDefaultStyles from './Colors';
@@ -9,17 +9,21 @@ interface Props {
}
const Background = styled(Pressable)`
padding: 100px 25px;
flex: 1;
justify-content: center;
`;
const Container = styled(Pressable)<Pick<Props, 'fullSize'>>`
border-radius: 20px;
margin: auto 0;
margin: auto 20px;
padding: 4px;
border-radius: 12px;
flex: 0 0 auto;
background: salmon;
${props => props.fullSize && css`
flex: 1;
margin: auto 0;
border-radius: 0px;
`}
`;
@@ -32,11 +36,9 @@ const Modal: React.FC<Props> = ({ children, fullSize = true }) => {
return (
<Background style={defaultStyles.modal} onPress={closeModal}>
<SafeAreaView style={{ flex: 1 }}>
<Container style={defaultStyles.modalInner} fullSize={fullSize}>
{children}
</Container>
</SafeAreaView>
<Container style={defaultStyles.modalInner} fullSize={fullSize}>
{children}
</Container>
</Background>
);
};

View File

@@ -1,54 +0,0 @@
import { useCallback, useEffect } from 'react';
import TrackPlayer, { TrackPlayerEvents } from 'react-native-track-player';
import { shallowEqual, useDispatch } from 'react-redux';
import { useTypedSelector } from 'store';
import player from 'store/player';
function PlayerStateUpdater() {
const dispatch = useDispatch();
const trackId = useTypedSelector(state => state.player.currentTrack?.id, shallowEqual);
const handleUpdate = useCallback(async () => {
const currentTrackId = await TrackPlayer.getCurrentTrack();
// GUARD: Only retrieve new track if it is different from the one we
// have currently in state.
if (currentTrackId === trackId){
return;
}
// GUARD: Only fetch current track if there is a current track
if (!currentTrackId) {
dispatch(player.actions.setCurrentTrack(undefined));
}
// If it is different, retrieve the track and save it
try {
const currentTrack = await TrackPlayer.getTrack(currentTrackId);
dispatch(player.actions.setCurrentTrack(currentTrack));
} catch {
// Due to the async nature, a track might be removed at the
// point when we try to retrieve it. If this happens, we'll just
// smother the error and wait for a new track update to
// finish.
}
}, [trackId, dispatch]);
useEffect(() => {
function handler() {
handleUpdate();
}
handler();
const subscription = TrackPlayer.addEventListener(TrackPlayerEvents.PLAYBACK_TRACK_CHANGED, handler);
return () => {
subscription.remove();
};
}, []);
return null;
}
export default PlayerStateUpdater;

View File

@@ -1,10 +1,10 @@
import React, { useCallback } from 'react';
import React, { PropsWithChildren, useCallback } from 'react';
import { Pressable, ViewStyle } from 'react-native';
interface TouchableHandlerProps {
id: string;
onPress: (id: string) => void;
onLongPress?: (id: string) => void;
interface TouchableHandlerProps<T = number> {
id: T;
onPress: (id: T) => void;
onLongPress?: (id: T) => void;
}
function TouchableStyles({ pressed }: { pressed: boolean }): ViewStyle {
@@ -19,7 +19,12 @@ function TouchableStyles({ pressed }: { pressed: boolean }): ViewStyle {
* This is a generic handler that accepts id as a prop, and return it when it is
* pressed. This comes in handy with lists in which albums / tracks need to be selected.
*/
const TouchableHandler: React.FC<TouchableHandlerProps> = ({ id, onPress, onLongPress, children }) => {
function TouchableHandler<T>({
id,
onPress,
onLongPress,
children
}: PropsWithChildren<TouchableHandlerProps<T>>): JSX.Element {
const handlePress = useCallback(() => {
return onPress(id);
}, [id, onPress]);
@@ -37,6 +42,6 @@ const TouchableHandler: React.FC<TouchableHandlerProps> = ({ id, onPress, onLon
{children}
</Pressable>
);
};
}
export default TouchableHandler;

View File

@@ -10,4 +10,5 @@ export const Header = styled(Text)`
export const SubHeader = styled(Text)`
font-size: 24px;
margin: 12px 0;
font-weight: 500;
`;

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components/native';
import Button from './Button';
export const WrappableButtonRow = styled.View`
flex: 0 0 auto;
flex-direction: row;
flex-wrap: wrap;
margin: 6px -2px;
`;
export const WrappableButton = styled(Button)`
margin: 2px;
`;

View File

@@ -39,5 +39,21 @@
"enable": "Enable",
"disable": "Disable",
"more-info": "More Info",
"track": "Track"
"track": "Track",
"playlists": "Playlists",
"playlist": "Playlist",
"play-playlist": "Play Playlist",
"shuffle-album": "Shuffle Album",
"shuffle-playlist": "Shuffle Playlist",
"downloads": "Downloads",
"download-track": "Download Track",
"download-album": "Download Album",
"download-playlist": "Download Playlist",
"no-downloads": "You have not yet downloaded any tracks",
"delete-track": "Delete Track",
"delete-all-tracks": "Delete All Tracks",
"delete-album": "Delete Album",
"delete-playlist": "Delete Playlist",
"total-download-size": "Total Download Size",
"retry-failed-downloads": "Retry Failed Downloads"
}

View File

@@ -1,6 +1,6 @@
{
"play-next": "Speel volgende",
"play-album": "Speel album",
"play-album": "Speel Album",
"queue": "Wachtrij",
"add-to-queue": "Voeg toe aan wachtrij",
"clear-queue": "Wis wachtrij",
@@ -38,5 +38,11 @@
"enable-error-reporting-description": "Dit helpt de appervaring te verbeteren door ons rapportages te sturen van crashes en andere foutmeldingen.",
"enable": "Zet aan",
"disable": "Zet uit",
"more-info": "Meer informatie"
"more-info": "Meer informatie",
"track": "Track",
"playlists": "Playlists",
"playlist": "Playlist",
"play-playlist": "Speel Playlist",
"shuffle-album": "Shuffle Album",
"shuffle-playlist": "Shuffle Playlist"
}

View File

@@ -38,4 +38,19 @@ export type LocaleKeys = 'play-next'
| 'enable'
| 'disable'
| 'more-info'
| 'track'
| 'track'
| 'playlists'
| 'playlist'
| 'play-playlist'
| 'shuffle-album'
| 'shuffle-playlist'
| 'downloads'
| 'download-track'
| 'download-album'
| 'download-playlist'
| 'delete-album'
| 'delete-playlist'
| 'delete-track'
| 'total-download-size'
| 'no-downloads'
| 'retry-failed-downloads'

View File

@@ -0,0 +1,144 @@
import useDefaultStyles from 'components/Colors';
import React, { useCallback, useMemo } from 'react';
import { FlatListProps, Text, TouchableOpacity, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTypedSelector } from 'store';
import formatBytes from 'utility/formatBytes';
import TrashIcon from 'assets/trash.svg';
import ArrowClockwise from 'assets/arrow-clockwise.svg';
import { THEME_COLOR } from 'CONSTANTS';
import { useDispatch } from 'react-redux';
import { EntityId } from '@reduxjs/toolkit';
import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
import Button from 'components/Button';
import { t } from 'i18n-js';
import DownloadIcon from 'components/DownloadIcon';
import styled from 'styled-components/native';
const DownloadedTrack = styled.View`
flex: 1 0 auto;
flex-direction: row;
padding: 8px 0;
align-items: center;
margin: 0 20px;
border-bottom-width: 1px;
`;
function Downloads() {
const defaultStyles = useDefaultStyles();
const dispatch = useDispatch();
const { entities, ids } = useTypedSelector((state) => state.downloads);
const tracks = useTypedSelector((state) => state.music.tracks.entities);
// Calculate the total download size
const totalDownloadSize = useMemo(() => (
ids?.reduce<number>((sum, id) => sum + (entities[id]?.size || 0), 0)
), [ids, entities]);
/**
* Handlers for actions in this components
*/
// Delete a single downloaded track
const handleDelete = useCallback((id: EntityId) => {
dispatch(removeDownloadedTrack(id));
}, [dispatch]);
// Delete all downloaded tracks
const handleDeleteAllTracks = useCallback(() => ids.forEach(handleDelete), [handleDelete, ids]);
// Retry a single failed track
const retryTrack = useCallback((id: EntityId) => {
dispatch(downloadTrack(id));
}, [dispatch]);
// Retry all failed tracks
const failedIds = useMemo(() => ids.filter((id) => !entities[id]?.isComplete), [ids, entities]);
const handleRetryFailed = useCallback(() => (
failedIds.forEach(retryTrack)
), [failedIds, retryTrack]);
/**
* Render section
*/
const ListHeaderComponent = useMemo(() => (
<View style={{ marginHorizontal: 20, marginBottom: 12 }}>
<Text style={[{ textAlign: 'center', marginVertical: 6 }, defaultStyles.textHalfOpacity]}>
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
</Text>
<Button
icon={TrashIcon}
title={t('delete-all-tracks')}
onPress={handleDeleteAllTracks}
disabled={!ids.length}
style={{ marginTop: 8 }}
/>
<Button
icon={ArrowClockwise}
title={t('retry-failed-downloads')}
onPress={handleRetryFailed}
disabled={failedIds.length === 0}
style={{ marginTop: 4 }}
/>
</View>
), [totalDownloadSize, defaultStyles, failedIds.length, handleRetryFailed, handleDeleteAllTracks, ids.length]);
const renderItem = useCallback<NonNullable<FlatListProps<EntityId>['renderItem']>>(({ item }) => (
<DownloadedTrack style={defaultStyles.border}>
<View style={{ marginRight: 12 }}>
<DownloadIcon trackId={item} />
</View>
<View style={{ flexShrink: 1, marginRight: 8 }}>
<Text style={{ fontSize: 16, marginBottom: 4 }} numberOfLines={1}>
{tracks[item]?.Name}
</Text>
<Text style={[{ flexShrink: 1, fontSize: 11 }, defaultStyles.textHalfOpacity]} numberOfLines={1}>
{tracks[item]?.AlbumArtist} ({tracks[item]?.Album})
</Text>
</View>
<View style={{ marginLeft: 'auto', flexDirection: 'row', alignItems: 'center' }}>
{entities[item]?.isComplete && entities[item]?.size ? (
<Text style={[defaultStyles.textHalfOpacity, { marginRight: 6, fontSize: 12 }]}>
{formatBytes(entities[item]?.size || 0)}
</Text>
) : null}
<TouchableOpacity onPress={() => handleDelete(item)}>
<TrashIcon height={24} width={24} fill={THEME_COLOR} />
</TouchableOpacity>
{!entities[item]?.isComplete && (
<TouchableOpacity onPress={() => retryTrack(item)}>
<ArrowClockwise height={18} width={18} fill={THEME_COLOR} />
</TouchableOpacity>
)}
</View>
</DownloadedTrack>
), [entities, retryTrack, handleDelete, defaultStyles, tracks]);
// If no tracks have been downloaded, show a short message describing this
if (!ids.length) {
return (
<View style={{ margin: 24, flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={[{ textAlign: 'center'}, defaultStyles.textHalfOpacity]}>
{t('no-downloads')}
</Text>
</View>
);
}
return (
<SafeAreaView style={{ flex: 1 }}>
<FlatList
data={ids}
style={{ flex: 1 }}
contentContainerStyle={{ flexGrow: 1 }}
ListHeaderComponent={ListHeaderComponent}
renderItem={renderItem}
/>
</SafeAreaView>
);
}
export default Downloads;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { StackParams } from './types';
import { MusicStackParams } from './types';
import Albums from './stacks/Albums';
import Album from './stacks/Album';
import RecentAlbums from './stacks/RecentAlbums';
@@ -8,8 +8,10 @@ import Search from './stacks/Search';
import { THEME_COLOR } from 'CONSTANTS';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import Playlists from './stacks/Playlists';
import Playlist from './stacks/Playlist';
const Stack = createStackNavigator<StackParams>();
const Stack = createStackNavigator<MusicStackParams>();
function MusicStack() {
const defaultStyles = useDefaultStyles();
@@ -22,6 +24,8 @@ function MusicStack() {
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums') }} />
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
<Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />
<Stack.Screen name="Playlist" component={Playlist} options={{ headerTitle: t('playlist') }} />
<Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search') }} />
</Stack.Navigator>
);

View File

@@ -1,140 +1,46 @@
import React, { useCallback, useEffect } from 'react';
import { StackParams } from '../types';
import { Text, ScrollView, Dimensions, RefreshControl, StyleSheet, View } from 'react-native';
import { useGetImage } from 'utility/JellyfinApi';
import styled, { css } from 'styled-components/native';
import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
import FastImage from 'react-native-fast-image';
import { useDispatch } from 'react-redux';
import { differenceInDays } from 'date-fns';
import { MusicStackParams } from '../types';
import { useRoute, RouteProp } from '@react-navigation/native';
import { useTypedSelector } from 'store';
import TrackListView from './components/TrackListView';
import { fetchTracksByAlbum } from 'store/music/actions';
import { ALBUM_CACHE_AMOUNT_OF_DAYS, THEME_COLOR } from 'CONSTANTS';
import usePlayAlbum from 'utility/usePlayAlbum';
import TouchableHandler from 'components/TouchableHandler';
import useCurrentTrack from 'utility/useCurrentTrack';
import TrackPlayer from 'react-native-track-player';
import { differenceInDays } from 'date-fns';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import { t } from '@localisation';
import Button from 'components/Button';
import Play from 'assets/play.svg';
import useDefaultStyles from 'components/Colors';
import { useDispatch } from 'react-redux';
type Route = RouteProp<StackParams, 'Album'>;
const Screen = Dimensions.get('screen');
const styles = StyleSheet.create({
name: {
fontSize: 36,
fontWeight: 'bold'
},
artist: {
fontSize: 24,
opacity: 0.5,
marginBottom: 24
},
index: {
width: 20,
opacity: 0.5,
marginRight: 5
}
});
const AlbumImage = styled(FastImage)`
border-radius: 10px;
width: ${Screen.width * 0.6}px;
height: ${Screen.width * 0.6}px;
margin: 10px auto;
`;
const TrackContainer = styled.View<{isPlaying: boolean}>`
padding: 15px;
border-bottom-width: 1px;
flex-direction: row;
${props => props.isPlaying && css`
background-color: ${THEME_COLOR}16;
margin: 0 -20px;
padding: 15px 35px;
`}
`;
type Route = RouteProp<MusicStackParams, 'Album'>;
const Album: React.FC = () => {
const defaultStyles = useDefaultStyles();
// Retrieve state
const { params: { id } } = useRoute<Route>();
const tracks = useTypedSelector((state) => state.music.tracks.entities);
const album = useTypedSelector((state) => state.music.albums.entities[id]);
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
// Retrieve helpers
const dispatch = useDispatch();
const getImage = useGetImage();
const playAlbum = usePlayAlbum();
const currentTrack = useCurrentTrack();
const navigation = useNavigation();
// Setup callbacks
const selectAlbum = useCallback(() => { playAlbum(id); }, [playAlbum, id]);
// Retrieve the album data from the store
const album = useTypedSelector((state) => state.music.albums.entities[id]);
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
// Define a function for refreshing this entity
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]);
const selectTrack = useCallback(async (trackId) => {
const tracks = await playAlbum(id, false);
if (tracks) {
const track = tracks.find((t) => t.id.startsWith(trackId));
if (track) {
await TrackPlayer.skip(track.id);
await TrackPlayer.play();
}
}
}, [playAlbum, id]);
const longPressTrack = useCallback((trackId: string) => {
navigation.navigate('TrackPopupMenu', { trackId });
}, [navigation]);
// Retrieve album tracks on load
// Auto-fetch the track data periodically
useEffect(() => {
if (!album?.lastRefreshed || differenceInDays(album?.lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) {
refresh();
}
}, [album?.lastRefreshed, refresh]);
// GUARD: If there is no album, we cannot render a thing
if (!album) {
return null;
}
return (
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 50 }}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
}
>
<AlbumImage source={{ uri: getImage(album?.Id) }} style={defaultStyles.imageBackground} />
<Text style={[ defaultStyles.text, styles.name ]} >{album?.Name}</Text>
<Text style={[ defaultStyles.text, styles.artist ]}>{album?.AlbumArtist}</Text>
<Button title={t('play-album')} icon={Play} onPress={selectAlbum} />
<View style={{ marginTop: 15 }}>
{album?.Tracks?.length ? album.Tracks.map((trackId) =>
<TouchableHandler
key={trackId}
id={trackId}
onPress={selectTrack}
onLongPress={longPressTrack}
>
<TrackContainer isPlaying={currentTrack?.id.startsWith(trackId) || false} style={defaultStyles.border}>
<Text style={[ defaultStyles.text, styles.index ]}>
{tracks[trackId]?.IndexNumber}
</Text>
<Text style={defaultStyles.text}>{tracks[trackId]?.Name}</Text>
</TrackContainer>
</TouchableHandler>
) : undefined}
</View>
</ScrollView>
<TrackListView
trackIds={albumTracks || []}
title={album?.Name}
artist={album?.AlbumArtist}
entityId={id}
refresh={refresh}
playButtonText={t('play-album')}
shuffleButtonText={t('shuffle-album')}
downloadButtonText={t('download-album')}
deleteButtonText={t('delete-album')}
/>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { Album, NavigationProp } from '../types';
import { MusicNavigationProp } from '../types';
import { Text, SafeAreaView, SectionList, View } from 'react-native';
import { useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
@@ -15,6 +15,7 @@ import AlphabetScroller from 'components/AlphabetScroller';
import { EntityId } from '@reduxjs/toolkit';
import styled from 'styled-components/native';
import useDefaultStyles from 'components/Colors';
import { Album } from 'store/music/types';
interface VirtualizedItemInfo {
section: SectionedId,
@@ -92,12 +93,12 @@ const Albums: React.FC = () => {
// Retrieve data from store
const { entities: albums } = useTypedSelector((state) => state.music.albums);
const isLoading = useTypedSelector((state) => state.music.albums.isLoading);
const lastRefreshed = useTypedSelector((state) => state.music.lastRefreshed);
const lastRefreshed = useTypedSelector((state) => state.music.albums.lastRefreshed);
const sections = useTypedSelector(selectAlbumsByAlphabet);
// Initialise helpers
const dispatch = useDispatch();
const navigation = useNavigation<NavigationProp>();
const navigation = useNavigation<MusicNavigationProp>();
const getImage = useGetImage();
const listRef = useRef<SectionList<EntityId>>(null);

View File

@@ -0,0 +1,47 @@
import React, { useCallback, useEffect } from 'react';
import { MusicStackParams } from '../types';
import { useRoute, RouteProp } from '@react-navigation/native';
import { useTypedSelector } from 'store';
import TrackListView from './components/TrackListView';
import { fetchTracksByPlaylist } from 'store/music/actions';
import { differenceInDays } from 'date-fns';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import { t } from '@localisation';
import { useDispatch } from 'react-redux';
type Route = RouteProp<MusicStackParams, 'Album'>;
const Playlist: React.FC = () => {
const { params: { id } } = useRoute<Route>();
const dispatch = useDispatch();
// Retrieve the album data from the store
const playlist = useTypedSelector((state) => state.music.playlists.entities[id]);
const playlistTracks = useTypedSelector((state) => state.music.tracks.byPlaylist[id]);
// Define a function for refreshing this entity
const refresh = useCallback(() => dispatch(fetchTracksByPlaylist(id)), [dispatch, id]);
// Auto-fetch the track data periodically
useEffect(() => {
if (!playlist?.lastRefreshed || differenceInDays(playlist?.lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) {
refresh();
}
}, [playlist?.lastRefreshed, refresh]);
return (
<TrackListView
trackIds={playlistTracks || []}
title={playlist?.Name}
entityId={id}
refresh={refresh}
listNumberingStyle='index'
playButtonText={t('play-playlist')}
shuffleButtonText={t('shuffle-playlist')}
downloadButtonText={t('download-playlist')}
deleteButtonText={t('delete-playlist')}
/>
);
};
export default Playlist;

View File

@@ -0,0 +1,110 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { MusicNavigationProp } from '../types';
import { Text, View, FlatList, ListRenderItem } from 'react-native';
import { useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { differenceInDays } from 'date-fns';
import { useTypedSelector } from 'store';
import { fetchAllPlaylists } from 'store/music/actions';
import { PLAYLIST_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import TouchableHandler from 'components/TouchableHandler';
import AlbumImage, { AlbumItem } from './components/AlbumImage';
import { EntityId } from '@reduxjs/toolkit';
import useDefaultStyles from 'components/Colors';
interface GeneratedAlbumItemProps {
id: ReactText;
imageUrl: string;
name: string;
onPress: (id: string) => void;
}
const GeneratedPlaylistItem = React.memo(function GeneratedPlaylistItem(props: GeneratedAlbumItemProps) {
const defaultStyles = useDefaultStyles();
const { id, imageUrl, name, onPress } = props;
return (
<TouchableHandler id={id as string} onPress={onPress}>
<AlbumItem>
<AlbumImage source={{ uri: imageUrl }} style={defaultStyles.imageBackground} />
<Text numberOfLines={1} style={defaultStyles.text}>{name}</Text>
</AlbumItem>
</TouchableHandler>
);
});
const Playlists: React.FC = () => {
// Retrieve data from store
const { entities, ids } = useTypedSelector((state) => state.music.playlists);
const isLoading = useTypedSelector((state) => state.music.playlists.isLoading);
const lastRefreshed = useTypedSelector((state) => state.music.playlists.lastRefreshed);
// Initialise helpers
const dispatch = useDispatch();
const navigation = useNavigation<MusicNavigationProp>();
const getImage = useGetImage();
const listRef = useRef<FlatList<EntityId>>(null);
const getItemLayout = useCallback((data: EntityId[] | null | undefined, index: number): { offset: number, length: number, index: number } => {
const length = 220;
const offset = length * index;
return { index, length, offset };
}, []);
// Set callbacks
const retrieveData = useCallback(() => dispatch(fetchAllPlaylists()), [dispatch]);
const selectAlbum = useCallback((id: string) => {
navigation.navigate('Playlist', { id });
}, [navigation]);
const generateItem: ListRenderItem<EntityId> = useCallback(({ item, index }) => {
if (index % 2 === 1) {
return <View key={item} />;
}
const nextItemId = ids[index + 1];
const nextItem = entities[nextItemId];
return (
<View style={{ flexDirection: 'row', marginLeft: 10, marginRight: 10 }} key={item}>
<GeneratedPlaylistItem
id={item}
imageUrl={getImage(item as string)}
name={entities[item]?.Name || ''}
onPress={selectAlbum}
/>
{nextItem &&
<GeneratedPlaylistItem
id={nextItemId}
imageUrl={getImage(nextItemId as string)}
name={nextItem.Name || ''}
onPress={selectAlbum}
/>
}
</View>
);
}, [entities, getImage, selectAlbum, ids]);
// Retrieve data on mount
useEffect(() => {
// GUARD: Only refresh this API call every set amounts of days
if (!lastRefreshed || differenceInDays(lastRefreshed, new Date()) > PLAYLIST_CACHE_AMOUNT_OF_DAYS) {
retrieveData();
}
});
return (
<FlatList
data={ids}
refreshing={isLoading}
onRefresh={retrieveData}
getItemLayout={getItemLayout}
ref={listRef}
keyExtractor={(item, index) => `${item}_${index}`}
renderItem={generateItem}
/>
);
};
export default Playlists;

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { Album, NavigationProp } from '../types';
import { MusicNavigationProp } from '../types';
import { Text, SafeAreaView, FlatList, StyleSheet } from 'react-native';
import { useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
@@ -14,6 +14,7 @@ import { Header } from 'components/Typography';
import ListButton from 'components/ListButton';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import { Album } from 'store/music/types';
const styles = StyleSheet.create({
columnWrapper: {
@@ -23,14 +24,16 @@ const styles = StyleSheet.create({
});
const NavigationHeader: React.FC = () => {
const navigation = useNavigation();
const navigation = useNavigation<MusicNavigationProp>();
const defaultStyles = useDefaultStyles();
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]);
const handleSearchClick = useCallback(() => { navigation.navigate('Search'); }, [navigation]);
return (
<>
<ListButton onPress={handleAllAlbumsClick}>{t('all-albums')}</ListButton>
<ListButton onPress={handlePlaylistsClick}>{t('playlists')}</ListButton>
<ListButton onPress={handleSearchClick}>{t('search')}</ListButton>
<ListContainer>
<Header style={defaultStyles.text}>{t('recent-albums')}</Header>
@@ -49,7 +52,7 @@ const RecentAlbums: React.FC = () => {
// Initialise helpers
const dispatch = useDispatch();
const navigation = useNavigation<NavigationProp>();
const navigation = useNavigation<MusicNavigationProp>();
const getImage = useGetImage();
// Set callbacks

View File

@@ -1,26 +1,31 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Input from 'components/Input';
import { ActivityIndicator, Text, View } from 'react-native';
import { ActivityIndicator, Text, TextInput, View } from 'react-native';
import styled from 'styled-components/native';
import { useAppDispatch, useTypedSelector } from 'store';
import { 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 { NavigationProp } from '../types';
import { MusicNavigationProp } from '../types';
import FastImage from 'react-native-fast-image';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import { searchAndFetchAlbums } from 'store/music/actions';
import { debounce } from 'lodash';
import { useDispatch } from 'react-redux';
const Container = styled.View`
padding: 0 20px;
position: relative;
`;
const FullSizeContainer = styled(Container)`
flex: 1;
`;
const Loading = styled.View`
position: absolute;
right: 32px;
@@ -80,17 +85,19 @@ export default function Search() {
// Prepare state
const [fuseIsReady, setFuseReady] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const albums = useTypedSelector(state => state.music.albums.entities);
const [isLoading, setLoading] = useState(false);
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
const [isLoading, setLoading] = useState(false);
const albums = useTypedSelector(state => state.music.albums.entities);
const fuse = useRef<Fuse<Album>>();
const searchElement = useRef<TextInput>(null);
// Prepare helpers
const navigation = useNavigation<NavigationProp>();
const navigation = useNavigation<MusicNavigationProp>();
const getImage = useGetImage();
const dispatch = useAppDispatch();
const dispatch = useDispatch();
/**
* Since it is impractical to have a global fuse variable, we need to
@@ -112,6 +119,7 @@ export default function Search() {
// eslint-disable-next-line react-hooks/exhaustive-deps
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string, currentResults: CombinedResults) => {
// First, query the Jellyfin API
// @ts-expect-error need to fix this with AppDispatch
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm }));
// Convert the current results to album ids
@@ -185,6 +193,13 @@ export default function Search() {
retrieveResults();
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
// Automatically focus on the text input on mount
useEffect(() => {
// Give the timeout a slight delay so the component has a chance to actually
// render the text input field.
setTimeout(() => searchElement.current?.focus(), 10);
}, []);
// Handlers
const selectAlbum = useCallback((id: string) =>
navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]
@@ -192,18 +207,25 @@ export default function Search() {
const HeaderComponent = React.useMemo(() => (
<Container>
<Input value={searchTerm} onChangeText={setSearchTerm} style={defaultStyles.input} placeholder={t('search') + '...'} />
<Input
// @ts-expect-error styled-components has outdated react-native typings
ref={searchElement}
value={searchTerm}
onChangeText={setSearchTerm}
style={defaultStyles.input}
placeholder={t('search') + '...'}
/>
{isLoading && <Loading><ActivityIndicator /></Loading>}
</Container>
), [searchTerm, setSearchTerm, defaultStyles, isLoading]);
const FooterComponent = React.useMemo(() => (
<Container>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading)
? <Text style={{ textAlign: 'center' }}>{t('no-results')}</Text>
: null}
</Container>
), [searchTerm, jellyfinResults, fuseResults, isLoading]);
// const FooterComponent = React.useMemo(() => (
// <FullSizeContainer>
// {(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading)
// ? <Text style={{ textAlign: 'center', opacity: 0.5 }}>{t('no-results')}</Text>
// : null}
// </FullSizeContainer>
// ), [searchTerm, jellyfinResults, fuseResults, isLoading]);
// GUARD: We cannot search for stuff unless Fuse is loaded with results.
// Therefore we delay rendering to when we are certain it's there.
@@ -226,7 +248,7 @@ export default function Search() {
}
return (
<TouchableHandler id={album.Id} onPress={selectAlbum}>
<TouchableHandler<string> id={album.Id} onPress={selectAlbum}>
<SearchResult style={defaultStyles.border}>
<AlbumImage source={{ uri: getImage(album.Id) }} />
<View>
@@ -243,9 +265,14 @@ export default function Search() {
}}
keyExtractor={(item) => item.id}
ListHeaderComponent={HeaderComponent}
ListFooterComponent={FooterComponent}
// ListFooterComponent={FooterComponent}
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}
</>
);
}

View File

@@ -0,0 +1,191 @@
import React, { useCallback } from 'react';
import { Text, ScrollView, Dimensions, RefreshControl, StyleSheet, View } from 'react-native';
import { useGetImage } from 'utility/JellyfinApi';
import styled, { css } from 'styled-components/native';
import { useNavigation } from '@react-navigation/native';
import FastImage from 'react-native-fast-image';
import { useTypedSelector } from 'store';
import { THEME_COLOR } from 'CONSTANTS';
import TouchableHandler from 'components/TouchableHandler';
import useCurrentTrack from 'utility/useCurrentTrack';
import TrackPlayer from 'react-native-track-player';
import Play from 'assets/play.svg';
import Shuffle from 'assets/shuffle.svg';
import useDefaultStyles from 'components/Colors';
import usePlayTracks from 'utility/usePlayTracks';
import { EntityId } from '@reduxjs/toolkit';
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
import { MusicNavigationProp } from 'screens/Music/types';
import DownloadIcon from 'components/DownloadIcon';
import CloudDownArrow from 'assets/cloud-down-arrow.svg';
import Trash from 'assets/trash.svg';
import { useDispatch } from 'react-redux';
import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
import { selectDownloadedTracks } from 'store/downloads/selectors';
const Screen = Dimensions.get('screen');
const styles = StyleSheet.create({
name: {
fontSize: 36,
fontWeight: 'bold'
},
artist: {
fontSize: 24,
opacity: 0.5,
marginBottom: 12
},
index: {
width: 20,
opacity: 0.5,
marginRight: 5
}
});
const AlbumImage = styled(FastImage)`
border-radius: 10px;
width: ${Screen.width * 0.6}px;
height: ${Screen.width * 0.6}px;
margin: 10px auto;
`;
const TrackContainer = styled.View<{isPlaying: boolean}>`
padding: 15px 4px;
border-bottom-width: 1px;
flex-direction: row;
${props => props.isPlaying && css`
background-color: ${THEME_COLOR}16;
margin: 0 -20px;
padding: 15px 24px;
`}
`;
interface TrackListViewProps {
title?: string;
artist?: string;
trackIds: EntityId[];
entityId: string;
refresh: () => void;
playButtonText: string;
shuffleButtonText: string;
downloadButtonText: string;
deleteButtonText: string;
listNumberingStyle?: 'album' | 'index';
}
const TrackListView: React.FC<TrackListViewProps> = ({
trackIds,
entityId,
title,
artist,
refresh,
playButtonText,
shuffleButtonText,
downloadButtonText,
deleteButtonText,
listNumberingStyle = 'album',
}) => {
const defaultStyles = useDefaultStyles();
// Retrieve state
const tracks = useTypedSelector((state) => state.music.tracks.entities);
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
// Retrieve helpers
const getImage = useGetImage();
const playTracks = usePlayTracks();
const { track: currentTrack } = useCurrentTrack();
const navigation = useNavigation<MusicNavigationProp>();
const dispatch = useDispatch();
// Setup callbacks
const playEntity = useCallback(() => { playTracks(trackIds); }, [playTracks, trackIds]);
const shuffleEntity = useCallback(() => { playTracks(trackIds, { shuffle: true }); }, [playTracks, trackIds]);
const selectTrack = useCallback(async (index: number) => {
await playTracks(trackIds, { play: false });
await TrackPlayer.skip(index);
await TrackPlayer.play();
}, [playTracks, trackIds]);
const longPressTrack = useCallback((index: number) => {
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
}, [navigation, trackIds]);
const downloadAllTracks = useCallback(() => {
trackIds.forEach((trackId) => dispatch(downloadTrack(trackId)));
}, [dispatch, trackIds]);
const deleteAllTracks = useCallback(() => {
downloadedTracks.forEach((trackId) => dispatch(removeDownloadedTrack(trackId)));
}, [dispatch, downloadedTracks]);
return (
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 50 }}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
}
>
<AlbumImage source={{ uri: getImage(entityId) }} style={defaultStyles.imageBackground} />
<Text style={[ defaultStyles.text, styles.name ]} >{title}</Text>
<Text style={[ defaultStyles.text, styles.artist ]}>{artist}</Text>
<WrappableButtonRow>
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} />
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} />
</WrappableButtonRow>
<View style={{ marginTop: 8 }}>
{trackIds.map((trackId, i) =>
<TouchableHandler
key={trackId}
id={i}
onPress={selectTrack}
onLongPress={longPressTrack}
>
<TrackContainer isPlaying={currentTrack?.backendId === trackId || false} style={defaultStyles.border}>
<Text
style={[
defaultStyles.text,
styles.index,
currentTrack?.backendId === trackId && {
color: THEME_COLOR,
opacity: 1
}
]}
>
{listNumberingStyle === 'index'
? i + 1
: tracks[trackId]?.IndexNumber}
</Text>
<Text
style={currentTrack?.backendId === trackId
? { color: THEME_COLOR, fontWeight: '700' }
: defaultStyles.text
}
>
{tracks[trackId]?.Name}
</Text>
<View style={{ marginLeft: 'auto' }}>
<DownloadIcon trackId={trackId} />
</View>
</TrackContainer>
</TouchableHandler>
)}
<WrappableButtonRow style={{ marginTop: 24 }}>
<WrappableButton
icon={CloudDownArrow}
title={downloadButtonText}
onPress={downloadAllTracks}
disabled={downloadedTracks.length === trackIds.length}
/>
<WrappableButton
icon={Trash}
title={deleteButtonText}
onPress={deleteAllTracks}
disabled={downloadedTracks.length === 0}
/>
</WrappableButtonRow>
</View>
</ScrollView>
);
};
export default TrackListView;

View File

@@ -1,11 +1,14 @@
import { StackNavigationProp } from '@react-navigation/stack';
import { Album } from 'store/music/types';
export type StackParams = {
export type MusicStackParams = {
[key: string]: Record<string, unknown> | undefined;
Albums: undefined;
Album: { id: string, album: Album };
Playlists: undefined;
Playlist: { id: string };
RecentAlbums: undefined;
Search: undefined;
};
export type NavigationProp = StackNavigationProp<StackParams>;
export type MusicNavigationProp = StackNavigationProp<MusicStackParams>;

View File

@@ -1,13 +1,13 @@
import React, { useCallback, useEffect } from 'react';
import styled from 'styled-components/native';
import { THEME_COLOR } from 'CONSTANTS';
import { Button } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from 'screens';
import { useTypedSelector } from 'store';
import { useDispatch } from 'react-redux';
import { setOnboardingStatus } from 'store/settings/actions';
import { t } from '@localisation';
import Button from 'components/Button';
const Container = styled.SafeAreaView`
background-color: ${THEME_COLOR};
@@ -67,7 +67,9 @@ function Onboarding() {
{t('onboarding-cta')}
</Text>
<ButtonContainer>
<Button title={t('set-jellyfin-server')} color="#ffffff" onPress={handleClick} />
<Button
title={t('set-jellyfin-server')}
onPress={handleClick}/>
</ButtonContainer>
</TextContainer>
</Container>

View File

@@ -1,8 +1,8 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import TrackPlayer, { usePlaybackState, STATE_PLAYING, STATE_PAUSED } from 'react-native-track-player';
import React, { useState, useCallback } from 'react';
import TrackPlayer, { Event, State, usePlaybackState, useTrackPlayerEvents } from 'react-native-track-player';
import { TouchableOpacity, useColorScheme } from 'react-native';
import styled from 'styled-components/native';
import { useHasQueue } from 'utility/useQueue';
import { useHasNextQueue, useHasPreviousQueue } from 'utility/useQueue';
import ForwardIcon from 'assets/forwards.svg';
import BackwardIcon from 'assets/backwards.svg';
import PlayIcon from 'assets/play.svg';
@@ -15,10 +15,10 @@ import Casting from './Casting';
const BUTTON_SIZE = 40;
const BUTTON_SIZE_SMALL = 25;
const pause = () => TrackPlayer.pause();
const play = () => TrackPlayer.play();
const next = () => TrackPlayer.skipToNext();
const previous = () => TrackPlayer.skipToPrevious();
const pause = TrackPlayer.pause;
const play = TrackPlayer.play;
const next = TrackPlayer.skipToNext;
const previous = TrackPlayer.skipToPrevious;
const Container = styled.View`
align-items: center;
@@ -62,7 +62,7 @@ export default function MediaControls() {
}
export function PreviousButton({ fill }: { fill: string }) {
const hasQueue = useHasQueue();
const hasQueue = useHasPreviousQueue();
return (
<TouchableOpacity onPress={previous} disabled={!hasQueue} style={{ opacity: hasQueue ? 1 : 0.5 }}>
@@ -72,7 +72,7 @@ export function PreviousButton({ fill }: { fill: string }) {
}
export function NextButton({ fill }: { fill: string }) {
const hasQueue = useHasQueue();
const hasQueue = useHasNextQueue();
return (
<TouchableOpacity onPress={next} disabled={!hasQueue} style={{ opacity: hasQueue ? 1 : 0.5 }}>
@@ -84,16 +84,12 @@ export function NextButton({ fill }: { fill: string }) {
export function RepeatButton({ fill }: { fill: string}) {
const [isRepeating, setRepeating] = useState(false);
const handlePress = useCallback(() => setRepeating(!isRepeating), [isRepeating, setRepeating]);
const listener = useRef<TrackPlayer.EmitterSubscription | null>(null);
// The callback that should determine whether we need to repeeat or not
const handleEndEvent = useCallback(async () => {
useTrackPlayerEvents([Event.PlaybackQueueEnded], async () => {
if (isRepeating) {
// Retrieve all current tracks
const tracks = await TrackPlayer.getQueue();
// Then skip to the first track
await TrackPlayer.skip(tracks[0].id);
// Skip to the first track
await TrackPlayer.skip(0);
// Cautiously reset the seek time, as there might only be a single
// item in queue.
@@ -102,19 +98,7 @@ export function RepeatButton({ fill }: { fill: string}) {
// Then play the item
await TrackPlayer.play();
}
}, [isRepeating]);
// Subscribe to ended event handler so that we can restart the queue from
// the start if looping is enabled
useEffect(() => {
// Set the event listener
listener.current = TrackPlayer.addEventListener('playback-queue-ended', handleEndEvent);
// Then clean up after
return function cleanup() {
listener?.current?.remove();
};
}, [handleEndEvent]);
});
return (
<TouchableOpacity onPress={handlePress} style={{ opacity: isRepeating ? 1 : 0.5 }}>
@@ -146,13 +130,13 @@ export function MainButton({ fill }: { fill: string }) {
const state = usePlaybackState();
switch (state) {
case STATE_PLAYING:
case State.Playing:
return (
<TouchableOpacity onPress={pause}>
<PauseIcon width={BUTTON_SIZE} height={BUTTON_SIZE} fill={fill} />
</TouchableOpacity>
);
case STATE_PAUSED:
case State.Paused:
return (
<TouchableOpacity onPress={play}>
<PlayIcon width={BUTTON_SIZE} height={BUTTON_SIZE} fill={fill} />

View File

@@ -32,7 +32,7 @@ const styles = StyleSheet.create({
export default function NowPlaying() {
const track = useCurrentTrack();
const { track } = useCurrentTrack();
const defaultStyles = useDefaultStyles();
return (
@@ -40,7 +40,7 @@ export default function NowPlaying() {
<Artwork
style={defaultStyles.imageBackground}
source={{
uri: track?.artwork,
uri: track?.artwork as string | undefined,
priority: FastImage.priority.high,
}}
/>

View File

@@ -31,16 +31,18 @@ export default class ProgressBar extends Component<{}, State> {
state: State = {
position: 0,
duration: 0,
}
};
timer: number = 0;
timer: number | null = null;
componentDidMount() {
this.timer = setInterval(this.updateProgress, 500);
}
componentWillUnmount() {
clearInterval(this.timer);
if (this.timer) {
clearInterval(this.timer);
}
}
updateProgress = async () => {
@@ -50,18 +52,18 @@ export default class ProgressBar extends Component<{}, State> {
]);
this.setState({ position, duration });
}
};
handleGesture = async (gesture: number) => {
// Set relative translation in state
this.setState({ gesture });
}
};
handleEndOfGesture = (position: number) => {
// Calculate and set the new position
TrackPlayer.seekTo(position);
this.setState({ gesture: undefined, position });
}
};
render() {
const { position, duration, gesture } = this.state;

View File

@@ -9,10 +9,15 @@ import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import Text from 'components/Text';
import Button from 'components/Button';
import { THEME_COLOR } from 'CONSTANTS';
import DownloadIcon from 'components/DownloadIcon';
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>`
padding: 10px;
border-bottom-width: 1px;
flex: 0 0 auto;
flex-direction: row;
align-items: center;
${props => props.active && css`
font-weight: 900;
@@ -38,10 +43,9 @@ const styles = StyleSheet.create({
export default function Queue() {
const defaultStyles = useDefaultStyles();
const queue = useQueue();
const currentTrack = useCurrentTrack();
const currentIndex = queue.findIndex(d => d.id === currentTrack?.id);
const playTrack = useCallback(async (trackId: string) => {
await TrackPlayer.skip(trackId);
const { index: currentIndex } = useCurrentTrack();
const playTrack = useCallback(async (index: number) => {
await TrackPlayer.skip(index);
await TrackPlayer.play();
}, []);
const clearQueue = useCallback(async () => {
@@ -52,18 +56,23 @@ export default function Queue() {
<View>
<Text style={{ marginTop: 20, marginBottom: 20 }}>{t('queue')}</Text>
{queue.map((track, i) => (
<TouchableHandler id={track.id} onPress={playTrack} key={i}>
<TouchableHandler id={i} onPress={playTrack} key={i}>
<QueueItem
active={currentTrack?.id === track.id}
active={currentIndex === i}
key={i}
alreadyPlayed={i < currentIndex}
alreadyPlayed={currentIndex ? i < currentIndex : false}
style={[
defaultStyles.border,
currentTrack?.id === track.id ? defaultStyles.activeBackground : {},
currentIndex === i ? defaultStyles.activeBackground : {},
]}
>
<Text style={styles.trackTitle}>{track.title}</Text>
<Text style={defaultStyles.textHalfOpacity}>{track.artist}</Text>
<View>
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '700' } : styles.trackTitle}>{track.title}</Text>
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : defaultStyles.textHalfOpacity}>{track.artist}</Text>
</View>
<View style={{ marginLeft: 'auto' }}>
<DownloadIcon trackId={track.backendId} />
</View>
</QueueItem>
</TouchableHandler>
))}

View File

@@ -9,9 +9,10 @@ import { useNavigation } from '@react-navigation/native';
import ListButton from 'components/ListButton';
import { THEME_COLOR } from 'CONSTANTS';
import Sentry from './components/Sentry';
import { SettingsNavigationProp } from './types';
export function SettingsList() {
const navigation = useNavigation();
const navigation = useNavigation<SettingsNavigationProp>();
const handleLibraryClick = useCallback(() => { navigation.navigate('Library'); }, [navigation]);
const handleCacheClick = useCallback(() => { navigation.navigate('Cache'); }, [navigation]);
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);

View File

@@ -0,0 +1,11 @@
import { StackNavigationProp } from '@react-navigation/stack';
export type SettingsStackParams = {
[key: string]: Record<string, unknown> | undefined;
SettingList: undefined;
Library: undefined;
Cache: undefined;
Sentry: undefined;
};
export type SettingsNavigationProp = StackNavigationProp<SettingsStackParams>;

View File

@@ -2,17 +2,21 @@ import React from 'react';
import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack';
import { CompositeNavigationProp } from '@react-navigation/native';
import SetJellyfinServer from './modals/SetJellyfinServer';
import { THEME_COLOR } from 'CONSTANTS';
import Player from './Player';
import Music from './Music';
import Settings from './Settings';
import Downloads from './Downloads';
import Onboarding from './Onboarding';
import TrackPopupMenu from './modals/TrackPopupMenu';
import SetJellyfinServer from './modals/SetJellyfinServer';
import PlayPauseIcon from 'assets/play-pause-fill.svg';
import NotesIcon from 'assets/notes.svg';
import GearIcon from 'assets/gear.svg';
import { THEME_COLOR } from 'CONSTANTS';
import DownloadsIcon from 'assets/arrow-down-to-line.svg';
import { useTypedSelector } from 'store';
import Onboarding from './Onboarding';
import TrackPopupMenu from './modals/TrackPopupMenu';
import { ModalStackParams } from './types';
import { t } from '@localisation';
import ErrorReportingAlert from 'utility/ErrorReportingAlert';
@@ -35,6 +39,8 @@ function getIcon(route: string): React.FC<any> | null {
return NotesIcon;
case 'Settings':
return GearIcon;
case 'Downloads':
return DownloadsIcon;
default:
return null;
}
@@ -62,15 +68,15 @@ function Screens() {
}
return <Icon fill={color} width={size} height={size} />;
}
},
tabBarActiveTintColor: THEME_COLOR,
tabBarInactiveTintColor: 'gray',
headerShown: false,
})}
tabBarOptions={{
activeTintColor: THEME_COLOR,
inactiveTintColor: 'gray',
}}
>
<Tab.Screen name="NowPlaying" component={Player} options={{ tabBarLabel: t('now-playing') }} />
<Tab.Screen name="Music" component={Music} options={{ tabBarLabel: t('music') }} />
<Tab.Screen name="Downloads" component={Downloads} options={{ tabBarLabel: t('downloads')}} />
<Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings') }} />
</Tab.Navigator>
</>
@@ -85,10 +91,12 @@ type Routes = {
export default function Routes() {
return (
<Stack.Navigator mode="modal" headerMode="none" screenOptions={{
<Stack.Navigator screenOptions={{
cardStyle: {
backgroundColor: 'transparent'
}
},
presentation: 'modal',
headerShown: false,
}}>
<Stack.Screen name="Screens" component={Screens} />
<Stack.Screen name="SetJellyfinServer" component={SetJellyfinServer} />

View File

@@ -14,7 +14,7 @@ class CredentialGenerator extends Component<Props> {
handleStateChange = () => {
// Call a debounced version to check if the credentials are there
this.checkIfCredentialsAreThere();
}
};
checkIfCredentialsAreThere = debounce(() => {
// Inject some javascript to check if the credentials can be extracted
@@ -52,7 +52,7 @@ class CredentialGenerator extends Component<Props> {
access_token: credentials.AccessToken,
device_id: deviceId,
});
}
};
render() {
const { serverUrl } = this.props;

View File

@@ -5,60 +5,82 @@ import { ModalStackParams } from 'screens/types';
import { useTypedSelector } from 'store';
import { SubHeader } from 'components/Typography';
import styled from 'styled-components/native';
import usePlayTrack from 'utility/usePlayTrack';
import { t } from '@localisation';
import Button from 'components/Button';
import PlayIcon from 'assets/play.svg';
import DownloadIcon from 'assets/cloud-down-arrow.svg';
import QueueAppendIcon from 'assets/queue-append.svg';
import TrashIcon from 'assets/trash.svg';
import Text from 'components/Text';
import { WrappableButton, WrappableButtonRow } from 'components/WrappableButtonRow';
import { useDispatch } from 'react-redux';
import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
import usePlayTracks from 'utility/usePlayTracks';
import { selectIsDownloaded } from 'store/downloads/selectors';
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
const Container = styled.View`
padding: 20px;
`;
const Buttons = styled.View`
margin-top: 20px;
flex-direction: row;
/* justify-content: space-around; */
`;
const ButtonSpacing = styled.View`
width: 8px;
flex: 0 0 auto;
flex-direction: column;
`;
function TrackPopupMenu() {
// Retrieve helpers
// Retrieve trackId from route
const { params: { trackId } } = useRoute<Route>();
// Retrieve helpers
const navigation = useNavigation();
const dispatch = useDispatch();
const playTracks = usePlayTracks();
// Retrieve data from store
const track = useTypedSelector((state) => state.music.tracks.entities[trackId]);
const playTrack = usePlayTrack();
const isDownloaded = useTypedSelector(selectIsDownloaded(trackId));
// Set callback to close the modal
const closeModal = useCallback(() => {
navigation.dispatch(StackActions.popToTop());
}, [navigation]);
// Callback for adding the track to the queue as the next song
const handlePlayNext = useCallback(() => {
playTrack(trackId, false, false);
playTracks([trackId], { method: 'add-after-currently-playing', play: false });
closeModal();
}, [playTrack, closeModal, trackId]);
}, [playTracks, closeModal, trackId]);
// Callback for adding the track to the end of the queue
const handleAddToQueue = useCallback(() => {
playTrack(trackId, false, true);
playTracks([trackId], { method: 'add-to-end', play: false });
closeModal();
}, [playTrack, closeModal, trackId]);
}, [playTracks, closeModal, trackId]);
// Callback for downloading the track
const handleDownload = useCallback(() => {
dispatch(downloadTrack(trackId));
closeModal();
}, [trackId, dispatch, closeModal]);
// Callback for removing the downloaded track
const handleDelete = useCallback(() => {
dispatch(removeDownloadedTrack(trackId));
closeModal();
}, [trackId, dispatch, closeModal]);
return (
<Modal fullSize={false}>
<Container>
<SubHeader>{track?.Name}</SubHeader>
<Text>{track?.Album} - {track?.AlbumArtist}</Text>
<Buttons>
<Button title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} />
<ButtonSpacing />
<Button title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} />
</Buttons>
<SubHeader style={{ textAlign: 'center' }}>{track?.Name}</SubHeader>
<Text style={{ marginBottom: 18, textAlign: 'center' }}>{track?.Album} - {track?.AlbumArtist}</Text>
<WrappableButtonRow>
<WrappableButton title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} />
<WrappableButton title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} />
{isDownloaded ? (
<WrappableButton title={t('delete-track')} icon={TrashIcon} onPress={handleDelete} />
) : (
<WrappableButton title={t('download-track')} icon={DownloadIcon} onPress={handleDownload} />
)}
</WrappableButtonRow>
</Container>
</Modal>
);

View File

@@ -1,4 +1,9 @@
import { StackNavigationProp } from '@react-navigation/stack';
export interface ModalStackParams {
[key: string]: Record<string, unknown> | undefined;
SetJellyfinServer: undefined;
TrackPopupMenu: { trackId: string };
}
}
export type ModalNavigationProp = StackNavigationProp<ModalStackParams>;

View File

@@ -0,0 +1,54 @@
import { createAction, createAsyncThunk, createEntityAdapter, EntityId } from '@reduxjs/toolkit';
import { AppState } from 'store';
import { generateTrackUrl } from 'utility/JellyfinApi';
import { downloadFile, unlink, DocumentDirectoryPath } from 'react-native-fs';
import { DownloadEntity } from './types';
export const downloadAdapter = createEntityAdapter<DownloadEntity>({
selectId: (entity) => entity.id,
});
export const initializeDownload = createAction<{ id: EntityId, size?: number, jobId?: number }>('download/initialize');
export const progressDownload = createAction<{ id: EntityId, progress: number, jobId?: number }>('download/progress');
export const completeDownload = createAction<{ id: EntityId, location: string, size?: number }>('download/complete');
export const failDownload = createAction<{ id: EntityId }>('download/fail');
export const downloadTrack = createAsyncThunk(
'/downloads/track',
async (id: EntityId, { dispatch, getState }) => {
// Get the credentials from the store
const { settings: { jellyfin: credentials } } = (getState() as AppState);
// Generate the URL we can use to download the file
const url = generateTrackUrl(id as string, credentials);
const location = `${DocumentDirectoryPath}/${id}.mp3`;
// Actually kick off the download
const { promise } = await downloadFile({
fromUrl: url,
progressInterval: 250,
background: true,
begin: ({ jobId, contentLength }) => {
// Dispatch the initialization
dispatch(initializeDownload({ id, jobId, size: contentLength }));
},
progress: (result) => {
// Dispatch a progress update
dispatch(progressDownload({ id, progress: result.bytesWritten / result.contentLength }));
},
toFile: location,
});
// Await job completion
const result = await promise;
dispatch(completeDownload({ id, location, size: result.bytesWritten }));
},
);
export const removeDownloadedTrack = createAsyncThunk(
'/downloads/track/remove',
async(id: EntityId) => {
return unlink(`${DocumentDirectoryPath}/${id}.mp3`);
}
);

View File

@@ -0,0 +1,60 @@
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
import { completeDownload, downloadAdapter, failDownload, initializeDownload, progressDownload, removeDownloadedTrack } from './actions';
import { DownloadEntity } from './types';
interface State {
entities: Dictionary<DownloadEntity>;
ids: EntityId[];
}
const initialState: State = {
entities: {},
ids: [],
};
const downloads = createSlice({
name: 'downloads',
initialState,
reducers: {},
extraReducers: builder => {
builder.addCase(initializeDownload, (state, action) => {
downloadAdapter.upsertOne(state, {
...action.payload,
progress: 0,
isFailed: false,
isComplete: false,
});
});
builder.addCase(progressDownload, (state, action) => {
downloadAdapter.updateOne(state, {
id: action.payload.id,
changes: action.payload
});
});
builder.addCase(completeDownload, (state, action) => {
downloadAdapter.updateOne(state, {
id: action.payload.id,
changes: {
...action.payload,
isFailed: false,
isComplete: true,
}
});
});
builder.addCase(failDownload, (state, action) => {
downloadAdapter.updateOne(state, {
id: action.payload.id,
changes: {
isComplete: false,
isFailed: true,
progress: 0,
}
});
});
builder.addCase(removeDownloadedTrack.fulfilled, (state, action) => {
downloadAdapter.removeOne(state, action.meta.arg);
});
},
});
export default downloads;

View File

@@ -0,0 +1,29 @@
import { createSelector, EntityId } from '@reduxjs/toolkit';
import { intersection } from 'lodash';
import { AppState } from 'store';
export const selectAllDownloads = (state: AppState) => state.downloads;
export const selectDownloadedEntities = (state: AppState) => state.downloads.entities;
/**
* Only retain the supplied trackIds that have successfully been downloaded
*/
export const selectDownloadedTracks = (trackIds: EntityId[]) => (
createSelector(
selectAllDownloads,
({ entities, ids }) => {
return intersection(trackIds, ids)
.filter((id) => entities[id]?.isComplete);
}
)
);
/**
* Select a boolean that indicates whether the track is downloaded
*/
export const selectIsDownloaded = (trackId: string) => (
createSelector(
selectDownloadedEntities,
(entities) => entities[trackId]?.isComplete,
)
);

View File

@@ -0,0 +1,11 @@
import { EntityId } from '@reduxjs/toolkit';
export interface DownloadEntity {
id: EntityId;
progress: number;
isFailed: boolean;
isComplete: boolean;
size?: number;
location?: string;
jobId?: number;
}

View File

@@ -3,7 +3,6 @@ import { useSelector, TypedUseSelectorHook, useDispatch } from 'react-redux';
import AsyncStorage from '@react-native-community/async-storage';
import { persistStore, persistReducer, PersistConfig } from 'redux-persist';
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';
// import logger from 'redux-logger';
const persistConfig: PersistConfig<AppState> = {
key: 'root',
@@ -13,12 +12,12 @@ const persistConfig: PersistConfig<AppState> = {
import settings from './settings';
import music from './music';
import player from './player';
import downloads from './downloads';
const reducers = combineReducers({
settings,
player: player.reducer,
music: music.reducer,
downloads: downloads.reducer,
});
const persistedReducer = persistReducer(persistConfig, reducers);
@@ -26,7 +25,8 @@ const persistedReducer = persistReducer(persistConfig, reducers);
const store = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware({ serializableCheck: false, immutableCheck: false }).concat(
// logger
// logger,
__DEV__ ? require('redux-flipper').default() : undefined,
),
});

View File

@@ -1,7 +1,7 @@
import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { Album, AlbumTrack } from './types';
import { Album, AlbumTrack, Playlist } from './types';
import { AsyncThunkAPI } from '..';
import { retrieveAllAlbums, retrieveAlbumTracks, retrieveRecentAlbums, searchItem, retrieveAlbum } from 'utility/JellyfinApi';
import { retrieveAllAlbums, retrieveAlbumTracks, retrieveRecentAlbums, searchItem, retrieveAlbum, retrieveAllPlaylists, retrievePlaylistTracks } from 'utility/JellyfinApi';
export const albumAdapter = createEntityAdapter<Album>({
selectId: album => album.Id,
@@ -76,4 +76,31 @@ AsyncThunkAPI
results
};
}
);
export const playlistAdapter = createEntityAdapter<Playlist>({
selectId: (playlist) => playlist.Id,
sortComparer: (a, b) => a.Name.localeCompare(b.Name),
});
/**
* Fetch all playlists available
*/
export const fetchAllPlaylists = createAsyncThunk<Playlist[], undefined, AsyncThunkAPI>(
'/playlists/all',
async (empty, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAllPlaylists(credentials) as Promise<Playlist[]>;
}
);
/**
* Retrieve all tracks from a particular playlist
*/
export const fetchTracksByPlaylist = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
'/tracks/byPlaylist',
async (ItemId, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrievePlaylistTracks(ItemId, credentials) as Promise<AlbumTrack[]>;
}
);

View File

@@ -1,6 +1,16 @@
import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums, searchAndFetchAlbums } from './actions';
import {
fetchAllAlbums,
albumAdapter,
fetchTracksByAlbum,
trackAdapter,
fetchRecentAlbums,
searchAndFetchAlbums,
playlistAdapter,
fetchAllPlaylists,
fetchTracksByPlaylist
} from './actions';
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
import { Album, AlbumTrack } from './types';
import { Album, AlbumTrack, Playlist } from './types';
import { setJellyfinCredentials } from 'store/settings/actions';
export interface State {
@@ -8,13 +18,21 @@ export interface State {
isLoading: boolean;
entities: Dictionary<Album>;
ids: EntityId[];
lastRefreshed?: number,
},
tracks: {
isLoading: boolean;
entities: Dictionary<AlbumTrack>;
ids: EntityId[];
byAlbum: Dictionary<EntityId[]>;
byPlaylist: Dictionary<EntityId[]>;
},
lastRefreshed?: number,
playlists: {
isLoading: boolean;
entities: Dictionary<Playlist>;
ids: EntityId[];
lastRefreshed?: number,
}
}
const initialState: State = {
@@ -25,7 +43,13 @@ const initialState: State = {
tracks: {
...trackAdapter.getInitialState(),
isLoading: false,
byAlbum: {},
byPlaylist: {},
},
playlists: {
...playlistAdapter.getInitialState(),
isLoading: false,
}
};
const music = createSlice({
@@ -41,7 +65,7 @@ const music = createSlice({
builder.addCase(fetchAllAlbums.fulfilled, (state, { payload }) => {
albumAdapter.setAll(state.albums, payload);
state.albums.isLoading = false;
state.lastRefreshed = new Date().getTime();
state.albums.lastRefreshed = new Date().getTime();
});
builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; });
@@ -59,7 +83,7 @@ const music = createSlice({
/**
* Fetch tracks by album
*/
builder.addCase(fetchTracksByAlbum.fulfilled, (state, { payload }) => {
builder.addCase(fetchTracksByAlbum.fulfilled, (state, { payload, meta }) => {
if (!payload.length) {
return;
}
@@ -67,9 +91,9 @@ const music = createSlice({
trackAdapter.upsertMany(state.tracks, payload);
// Also store all the track ids in the album
const album = state.albums.entities[payload[0].AlbumId];
state.tracks.byAlbum[meta.arg] = payload.map(d => d.Id);
const album = state.albums.entities[meta.arg];
if (album) {
album.Tracks = payload.map(d => d.Id);
album.lastRefreshed = new Date().getTime();
}
state.tracks.isLoading = false;
@@ -82,6 +106,40 @@ const music = createSlice({
albumAdapter.upsertMany(state.albums, payload.albums);
state.albums.isLoading = false;
});
/**
* Fetch all playlists
*/
builder.addCase(fetchAllPlaylists.fulfilled, (state, { payload }) => {
playlistAdapter.setAll(state.playlists, payload);
state.playlists.isLoading = false;
state.playlists.lastRefreshed = new Date().getTime();
});
builder.addCase(fetchAllPlaylists.pending, (state) => { state.playlists.isLoading = true; });
builder.addCase(fetchAllPlaylists.rejected, (state) => { state.playlists.isLoading = false; });
/**
* Fetch tracks by playlist
*/
builder.addCase(fetchTracksByPlaylist.fulfilled, (state, { payload, meta }) => {
if (!payload.length) {
return;
}
// Upsert the retrieved tracks
trackAdapter.upsertMany(state.tracks, payload);
// Also store all the track ids in the playlist
state.tracks.byPlaylist[meta.arg] = payload.map(d => d.Id);
state.tracks.isLoading = false;
const playlist = state.playlists.entities[meta.arg];
if (playlist) {
playlist.lastRefreshed = new Date().getTime();
}
});
builder.addCase(fetchTracksByPlaylist.pending, (state) => { state.tracks.isLoading = true; });
builder.addCase(fetchTracksByPlaylist.rejected, (state) => { state.tracks.isLoading = false; });
// Reset any caches we have when a new server is set
builder.addCase(setJellyfinCredentials, () => initialState);

View File

@@ -55,7 +55,7 @@ export type SectionedId = SectionListData<EntityId>;
/**
* Splits a set of albums into a list that is split by alphabet letters
*/
function splitByAlphabet(state: AppState['music']['albums']): SectionedId[] {
function splitAlbumsByAlphabet(state: AppState['music']['albums']): SectionedId[] {
const { entities: albums } = state;
const albumIds = albumsByArtist(state);
const sections: SectionedId[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] }));
@@ -75,5 +75,5 @@ function splitByAlphabet(state: AppState['music']['albums']): SectionedId[] {
*/
export const selectAlbumsByAlphabet = createSelector(
(state: AppState) => state.music.albums,
splitByAlphabet,
);
splitAlbumsByAlphabet,
);

View File

@@ -74,4 +74,24 @@ export interface State {
entities: Dictionary<Album>;
isLoading: boolean;
}
}
export interface Playlist {
Name: string;
ServerId: string;
Id: string;
CanDelete: boolean;
SortName: string;
ChannelId?: any;
RunTimeTicks: number;
IsFolder: boolean;
Type: string;
UserData: UserData;
PrimaryImageAspectRatio: number;
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
Tracks?: string[];
lastRefreshed?: number;
}

View File

@@ -1,27 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Track } from 'react-native-track-player';
interface State {
addedTrackCount: number,
currentTrack: Track | undefined,
}
const initialState: State = {
addedTrackCount: 0,
currentTrack: undefined,
};
const player = createSlice({
name: 'player',
initialState,
reducers: {
addNewTrackToPlayer: (state) => {
state.addedTrackCount += 1;
},
setCurrentTrack: (state, action: PayloadAction<Track | undefined>) => {
state.currentTrack = action.payload;
},
}
});
export default player;

3
src/typings/env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module '@env' {
export const SENTRY_DSN: string;
}

View File

@@ -2,8 +2,4 @@ declare module '*.svg' {
import { SvgProps } from 'react-native-svg';
const content: React.FC<SvgProps>;
export default content;
}
declare module '@env' {
export const SENTRY_DSN: string;
}

View File

@@ -0,0 +1,43 @@
import TrackPlayer from 'react-native-track-player';
import { useEffect } from 'react';
import EventEmitter from 'events';
const eventName = 'track-added';
const addedTrackEmitter = new EventEmitter();
/**
* Emit the event that a track has been added
*/
export function emitTrackAdded() {
addedTrackEmitter.emit(eventName);
}
/**
* Call the callback whenever a track has been added to the queue
*/
export function onTrackAdded(callback: () => void) {
addedTrackEmitter.addListener(eventName, callback);
}
/**
* A hook to manage the listeners for the added track function
*/
export function useOnTrackAdded(callback: () => void) {
useEffect(() => {
addedTrackEmitter.addListener(eventName, callback);
return () => {
addedTrackEmitter.removeListener(eventName, callback);
};
});
}
/**
* Monkey-patch the track-player to also emit track added events
*/
export function patchTrackPlayer() {
const oldAddFunction = TrackPlayer.add;
TrackPlayer.add = (...args: Parameters<typeof oldAddFunction>) => {
emitTrackAdded();
return oldAddFunction(...args);
};
}

View File

@@ -6,6 +6,7 @@ import { setReceivedErrorReportingAlert } from 'store/settings/actions';
import { setSentryStatus } from './Sentry';
import { useNavigation } from '@react-navigation/native';
import { useDispatch } from 'react-redux';
import { ModalNavigationProp } from 'screens/types';
/**
* This will send out an alert message asking the user if they want to enable
@@ -13,7 +14,7 @@ import { useDispatch } from 'react-redux';
*/
export default function ErrorReportingAlert() {
const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings);
const navigation = useNavigation();
const navigation = useNavigation<ModalNavigationProp>();
const dispatch = useDispatch();
useEffect(() => {
@@ -53,7 +54,7 @@ export default function ErrorReportingAlert() {
dispatch(setReceivedErrorReportingAlert());
}
}, []);
}, [dispatch, hasReceivedErrorReportingAlert, navigation]);
return null;
}

View File

@@ -20,8 +20,8 @@ const baseTrackOptions: Record<string, string> = {
MaxStreamingBitrate: '140000000',
MaxSampleRate: '48000',
// This must be set to support client seeking
TranscodingProtocol: 'hls',
TranscodingContainer: 'ts',
TranscodingProtocol: 'http',
TranscodingContainer: 'aac',
Container: 'mp3,aac,m4a,m4b|aac,alac,m4a,m4b|alac,flac|ogg',
AudioCodec: 'aac',
static: 'true',
@@ -34,18 +34,11 @@ const baseTrackOptions: Record<string, string> = {
*/
export function generateTrack(track: AlbumTrack, credentials: Credentials): Track {
// Also construct the URL for the stream
const trackOptions = {
...baseTrackOptions,
UserId: credentials?.user_id || '',
api_key: credentials?.access_token || '',
DeviceId: credentials?.device_id || '',
};
const trackParams = new URLSearchParams(trackOptions).toString();
const url = encodeURI(`${credentials?.uri}/Audio/${track.Id}/universal?${trackParams}`);
const url = generateTrackUrl(track.Id, credentials);
return {
id: track.Id,
url,
backendId: track.Id,
title: track.Name,
artist: track.Artists.join(', '),
album: track.Album,
@@ -55,6 +48,23 @@ export function generateTrack(track: AlbumTrack, credentials: Credentials): Trac
};
}
/**
* Generate the track streaming url from the trackId
*/
export function generateTrackUrl(trackId: string, credentials: Credentials) {
const trackOptions = {
...baseTrackOptions,
UserId: credentials?.user_id || '',
api_key: credentials?.access_token || '',
DeviceId: credentials?.device_id || '',
};
const trackParams = new URLSearchParams(trackOptions).toString();
const url = encodeURI(`${credentials?.uri}/Audio/${trackId}/universal?${trackParams}`);
return url;
}
const albumOptions = {
SortBy: 'AlbumArtist,SortName',
SortOrder: 'Ascending',
@@ -193,4 +203,43 @@ export async function searchItem(
return results.Items;
}
const playlistOptions = {
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Playlist',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
MediaTypes: 'Audio',
};
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrieveAllPlaylists(credentials: Credentials) {
const config = generateConfig(credentials);
const playlistParams = new URLSearchParams(playlistOptions).toString();
const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${playlistParams}`, config)
.then(response => response.json());
return albums.Items;
}
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrievePlaylistTracks(ItemId: string, credentials: Credentials) {
const singlePlaylistOptions = {
SortBy: 'SortName',
UserId: credentials?.user_id || '',
};
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
const config = generateConfig(credentials);
const playlists = await fetch(`${credentials?.uri}/Playlists/${ItemId}/Items?${singlePlaylistParams}`, config)
.then(response => response.json());
return playlists.Items;
}

View File

@@ -7,30 +7,30 @@
* such as processing media buttons or analytics
*/
import TrackPlayer from 'react-native-track-player';
import TrackPlayer, { Event } from 'react-native-track-player';
export default async function() {
TrackPlayer.addEventListener('remote-play', () => {
TrackPlayer.addEventListener(Event.RemotePlay, () => {
TrackPlayer.play();
});
TrackPlayer.addEventListener('remote-pause', () => {
TrackPlayer.addEventListener(Event.RemotePause, () => {
TrackPlayer.pause();
});
TrackPlayer.addEventListener('remote-next', () => {
TrackPlayer.addEventListener(Event.RemoteNext, () => {
TrackPlayer.skipToNext();
});
TrackPlayer.addEventListener('remote-previous', () => {
TrackPlayer.addEventListener(Event.RemotePrevious, () => {
TrackPlayer.skipToPrevious();
});
TrackPlayer.addEventListener('remote-stop', () => {
TrackPlayer.addEventListener(Event.RemoteStop, () => {
TrackPlayer.destroy();
});
TrackPlayer.addEventListener('remote-seek', (event) => {
TrackPlayer.addEventListener(Event.RemoteSeek, (event) => {
TrackPlayer.seekTo(event.position);
});

View File

@@ -0,0 +1,15 @@
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
/**
* Convert a number of bytes to a human-readable string
* CREDIT: https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c
*/
export default function formatBytes(bytes: number, decimals: number = 2) {
if (bytes === 0) {
return '0 Bytes';
}
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}

View File

@@ -1,15 +1,29 @@
import { Track } from 'react-native-track-player';
import { useTypedSelector } from 'store';
import { useCallback, useEffect, useState } from 'react';
import TrackPlayer, { Event, Track, useTrackPlayerEvents } from 'react-native-track-player';
const idEqual = (left: Track | undefined, right: Track | undefined) => {
return left?.id === right?.id;
};
interface CurrentTrackResponse {
track: Track | undefined;
index: number | undefined;
}
/**
* This hook retrieves the current playing track from TrackPlayer
*/
export default function useCurrentTrack(): Track | undefined {
const track = useTypedSelector(state => state.player.currentTrack, idEqual);
export default function useCurrentTrack(): CurrentTrackResponse {
const [track, setTrack] = useState<Track | undefined>();
const [index, setIndex] = useState<number | undefined>();
// Retrieve the current track from the queue using the index
const retrieveCurrentTrack = useCallback(async () => {
const queue = await TrackPlayer.getQueue();
const currentTrackIndex = await TrackPlayer.getCurrentTrack();
setTrack(queue[currentTrackIndex]);
setIndex(currentTrackIndex);
}, [setTrack, setIndex]);
// Then execute the function on component mount and track changes
useEffect(() => { retrieveCurrentTrack(); }, [retrieveCurrentTrack]);
useTrackPlayerEvents([ Event.PlaybackTrackChanged ], retrieveCurrentTrack);
return track;
return { track, index };
}

View File

@@ -1,76 +0,0 @@
import { useTypedSelector } from 'store';
import { useCallback } from 'react';
import TrackPlayer, { Track } from 'react-native-track-player';
import { generateTrack } from './JellyfinApi';
import useQueue from './useQueue';
import player from 'store/player';
import { useDispatch } from 'react-redux';
/**
* Generate a callback function that starts playing a full album given its
* supplied id.
*/
export default function usePlayAlbum() {
const dispatch = useDispatch();
const credentials = useTypedSelector(state => state.settings.jellyfin);
const albums = useTypedSelector(state => state.music.albums.entities);
const tracks = useTypedSelector(state => state.music.tracks.entities);
const queue = useQueue();
return useCallback(async function playAlbum(albumId: string, play = true): Promise<TrackPlayer.Track[] | undefined> {
const album = albums[albumId];
const trackIds = album?.Tracks;
// GUARD: Check that the album actually has tracks
if (!album || !trackIds?.length) {
return;
}
// Check if the queue already contains the consecutive track listing
// that is described as part of the album
const queuedAlbum = queue.reduce<TrackPlayer.Track[]>((sum, track) => {
if (track.id.startsWith(trackIds[sum.length])) {
sum.push(track);
} else {
sum = [];
}
return sum;
}, []);
// If the entire album is already in the queue, we can just return those
// tracks, rather than adding it to the queue again.
if (queuedAlbum.length === trackIds.length) {
if (play) {
await TrackPlayer.skip(trackIds[0]);
await TrackPlayer.play();
}
return queuedAlbum;
}
// Convert all trackIds to the relevant format for react-native-track-player
const newTracks = trackIds.map((trackId) => {
const track = tracks[trackId];
if (!trackId || !track) {
return;
}
return generateTrack(track, credentials);
}).filter((t): t is Track => typeof t !== 'undefined');
// Clear the queue and add all tracks
await TrackPlayer.removeUpcomingTracks();
await TrackPlayer.add(newTracks);
// Then, we'll dispatch the added track event
dispatch(player.actions.addNewTrackToPlayer());
if (play) {
await TrackPlayer.skip(trackIds[0]);
await TrackPlayer.play();
}
return newTracks;
}, [credentials, albums, tracks, queue, dispatch]);
}

View File

@@ -1,67 +0,0 @@
import { useCallback } from 'react';
import TrackPlayer from 'react-native-track-player';
import { useTypedSelector } from 'store';
import { generateTrack } from './JellyfinApi';
import useQueue from './useQueue';
import { useDispatch } from 'react-redux';
import player from 'store/player';
/**
* A hook that generates a callback that can setup and start playing a
* particular trackId in the player.
*/
export default function usePlayTrack() {
const dispatch = useDispatch();
const credentials = useTypedSelector(state => state.settings.jellyfin);
const tracks = useTypedSelector(state => state.music.tracks.entities);
const queue = useQueue();
return useCallback(async function playTrack(trackId: string, play = true, addToEnd = true) {
// Get the relevant track
const track = tracks[trackId];
// GUARD: Check if the track actually exists in the store
if (!track) {
return;
}
// GUARD: Check if the track is already in the queue
const trackInstances = queue.filter((t) => t.id.startsWith(trackId));
// Generate the new track for the queue
const newTrack = {
...(trackInstances.length ? trackInstances[0] : generateTrack(track, credentials)),
id: `${trackId}_${trackInstances.length}`
};
// Then, we'll need to check where to add the track
if (addToEnd) {
await TrackPlayer.add([ newTrack ]);
} else {
// Try and locate the current track
const currentTrackId = await TrackPlayer.getCurrentTrack();
const currentTrackIndex = queue.findIndex(track => track.id === currentTrackId);
// Since the argument is the id to insert the track BEFORE, we need
// to get the current track + 1
const targetTrack = currentTrackIndex >= 0 && queue.length > 1
? queue[currentTrackIndex + 1].id
: undefined;
// Depending on whether this track exists, we either add it there,
// or at the end of the queue.
await TrackPlayer.add([ newTrack ], targetTrack);
}
// Then, we'll dispatch the added track event
dispatch(player.actions.addNewTrackToPlayer());
// Then we'll skip to it and play it
if (play) {
await TrackPlayer.skip(newTrack.id);
await TrackPlayer.play();
}
return newTrack;
}, [credentials, tracks, queue, dispatch]);
}

View File

@@ -0,0 +1,115 @@
import { useTypedSelector } from 'store';
import { useCallback } from 'react';
import TrackPlayer, { Track } from 'react-native-track-player';
import { generateTrack } from './JellyfinApi';
import { EntityId } from '@reduxjs/toolkit';
import { shuffle as shuffleArray } from 'lodash';
interface PlayOptions {
play: boolean;
shuffle: boolean;
method: 'add-to-end' | 'add-after-currently-playing' | 'replace';
}
const defaults: PlayOptions = {
play: true,
shuffle: false,
method: 'replace',
};
/**
* Generate a callback function that starts playing a full album given its
* supplied id.
*/
export default function usePlayTracks() {
const credentials = useTypedSelector(state => state.settings.jellyfin);
const tracks = useTypedSelector(state => state.music.tracks.entities);
const downloads = useTypedSelector(state => state.downloads.entities);
return useCallback(async function playTracks(
trackIds: EntityId[] | undefined,
options: Partial<PlayOptions> = {},
): Promise<Track[] | undefined> {
if (!trackIds) {
return;
}
// Retrieve options and queue
const {
play,
shuffle,
method,
} = Object.assign({}, defaults, options);
const queue = await TrackPlayer.getQueue();
// Convert all trackIds to the relevant format for react-native-track-player
const generatedTracks = trackIds.map((trackId) => {
const track = tracks[trackId];
// GUARD: Check that the track actually exists in Redux
if (!trackId || !track) {
return;
}
// Retrieve the generated track from Jellyfin
const generatedTrack = generateTrack(track, credentials);
// Check if a downloaded version exists, and if so rewrite the URL
const download = downloads[trackId];
if (download?.location) {
generatedTrack.url = download.location;
}
return generatedTrack;
}).filter((t): t is Track => typeof t !== 'undefined');
// Potentially shuffle all tracks
const newTracks = shuffle ? shuffleArray(generatedTracks) : generatedTracks;
// Then, we'll need to check where to add the track
switch(method) {
case 'add-to-end': {
await TrackPlayer.add(newTracks);
// Then we'll skip to it and play it
if (play) {
await TrackPlayer.skip((await TrackPlayer.getQueue()).length - newTracks.length);
await TrackPlayer.play();
}
break;
}
case 'add-after-currently-playing': {
// Try and locate the current track
const currentTrackIndex = await TrackPlayer.getCurrentTrack();
// Since the argument is the id to insert the track BEFORE, we need
// to get the current track + 1
const targetTrack = currentTrackIndex >= 0 && queue.length > 1
? queue[currentTrackIndex + 1].id
: undefined;
// Depending on whether this track exists, we either add it there,
// or at the end of the queue.
await TrackPlayer.add(newTracks, targetTrack);
if (play) {
await TrackPlayer.skip(currentTrackIndex + 1);
await TrackPlayer.play();
}
break;
}
case 'replace': {
await TrackPlayer.reset();
await TrackPlayer.add(newTracks);
if (play) {
await TrackPlayer.play();
}
break;
}
}
}, [credentials, downloads, tracks]);
}

View File

@@ -1,18 +1,23 @@
import { useEffect, useState } from 'react';
import TrackPlayer, { usePlaybackState, Track } from 'react-native-track-player';
import { useTypedSelector } from 'store';
import { useCallback, useEffect, useState } from 'react';
import TrackPlayer, { Event, Track, useTrackPlayerEvents } from 'react-native-track-player';
import { useOnTrackAdded } from './AddedTrackEvents';
import useCurrentTrack from './useCurrentTrack';
/**
* This hook retrieves the current playing track from TrackPlayer
*/
export default function useQueue(): Track[] {
const state = usePlaybackState();
const [queue, setQueue] = useState<Track[]>([]);
const addedTrackCount = useTypedSelector(state => state.player.addedTrackCount);
useEffect(() => {
TrackPlayer.getQueue().then(setQueue);
}, [state, addedTrackCount]);
// Define function that fetches the current queue
const updateQueue = useCallback(() => TrackPlayer.getQueue().then(setQueue), [setQueue]);
// Then define the triggers for updating it
useEffect(() => { updateQueue(); }, [updateQueue]);
useTrackPlayerEvents([
Event.PlaybackState,
], updateQueue);
useOnTrackAdded(updateQueue);
return queue;
}
@@ -20,7 +25,19 @@ export default function useQueue(): Track[] {
/**
* Shorthand helper to determine whether a queue exists
*/
export function useHasQueue(): boolean {
export function useHasNextQueue(): boolean {
const { index } = useCurrentTrack();
const queue = useQueue();
return !!queue && queue.length > 1;
return queue?.length > 1 && (index || 0) < (queue.length - 1);
}
/**
* Shorthand helper to determine whether a queue exists
*/
export function useHasPreviousQueue(): boolean {
const { index } = useCurrentTrack();
const queue = useQueue();
return queue?.length > 1 && (index || 0) > 0;
}

View File

@@ -58,7 +58,8 @@
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support
// for emitting type metadata for decorators. */
"resolveJsonModule": true
"resolveJsonModule": true,
"skipLibCheck": true,
},
"exclude": [
"node_modules", "babel.config.js", "metro.config.js", "jest.config.js"