feat: CarPlay and Android Auto (#334)

* feat: base carplay

* fix: android auto

* fix: directly reference redux store

* feat: icons instead of buttons

* fix: implement viewfactory so carplay app can start without phone app

* fix: mvoe react-native-auto-play to stable version
This commit is contained in:
Lei Nelissen
2026-02-07 22:26:47 +01:00
committed by GitHub
parent 2c0e955816
commit d323f5a285
51 changed files with 1752 additions and 345 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="template"/>
</automotiveApp>
+3 -1
View File
@@ -7,9 +7,11 @@ import PlaybackService from './src/utility/PlaybackService';
import { setupSentry } from '@/utility/Sentry';
import { enableScreens } from 'react-native-screens';
import { patchTrackPlayer } from '@/utility/AddedTrackEvents';
import { registerAutoPlay } from './src/screens/carplay/register';
setupSentry();
enableScreens();
patchTrackPlayer();
AppRegistry.registerComponent(appName, () => App);
TrackPlayer.registerPlaybackService(() => PlaybackService);
TrackPlayer.registerPlaybackService(() => PlaybackService);
registerAutoPlay();
-48
View File
@@ -1,48 +0,0 @@
import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "Fintunes",
in: window,
launchOptions: launchOptions
)
return true
}
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
self.bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
+4
View File
@@ -0,0 +1,4 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
+32 -7
View File
@@ -13,10 +13,21 @@
4C04FC6E055249ABB204D3BC /* Inter-VariableFont_slnt,wght.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4B4A0465FF364579B28CF5D7 /* Inter-VariableFont_slnt,wght.ttf */; };
AB393FCA2857CC8400773469 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB393FC92857CC8400773469 /* SnapshotHelper.swift */; };
AB4A8DFE2857C8DA005A1ED0 /* FintunesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4A8DFD2857C8DA005A1ED0 /* FintunesUITests.swift */; };
AB7AA5F92DC8E5D600578CAC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7AA5F82DC8E5D600578CAC /* AppDelegate.swift */; };
ABD245FC2F3605DE00135530 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD245FB2F3605DE00135530 /* AppDelegate.swift */; };
ABD245FE2F3605F500135530 /* PhoneSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD245FD2F3605F500135530 /* PhoneSceneDelegate.swift */; };
FA01635F2599C28FC19F2EC3 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3896494129CBC30258D9BB1C /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
AB87931D2F2CE1530093DF3D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 13B07F861A680F5B00A75B9A;
remoteInfo = Fintunes;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Fintunes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Fintunes.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = "<group>"; };
@@ -30,7 +41,10 @@
AB393FC92857CC8400773469 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = "<group>"; };
AB4A8DFB2857C8DA005A1ED0 /* FintunesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FintunesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AB4A8DFD2857C8DA005A1ED0 /* FintunesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FintunesUITests.swift; sourceTree = "<group>"; };
AB7AA5F82DC8E5D600578CAC /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AB78871B2F35FDA400C6CF3D /* Fintunes-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Fintunes-Bridging-Header.h"; sourceTree = "<group>"; };
AB8793222F2D399F0093DF3D /* Fintunes.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = Fintunes.entitlements; path = Fintunes/Fintunes.entitlements; sourceTree = "<group>"; };
ABD245FB2F3605DE00135530 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Fintunes/AppDelegate.swift; sourceTree = "<group>"; };
ABD245FD2F3605F500135530 /* PhoneSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneSceneDelegate.swift; sourceTree = "<group>"; };
E22EC545298DA9F9017776C0 /* libPods-Fintunes.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Fintunes.a"; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; };
@@ -58,11 +72,14 @@
13B07FAE1A68108700A75B9A /* Fintunes */ = {
isa = PBXGroup;
children = (
AB7AA5F82DC8E5D600578CAC /* AppDelegate.swift */,
ABD245FD2F3605F500135530 /* PhoneSceneDelegate.swift */,
ABD245FB2F3605DE00135530 /* AppDelegate.swift */,
AB8793222F2D399F0093DF3D /* Fintunes.entitlements */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB11A68108700A75B9A /* LaunchScreen.xib */,
3896494129CBC30258D9BB1C /* PrivacyInfo.xcprivacy */,
AB78871B2F35FDA400C6CF3D /* Fintunes-Bridging-Header.h */,
);
name = Fintunes;
sourceTree = "<group>";
@@ -189,7 +206,7 @@
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
DevelopmentTeam = HD2D35G9Y4;
LastSwiftMigration = 1210;
LastSwiftMigration = 2620;
ProvisioningStyle = Manual;
};
AB4A8DFA2857C8DA005A1ED0 = {
@@ -309,6 +326,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactNativeAutoPlay/ReactNativeAutoPlay.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
@@ -321,6 +339,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReactNativeAutoPlay.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
@@ -338,7 +357,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AB7AA5F92DC8E5D600578CAC /* AppDelegate.swift in Sources */,
ABD245FE2F3605F500135530 /* PhoneSceneDelegate.swift in Sources */,
ABD245FC2F3605DE00135530 /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -357,6 +377,7 @@
AB4A8E022857C8DA005A1ED0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 13B07F861A680F5B00A75B9A /* Fintunes */;
targetProxy = AB87931D2F2CE1530093DF3D /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@@ -379,6 +400,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Fintunes/Fintunes.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
@@ -405,7 +427,8 @@
PRODUCT_BUNDLE_IDENTIFIER = nl.moeilijkedingen.jellyfinaudioplayer;
PRODUCT_NAME = Fintunes;
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "nl.moeilijkedingen.jellyfinaudioplayer AppStore 1754345870";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "nl.moeilijkedingen.jellyfinaudioplayer AppStore 17";
SWIFT_OBJC_BRIDGING_HEADER = "Fintunes-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -418,6 +441,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Fintunes/Fintunes.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
@@ -440,7 +464,8 @@
PRODUCT_NAME = Fintunes;
PROVISIONING_PROFILE = "915c5213-22f6-4f9d-8065-2a06300f9bfb";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "nl.moeilijkedingen.jellyfinaudioplayer AppStore 1754345870";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "nl.moeilijkedingen.jellyfinaudioplayer AppStore 17";
SWIFT_OBJC_BRIDGING_HEADER = "Fintunes-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
+30
View File
@@ -31,6 +31,36 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}
@objc func getRootViewForAutoplay(
moduleName: String,
initialProperties: [String: Any]?
) -> UIView? {
if RCTIsNewArchEnabled() {
if let factory = reactNativeFactory?.rootViewFactory as? RCTRootViewFactory {
return factory.view(
withModuleName: moduleName,
initialProperties: initialProperties,
launchOptions: nil
)
}
return reactNativeFactory?.rootViewFactory.view(
withModuleName: moduleName,
initialProperties: initialProperties
)
}
if let rootView = window?.rootViewController?.view as? RCTRootView {
return RCTRootView(
bridge: rootView.bridge,
moduleName: moduleName,
initialProperties: initialProperties
)
}
return nil
}
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.carplay-audio</key>
<true/>
</dict>
</plist>
+30
View File
@@ -64,5 +64,35 @@
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>CPTemplateApplicationScene</string>
<key>UISceneConfigurationName</key>
<string>CarPlayHeadUnit</string>
<key>UISceneDelegateClassName</key>
<string>HeadUnitSceneDelegate</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>Phone</string>
<key>UISceneDelegateClassName</key>
<string>Fintunes.PhoneSceneDelegate</string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>
+32
View File
@@ -0,0 +1,32 @@
import UIKit
import React_RCTAppDelegate
class PhoneSceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
// Get the AppDelegate to access the React Native setup
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
// Create the window for this scene
let window = UIWindow(windowScene: windowScene)
// Start React Native if not already started
if let factory = appDelegate.reactNativeFactory {
factory.startReactNative(
withModuleName: "Fintunes",
in: window,
launchOptions: nil
)
}
self.window = window
window.makeKeyAndVisible()
}
}
+32
View File
@@ -0,0 +1,32 @@
import UIKit
import React_RCTAppDelegate
class PhoneSceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
// Get the AppDelegate to access the React Native setup
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
// Create the window for this scene
let window = UIWindow(windowScene: windowScene)
// Start React Native if not already started
if let factory = appDelegate.reactNativeFactory {
factory.startReactNative(
withModuleName: "Fintunes",
in: window,
launchOptions: nil
)
}
self.window = window
window.makeKeyAndVisible()
}
}
+103 -10
View File
@@ -25,6 +25,35 @@ PODS:
- libwebp/sharpyuv (1.3.2)
- libwebp/webp (1.3.2):
- libwebp/sharpyuv
- NitroModules (0.33.7):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-callinvoker
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- RCT-Folly (2024.11.18.00):
- boost
- DoubleConversion
@@ -1939,8 +1968,34 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-netinfo (11.4.1):
- react-native-netinfo (11.5.2):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-safe-area-context (5.6.2):
- boost
- DoubleConversion
@@ -2646,6 +2701,36 @@ PODS:
- React-perflogger (= 0.83.1)
- React-utils (= 0.83.1)
- SocketRocket
- ReactNativeAutoPlay (0.2.2):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- NitroModules
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-callinvoker
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- ReactNativeBlur (4.5.3):
- boost
- DoubleConversion
@@ -2914,7 +2999,7 @@ PODS:
- RNWorklets
- SocketRocket
- Yoga
- RNScreens (4.20.0):
- RNScreens (4.22.0):
- boost
- DoubleConversion
- fast_float
@@ -2941,10 +3026,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNScreens/common (= 4.20.0)
- RNScreens/common (= 4.22.0)
- SocketRocket
- Yoga
- RNScreens/common (4.20.0):
- RNScreens/common (4.22.0):
- boost
- DoubleConversion
- fast_float
@@ -3003,7 +3088,7 @@ PODS:
- Sentry/HybridSDK (= 8.58.0)
- SocketRocket
- Yoga
- RNSVG (15.15.1):
- RNSVG (15.15.2):
- boost
- DoubleConversion
- fast_float
@@ -3029,10 +3114,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNSVG/common (= 15.15.1)
- RNSVG/common (= 15.15.2)
- SocketRocket
- Yoga
- RNSVG/common (15.15.1):
- RNSVG/common (15.15.2):
- boost
- DoubleConversion
- fast_float
@@ -3174,6 +3259,7 @@ DEPENDENCIES:
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- libwebp (= 1.3.2)
- NitroModules (from `../node_modules/react-native-nitro-modules`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
@@ -3251,6 +3337,7 @@ DEPENDENCIES:
- ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`)
- ReactCodegen (from `build/generated/ios/ReactCodegen`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "ReactNativeAutoPlay (from `../node_modules/@iternio/react-native-auto-play`)"
- "ReactNativeBlur (from `../node_modules/@sbaiahmed1/react-native-blur`)"
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
@@ -3295,6 +3382,8 @@ EXTERNAL SOURCES:
hermes-engine:
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-v0.14.0
NitroModules:
:path: "../node_modules/react-native-nitro-modules"
RCT-Folly:
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
RCTDeprecation:
@@ -3447,6 +3536,8 @@ EXTERNAL SOURCES:
:path: build/generated/ios/ReactCodegen
ReactCommon:
:path: "../node_modules/react-native/ReactCommon"
ReactNativeAutoPlay:
:path: "../node_modules/@iternio/react-native-auto-play"
ReactNativeBlur:
:path: "../node_modules/@sbaiahmed1/react-native-blur"
RNCAsyncStorage:
@@ -3485,6 +3576,7 @@ SPEC CHECKSUMS:
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
NitroModules: e8ec707a245a85cf1eb4289f91a9372f9590a897
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1
RCTRequired: 7be34aabb0b77c3cefe644528df0fa0afad4e4d0
@@ -3523,7 +3615,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: 7b72a669e94662359dad4f42b5af005eb24b4e83
React-microtasksnativemodule: cdc02da075f2857803ed63f24f5f72fc40e094c0
react-native-accessibility-settings: 8803c0bd7e1724269bf0ac5064be51773c05df9f
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-netinfo: 57447b5a45c98808f8eae292cf641f3d91d13830
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
react-native-skia: b60d0b9d9aea330baef956b7f015d0af2b42076d
react-native-track-player: dd56f9948e03756a217c75e0a61aa04aab65b5a1
@@ -3561,6 +3653,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01
ReactCodegen: 3d48510bcef445f6403c0004047d4d9cbb915435
ReactCommon: ac934cb340aee91282ecd6f273a26d24d4c55cae
ReactNativeAutoPlay: c7130c09e5c07962d26fcee8dfa53ebc78a40eb4
ReactNativeBlur: 500e90ffcc66e89a3a27a6f3a50a7b765ac1f145
RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82
RNDateTimePicker: 97a86d7c8b51f2d51f694a9dddfe5996dfd9a407
@@ -3569,9 +3662,9 @@ SPEC CHECKSUMS:
RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381
RNLocalize: 23e6689ec46c6cba49eb8cde959e30e49160a35b
RNReanimated: 292cd58688552a22b3fc1cefcfbc49b336dfed68
RNScreens: 714e10b6b554f7dc7ad9f78dcf36dc8e3fc73415
RNScreens: c77cc7a5d1fbc23f0725b54e96eec81118453382
RNSentry: a4c81a5c5bcd58d765126726432348e60cd7183d
RNSVG: ba3c3bc3cf413f32d1a7012274fe503c6fc1eadd
RNSVG: a4ea97e14b7a2221d85091a6b3128f807445a7ec
RNWorklets: 01efdd402d236a13651ea5ea5437ca85a44e7afa
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "fintunes",
"version": "2.5.0",
"main": "src/index.js",
"main": "index.js",
"private": true,
"scripts": {
"android": "react-native run-android",
@@ -13,6 +13,7 @@
},
"dependencies": {
"@d11/react-native-fast-image": "8.13.0",
"@iternio/react-native-auto-play": "0.2.2",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "^8.6.0",
"@react-native-community/netinfo": "^11.4.1",
@@ -41,6 +42,7 @@
"react-native-gesture-handler": "^2.30.0",
"react-native-localize": "3.6.1",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-nitro-modules": "^0.33.2",
"react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.20.0",
@@ -0,0 +1,114 @@
diff --git a/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/AndroidAutoScreen.kt b/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/AndroidAutoScreen.kt
index 19eb2de81576c4c9df9f0353f9dc7aef3f5fd1b5..8b9db9044e40c405fb8d166829eebe8391357150 100644
--- a/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/AndroidAutoScreen.kt
+++ b/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/AndroidAutoScreen.kt
@@ -124,7 +124,14 @@ class AndroidAutoScreen(
}
fun getScreenManager(): ScreenManager? {
- return screens[AndroidAutoSession.ROOT_SESSION]?.screenManager
+ // First try to get the ROOT_SESSION screen
+ val rootScreen = screens[AndroidAutoSession.ROOT_SESSION]
+ if (rootScreen != null) {
+ return rootScreen.screenManager
+ }
+ // If ROOT_SESSION doesn't exist, get any screen's screenManager
+ // (after setRootTemplate, the root might be under a different key)
+ return screens.values.firstOrNull()?.screenManager
}
fun invalidateScreens() {
diff --git a/ios/extensions/CarPlayTemplateExtensions.swift b/ios/extensions/CarPlayTemplateExtensions.swift
index e15d37c34acc692ead9a39e962242e46e3aea525..73ac36aeee867dca7e8c8a6bbe9f79cc254a5e30 100644
--- a/ios/extensions/CarPlayTemplateExtensions.swift
+++ b/ios/extensions/CarPlayTemplateExtensions.swift
@@ -18,8 +18,8 @@ extension CPTemplate {
self.init()
initTemplate(template: self, id: id)
}
- @objc var id: String {
- return (self.userInfo as? [String: Any])?["id"] as! String
+ @objc var id: String? {
+ return (self.userInfo as? [String: Any])?["id"] as? String
}
}
diff --git a/ios/scenes/AutoPlayInterfaceController.swift b/ios/scenes/AutoPlayInterfaceController.swift
index 5008b1ed3790e2d2798317aaeb4df3e65378dbce..927288c8a13bcdbc370cb48c3868c7aa57d89e33 100644
--- a/ios/scenes/AutoPlayInterfaceController.swift
+++ b/ios/scenes/AutoPlayInterfaceController.swift
@@ -90,7 +90,9 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
var templateIds: [String] = []
templates.forEach { template in
- let templateId = template.id
+ guard let templateId = template.id else {
+ return
+ }
if templateId == rootTemplateId {
return
@@ -117,12 +119,12 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
{
guard
let template = interfaceController.templates.first(
- where: {
- templateId == $0.id
+ where: { template in
+ template.id == templateId
})
else { return [] }
- var templateIds: [String] = interfaceController.templates.map {
+ var templateIds: [String] = interfaceController.templates.compactMap {
template in template.id
}
@@ -173,7 +175,10 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
_ aTemplate: CPTemplate,
animated: Bool
) {
- let templateId = aTemplate.id
+ guard let templateId = aTemplate.id else {
+ // System template without ID (e.g., CPNowPlayingTemplate)
+ return
+ }
try? RootModule.withAutoPlayTemplate(templateId: templateId) {
(template: AutoPlayTemplate) in
@@ -187,7 +192,10 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
_ aTemplate: CPTemplate,
animated: Bool
) {
- let templateId = aTemplate.id
+ guard let templateId = aTemplate.id else {
+ // System template without ID (e.g., CPNowPlayingTemplate)
+ return
+ }
if rootTemplateId == templateId {
// this makes sure we purge outdated CPSearchTemplate since that one can be popped on with a CarPlay native button we can not intercept
@@ -210,7 +218,10 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
_ aTemplate: CPTemplate,
animated: Bool
) {
- let templateId = aTemplate.id
+ guard let templateId = aTemplate.id else {
+ // System template without ID (e.g., CPNowPlayingTemplate)
+ return
+ }
try? RootModule.withAutoPlayTemplate(
templateId: templateId,
@@ -228,7 +239,10 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
_ aTemplate: CPTemplate,
animated: Bool
) {
- let templateId = aTemplate.id
+ guard let templateId = aTemplate.id else {
+ // System template without ID (e.g., CPNowPlayingTemplate)
+ return
+ }
try? RootModule.withAutoPlayTemplate(
templateId: templateId,
+307 -123
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,3 +4,4 @@ onlyBuiltDependencies:
patchedDependencies:
react-native-webview: patches/react-native-webview.patch
react-native-track-player: patches/react-native-track-player.patch
'@iternio/react-native-auto-play': patches/@iternio__react-native-auto-play.patch
+7 -1
View File
@@ -83,5 +83,11 @@
"kbps": "kbps",
"transcoded": "Транскодиран",
"khz": "kHz",
"direct-play": "Direct play"
"direct-play": "Direct play",
"browse": "Преглед",
"play": "Възпроизвеждане",
"shuffle": "Разбъркай",
"tracks": "песни",
"unknown-artist": "Неизвестен изпълнител",
"unknown-track": "Неизвестна песен"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"khz": "kHz",
"direct-play": "Reproducció directa",
"transcoded": "Transcodificació",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Explorar",
"play": "Reproduir",
"shuffle": "Aleatori",
"tracks": "pistes",
"unknown-artist": "Artista desconegut",
"unknown-track": "Pista desconeguda"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Přímé přehrávání",
"transcoded": "Překódované",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Procházet",
"play": "Přehrát",
"shuffle": "Zamíchat",
"tracks": "skladeb",
"unknown-artist": "Neznámý interpret",
"unknown-track": "Neznámá skladba"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Direkte spil",
"transcoded": "Transcode",
"kbps": "kbps",
"khz": "kHz"
"khz": "kHz",
"browse": "Gennemse",
"play": "Afspil",
"shuffle": "Bland",
"tracks": "numre",
"unknown-artist": "Ukendt kunstner",
"unknown-track": "Ukendt nummer"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Direkte Wiedergabe",
"transcoded": "Transkodiert",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Durchsuchen",
"play": "Abspielen",
"shuffle": "Zufällig",
"tracks": "Titel",
"unknown-artist": "Unbekannter Künstler",
"unknown-track": "Unbekannter Titel"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Direct play",
"transcoded": "Transcoded",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Browse",
"play": "Play",
"shuffle": "Shuffle",
"tracks": "tracks",
"unknown-artist": "Unknown Artist",
"unknown-track": "Unknown Track"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Reproducción directa",
"transcoded": "Transcodificar",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Explorar",
"play": "Reproducir",
"shuffle": "Aleatorio",
"tracks": "pistas",
"unknown-artist": "Artista desconocido",
"unknown-track": "Pista desconocida"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"similar-albums": "Samanlaisia albumeja",
"playback-reporting": "Toiston raportointi",
"playback-reporting-description": "Toiston raportoinnin avulla kaikki toistotapahtumat palautetaan palvelimellesi. Näin voit seurata eniten kuunneltuja kappaleita, esimerkiksi pluginien, kuten ListenBrainz tai LastFM, avulla.",
"color-scheme-description": "Fintunes noudattaa oletuksena käyttöjärjestelmän ulkoasua. Voit kuitenkin ohittaa tämän varmistaaksesi, että Fintunes on aina tummassa tai vaaleassa tilassa."
"color-scheme-description": "Fintunes noudattaa oletuksena käyttöjärjestelmän ulkoasua. Voit kuitenkin ohittaa tämän varmistaaksesi, että Fintunes on aina tummassa tai vaaleassa tilassa.",
"browse": "Selaa",
"play": "Toista",
"shuffle": "Sekoita",
"tracks": "kappaletta",
"unknown-artist": "Tuntematon artisti",
"unknown-track": "Tuntematon kappale"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Lecture directe",
"transcoded": "Converti",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Parcourir",
"play": "Lire",
"shuffle": "Aléatoire",
"tracks": "pistes",
"unknown-artist": "Artiste inconnu",
"unknown-track": "Piste inconnue"
}
+7 -1
View File
@@ -80,5 +80,11 @@
"where-is-data-stored": "Hol van tárolva a gyűjtött adat?",
"where-is-data-stored-description": "A Sentry rendszert saját szervereken futtatjuk, a mi saját infrastruktúránkon. A szerverekhez, adatbázisokhoz és naplófájlokhoz csak mi férünk hozzá, legkevésbé a Sentry munkatársai. Az infrastruktúra az Európai Unióban van futtatva.",
"enable-error-reporting": "Engedélyezni szeretnéd a hibajelentést?",
"enable-error-reporting-description": "Segít nekünk fejleszteni az alkalmazásélményt a hibák és összeomlások jelentésével."
"enable-error-reporting-description": "Segít nekünk fejleszteni az alkalmazásélményt a hibák és összeomlások jelentésével.",
"browse": "Böngészés",
"play": "Lejátszás",
"shuffle": "Keverés",
"tracks": "számok",
"unknown-artist": "Ismeretlen előadó",
"unknown-track": "Ismeretlen szám"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"privacy-policy": "Kebijakan Privasi",
"sleep-timer": "Pengatur waktu tidur",
"delete": "Menghapus",
"cancel": "Membatalkan"
"cancel": "Membatalkan",
"browse": "Jelajahi",
"play": "Mainkan",
"shuffle": "Acak",
"tracks": "lagu",
"unknown-artist": "Artis Tidak Dikenal",
"unknown-track": "Lagu Tidak Dikenal"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Gioco diretto",
"transcoded": "Transcodifica",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Sfoglia",
"play": "Riproduci",
"shuffle": "Casuale",
"tracks": "tracce",
"unknown-artist": "Artista sconosciuto",
"unknown-track": "Traccia sconosciuta"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "ダイレクトプレイ",
"transcoded": "トランスコード",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "ブラウズ",
"play": "再生",
"shuffle": "シャッフル",
"tracks": "トラック",
"unknown-artist": "不明なアーティスト",
"unknown-track": "不明なトラック"
}
+7 -1
View File
@@ -82,5 +82,11 @@
"lyrics": "Sangtekster",
"khz": "kHz",
"kbps": "kbps",
"transcoded": "Transkodet"
"transcoded": "Transkodet",
"browse": "Bla gjennom",
"play": "Spill",
"shuffle": "Stokk",
"tracks": "spor",
"unknown-artist": "Ukjent artist",
"unknown-track": "Ukjent spor"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"transcoded": "Getranscodeerd",
"khz": "kHz",
"kbps": "kbps",
"lyrics": "Songtekst"
"lyrics": "Songtekst",
"browse": "Bladeren",
"play": "Afspelen",
"shuffle": "Willekeurig",
"tracks": "nummers",
"unknown-artist": "Onbekende artiest",
"unknown-track": "Onbekend nummer"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Dyrektywa",
"transcoded": "Transcode",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Przeglądaj",
"play": "Odtwórz",
"shuffle": "Losowo",
"tracks": "utworów",
"unknown-artist": "Nieznany artysta",
"unknown-track": "Nieznany utwór"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Reprodução Direta",
"transcoded": "Transcodificado",
"khz": "kHz",
"retry-failed-downloads": "Tentar Transferir Novamente"
"retry-failed-downloads": "Tentar Transferir Novamente",
"browse": "Explorar",
"play": "Reproduzir",
"shuffle": "Aleatório",
"tracks": "faixas",
"unknown-artist": "Artista Desconhecido",
"unknown-track": "Faixa Desconhecida"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Reprodução direta",
"transcoded": "Transcodificado",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "Navegar",
"play": "Reproduzir",
"shuffle": "Aleatório",
"tracks": "faixas",
"unknown-artist": "Artista Desconhecido",
"unknown-track": "Faixa Desconhecida"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Прямая игра",
"transcoded": "Транскод",
"khz": "кГц",
"kbps": "кбит/с"
"kbps": "кбит/с",
"browse": "Обзор",
"play": "Воспроизвести",
"shuffle": "Перемешать",
"tracks": "треков",
"unknown-artist": "Неизвестный исполнитель",
"unknown-track": "Неизвестный трек"
}
+7 -1
View File
@@ -81,5 +81,11 @@
"disc": "Disk",
"lyrics": "Besedilo",
"khz": "kHz",
"transcoded": "Transcode"
"transcoded": "Transcode",
"browse": "Prebrskaj",
"play": "Predvajaj",
"shuffle": "Naključno",
"tracks": "skladbe",
"unknown-artist": "Neznan izvajalec",
"unknown-track": "Neznana skladba"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Direkt spel",
"transcoded": "Transkodning",
"khz": "kHz",
"kbps": "kb/s"
"kbps": "kb/s",
"browse": "Bläddra",
"play": "Spela",
"shuffle": "Blanda",
"tracks": "spår",
"unknown-artist": "Okänd artist",
"unknown-track": "Okänt spår"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"color-scheme-description": "இயல்பாக, உங்கள் இயக்க முறைமையின் வண்ணத் திட்டத்தை FINTUNES பின்பற்றும். இருப்பினும் நீங்கள் இதை மேலெழுத தேர்வு செய்யலாம் fintunes எப்போதும் இருண்ட பயன்முறையில் அல்லது ஒளி பயன்முறையில் இருப்பதை உறுதிப்படுத்தலாம்.",
"direct-play": "நேரடி நாடகம்",
"transcoded": "டிரான்ச்கோடட்",
"kbps": "கே.பி.பி.எச்"
"kbps": "கே.பி.பி.எச்",
"browse": "உலாவு",
"play": "இயக்கு",
"shuffle": "சீரற்றது",
"tracks": "பாடல்கள்",
"unknown-artist": "தெரியாத கலைஞர்",
"unknown-track": "தெரியாத பாடல்"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "Пряме відтворення",
"transcoded": "Перекодований",
"khz": "кГц",
"kbps": "кбіт/с"
"kbps": "кбіт/с",
"browse": "Огляд",
"play": "Відтворити",
"shuffle": "Перемішати",
"tracks": "треків",
"unknown-artist": "Невідомий виконавець",
"unknown-track": "Невідомий трек"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"direct-play": "直接播放",
"transcoded": "转码",
"khz": "kHz",
"kbps": "kbps"
"kbps": "kbps",
"browse": "浏览",
"play": "播放",
"shuffle": "随机播放",
"tracks": "曲目",
"unknown-artist": "未知艺术家",
"unknown-track": "未知曲目"
}
+7 -1
View File
@@ -83,5 +83,11 @@
"why-use-tracking": "為什麼要使用跟蹤?",
"why-use-tracking-description": "跟蹤透過報告奇怪的邊緣案例和疏忽來幫助加快此應用程式的開發。 這有助於使應用程式更加穩定和強大,從而改善每個人的應用程式體驗。",
"what-data-is-gathered": "收集了哪些資料?",
"enable-error-reporting-description": "這透過向我們傳送崩潰和錯誤報告來幫助改善應用程式體驗。"
"enable-error-reporting-description": "這透過向我們傳送崩潰和錯誤報告來幫助改善應用程式體驗。",
"browse": "瀏覽",
"play": "播放",
"shuffle": "隨機播放",
"tracks": "曲目",
"unknown-artist": "未知藝術家",
"unknown-track": "未知曲目"
}
+6
View File
@@ -83,3 +83,9 @@ export type LocaleKeys = 'play-next'
| 'transcoded'
| 'khz'
| 'kbps'
| 'browse'
| 'play'
| 'shuffle'
| 'tracks'
| 'unknown-artist'
| 'unknown-track'
+28
View File
@@ -0,0 +1,28 @@
import { HybridAutoPlay } from '@iternio/react-native-auto-play';
import { createBrowseMenu } from './templates/BrowseMenu';
/**
* Registers CarPlay AutoPlay listeners and sets up the root browse menu
* template when the device is connected to a CarPlay-enabled vehicle.
*/
export function registerAutoPlay(): void {
// Listen for CarPlay connection events
HybridAutoPlay.addListener('didConnect', async () => {
console.log('[AutoPlay] Connected');
try {
// Set up and display the browse menu as the root template
const browseTemplate = createBrowseMenu();
await browseTemplate.setRootTemplate();
console.log('[AutoPlay] Root template set successfully');
} catch (error) {
console.error('[AutoPlay] Error setting up templates:', error);
}
});
// Listen for CarPlay disconnection events
HybridAutoPlay.addListener('didDisconnect', () => {
console.log('[AutoPlay] Disconnected');
});
}
+271
View File
@@ -0,0 +1,271 @@
import { ListTemplate, HybridAutoPlay, type ImageButton } from '@iternio/react-native-auto-play';
import type { Album } from '@/store/music/types';
import { t } from '@/localisation';
import { fetchTracksByAlbum } from '@/store/music/actions';
import { ALPHABET_LETTERS } from '@/CONSTANTS';
import { playTracks } from '@/utility/usePlayTracks';
import store from '@/store';
/**
* Creates a list template showing the 24 most recently added albums,
* sorted by creation date in descending order.
*/
export function createRecentAlbumsTemplate(): ListTemplate {
console.log('[AlbumsList] Creating Recent Albums template...');
const state = store.getState();
const allAlbums = state.music.albums.ids
.map(id => state.music.albums.entities[id])
.filter((album): album is Album => album !== undefined);
const albumsWithDates = allAlbums.filter(album => album.DateCreated !== undefined);
const albums = albumsWithDates
.sort((a, b) => {
const dateA = new Date(a.DateCreated!).getTime();
const dateB = new Date(b.DateCreated!).getTime();
return dateB - dateA;
})
.slice(0, 24);
console.log('[AlbumsList] Recent albums count:', albums.length);
const items = albums.map(album => ({
type: 'default' as const,
title: { text: album.Name },
detailedText: { text: album.AlbumArtist || t('unknown-artist') },
onPress: async () => {
console.log('[AlbumsList] Album selected:', album.Name);
try {
const detailTemplate = await createAlbumDetailTemplate(album);
await detailTemplate.push();
console.log('[AlbumsList] Album detail pushed');
} catch (error) {
console.error('[AlbumsList] Error pushing album detail:', error);
}
},
}));
return new ListTemplate({
title: { text: t('recent-albums') },
sections: items.length > 0 ? { type: 'default', items } : undefined,
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
},
});
}
/**
* Creates a list template showing all albums grouped by alphabetical
* sections, sorted by artist name then album name.
*/
export function createAllAlbumsTemplate(): ListTemplate {
console.log('[AlbumsList] Creating All Albums template...');
const state = store.getState();
const albums = state.music.albums.ids
.map(id => state.music.albums.entities[id])
.filter((album): album is Album => album !== undefined)
.sort((a, b) => {
const artistA = (a.AlbumArtist || a.Artists?.[0] || t('unknown-artist')).toUpperCase();
const artistB = (b.AlbumArtist || b.Artists?.[0] || t('unknown-artist')).toUpperCase();
if (artistA < artistB) return -1;
if (artistA > artistB) return 1;
const nameA = a.Name.toUpperCase();
const nameB = b.Name.toUpperCase();
return nameA < nameB ? -1 : nameA > nameB ? 1 : 0;
});
console.log('[AlbumsList] Total albums:', albums.length);
// Group into alphabetical sections
const sectionMap = new Map<string, Album[]>();
albums.forEach(album => {
const firstLetter = (album.AlbumArtist || album.Artists?.[0] || '#')[0].toUpperCase();
const section = ALPHABET_LETTERS.includes(firstLetter) ? firstLetter : '#';
if (!sectionMap.has(section)) {
sectionMap.set(section, []);
}
sectionMap.get(section)!.push(album);
});
const sections = Array.from(sectionMap.entries())
.filter(([_, sectionAlbums]) => sectionAlbums.length > 0)
.sort(([a], [b]) => {
if (a === '#') return 1;
if (b === '#') return -1;
return a.localeCompare(b);
})
.map(([letter, sectionAlbums]) => ({
type: 'default' as const,
title: letter,
items: sectionAlbums.map(album => ({
type: 'default' as const,
title: { text: album.Name },
detailedText: { text: album.AlbumArtist || t('unknown-artist') },
onPress: async () => {
console.log('[AlbumsList] Album selected:', album.Name);
try {
const detailTemplate = await createAlbumDetailTemplate(album);
await detailTemplate.push();
console.log('[AlbumsList] Album detail pushed');
} catch (error) {
console.error('[AlbumsList] Error pushing album detail:', error);
}
},
})),
}));
return new ListTemplate({
title: { text: t('all-albums') },
sections: sections.length > 0 ? sections : undefined,
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
},
});
}
/**
* Creates a detail template for a specific album showing its tracks
* with play and shuffle actions. Fetches tracks from the API if they're
* not already in the store.
*/
export async function createAlbumDetailTemplate(album: Album): Promise<ListTemplate> {
let state = store.getState();
let trackIds = state.music.tracks.byAlbum[album.Id] || [];
// Only fetch if tracks are missing
if (trackIds.length === 0) {
console.log('[AlbumsList] Tracks missing, fetching for album:', album.Name);
await store.dispatch(fetchTracksByAlbum(album.Id));
state = store.getState();
trackIds = state.music.tracks.byAlbum[album.Id] || [];
}
const tracks = trackIds
.map(id => state.music.tracks.entities[id])
.filter((track): track is NonNullable<typeof track> => track !== undefined);
console.log('[AlbumsList] Album tracks:', tracks.length);
const items = tracks.map((track, index) => ({
type: 'default' as const,
title: { text: track.Name },
detailedText: { text: track.Artists?.join(', ') || t('unknown-artist') },
onPress: async () => {
try {
await playTracks(
trackIds,
state.music.tracks.entities,
state.downloads.entities,
{ playIndex: index }
);
} catch (error) {
console.error('[AlbumsList] Error playing track:', error);
}
},
}));
const playAction: ImageButton<ListTemplate> = {
type: 'image',
image: { type: 'glyph', name: 'play_arrow', fontScale: 0.8 },
onPress: async () => {
console.log('[AlbumsList] Play album:', album.Name);
try {
await playTracks(
trackIds,
state.music.tracks.entities,
state.downloads.entities
);
} catch (error) {
console.error('[AlbumsList] Error playing album:', error);
}
},
};
const shuffleAction: ImageButton<ListTemplate> = {
type: 'image',
image: { type: 'glyph', name: 'shuffle', fontScale: 0.8 },
onPress: async () => {
console.log('[AlbumsList] Shuffle album:', album.Name);
try {
await playTracks(
trackIds,
state.music.tracks.entities,
state.downloads.entities,
{ shuffle: true }
);
} catch (error) {
console.error('[AlbumsList] Error shuffling album:', error);
}
},
};
if (items.length === 0) {
return new ListTemplate({
title: { text: album.Name },
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
},
});
}
return new ListTemplate({
title: { text: album.Name },
sections: {
type: 'default',
items,
},
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
endHeaderActions: [playAction, shuffleAction],
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
trailingNavigationBarButtons: [playAction, shuffleAction],
},
},
});
}
@@ -0,0 +1,153 @@
import { ListTemplate, HybridAutoPlay } from '@iternio/react-native-auto-play';
import type { MusicArtist } from '@/store/music/types';
import { t } from '@/localisation';
import { createAlbumDetailTemplate } from './AlbumsList';
import { ALPHABET_LETTERS } from '@/CONSTANTS';
import store from '@/store';
/**
* Creates a list template showing all artists grouped by alphabetical
* sections, sorted alphabetically by name.
*/
export function createArtistsTemplate(): ListTemplate {
console.log('[ArtistsList] Creating Artists template...');
const state = store.getState();
const artists = state.music.artists.ids
.map(id => state.music.artists.entities[id])
.filter((artist): artist is MusicArtist => artist !== undefined)
.sort((a, b) => a.Name.toUpperCase().localeCompare(b.Name.toUpperCase()));
console.log('[ArtistsList] Total artists:', artists.length);
// Group into alphabetical sections
const sectionMap = new Map<string, MusicArtist[]>();
artists.forEach(artist => {
const firstLetter = artist.Name[0].toUpperCase();
const section = ALPHABET_LETTERS.includes(firstLetter) ? firstLetter : '#';
if (!sectionMap.has(section)) {
sectionMap.set(section, []);
}
sectionMap.get(section)!.push(artist);
});
const sections = Array.from(sectionMap.entries())
.filter(([_, sectionArtists]) => sectionArtists.length > 0)
.sort(([a], [b]) => {
if (a === '#') return 1;
if (b === '#') return -1;
return a.localeCompare(b);
})
.map(([letter, sectionArtists]) => ({
type: 'default' as const,
title: letter,
items: sectionArtists.map(artist => ({
type: 'default' as const,
title: { text: artist.Name },
onPress: async () => {
console.log('[ArtistsList] Artist selected:', artist.Name);
try {
const detailTemplate = createArtistDetailTemplate(artist);
await detailTemplate.push();
console.log('[ArtistsList] Artist detail pushed');
} catch (error) {
console.error('[ArtistsList] Error pushing artist detail:', error);
}
},
})),
}));
return new ListTemplate({
title: { text: t('artists') },
sections: sections.length > 0 ? sections : undefined,
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
},
});
}
/**
* Creates a detail template for a specific artist showing all their
* albums.
*/
function createArtistDetailTemplate(artist: MusicArtist): ListTemplate {
const state = store.getState();
// Find albums by this artist
const albums = state.music.albums.ids
.map(id => state.music.albums.entities[id])
.filter(album => album?.ArtistItems?.find(item => item.Id === artist.Id))
.filter((album): album is NonNullable<typeof album> => album !== undefined);
console.log('[ArtistsList] Artist albums:', albums.length);
const items = albums.map(album => ({
type: 'default' as const,
title: { text: album.Name },
detailedText: { text: album.ProductionYear?.toString() || t('album') },
onPress: async () => {
console.log('[ArtistsList] Album selected:', album.Name);
try {
const detailTemplate = await createAlbumDetailTemplate(album);
await detailTemplate.push();
console.log('[ArtistsList] Album detail pushed');
} catch (error) {
console.error('[ArtistsList] Error opening album:', error);
}
},
}));
// Play/shuffle actions removed - artist view shows albums, not tracks
if (items.length === 0) {
return new ListTemplate({
title: { text: artist.Name },
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
},
});
}
return new ListTemplate({
title: { text: artist.Name },
sections: { type: 'default', items },
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
},
});
}
@@ -0,0 +1,83 @@
import { ListTemplate } from '@iternio/react-native-auto-play';
import { t } from '@/localisation';
import {
createRecentAlbumsTemplate,
createAllAlbumsTemplate,
} from './AlbumsList';
import { createPlaylistsTemplate } from './PlaylistsList';
import { createArtistsTemplate } from './ArtistsList';
/**
* Creates the main CarPlay browse menu with navigation to recent albums,
* all albums, playlists, and artists.
*/
export function createBrowseMenu(): ListTemplate {
return new ListTemplate({
title: { text: t('browse') },
sections: {
type: 'default',
items: [
{
type: 'default',
title: { text: t('recent-albums') },
image: { type: 'glyph', name: 'album', fontScale: 0.6 },
onPress: async () => {
console.log('[BrowseMenu] Recent Albums selected');
try {
const template = createRecentAlbumsTemplate();
await template.push();
console.log('[BrowseMenu] Recent Albums template pushed');
} catch (error) {
console.error('[BrowseMenu] Error pushing Recent Albums:', error);
}
},
},
{
type: 'default',
title: { text: t('all-albums') },
image: { type: 'glyph', name: 'album', fontScale: 0.6 },
onPress: async () => {
console.log('[BrowseMenu] All Albums selected');
try {
const template = createAllAlbumsTemplate();
await template.push();
console.log('[BrowseMenu] All Albums template pushed');
} catch (error) {
console.error('[BrowseMenu] Error pushing All Albums:', error);
}
},
},
{
type: 'default',
title: { text: t('playlists') },
image: { type: 'glyph', name: 'queue', fontScale: 0.6 },
onPress: async () => {
console.log('[BrowseMenu] Playlists selected');
try {
const template = createPlaylistsTemplate();
await template.push();
console.log('[BrowseMenu] Playlists template pushed');
} catch (error) {
console.error('[BrowseMenu] Error pushing Playlists:', error);
}
},
},
{
type: 'default',
title: { text: t('artists') },
image: { type: 'glyph', name: 'artist', fontScale: 0.6 },
onPress: async () => {
console.log('[BrowseMenu] Artists selected');
try {
const template = createArtistsTemplate();
await template.push();
console.log('[BrowseMenu] Artists template pushed');
} catch (error) {
console.error('[BrowseMenu] Error pushing Artists:', error);
}
},
},
],
},
});
}
@@ -0,0 +1,180 @@
import { ListTemplate, HybridAutoPlay, type ImageButton } from '@iternio/react-native-auto-play';
import type { Playlist } from '@/store/music/types';
import { t } from '@/localisation';
import { fetchTracksByPlaylist } from '@/store/music/actions';
import { playTracks } from '@/utility/usePlayTracks';
import store from '@/store';
/**
* Creates a list template showing all user playlists with track counts.
*/
export function createPlaylistsTemplate(): ListTemplate {
console.log('[PlaylistsList] Creating Playlists template...');
const state = store.getState();
const playlists = state.music.playlists.ids
.map(id => state.music.playlists.entities[id])
.filter((playlist): playlist is Playlist => playlist !== undefined);
console.log('[PlaylistsList] Total playlists:', playlists.length);
const items = playlists.map(playlist => {
const trackCount = playlist.ChildCount ||
state.music.tracks.byPlaylist[playlist.Id]?.length ||
playlist.Tracks?.length ||
0;
return {
type: 'default' as const,
title: { text: playlist.Name },
detailedText: { text: `${trackCount} ${trackCount !== 1 ? t('tracks') : t('track')}` },
onPress: async () => {
console.log('[PlaylistsList] Playlist selected:', playlist.Name);
try {
const detailTemplate = await createPlaylistDetailTemplate(playlist);
await detailTemplate.push();
console.log('[PlaylistsList] Playlist detail pushed');
} catch (error) {
console.error('[PlaylistsList] Error pushing playlist detail:', error);
}
},
};
});
return new ListTemplate({
title: { text: t('playlists') },
sections: items.length > 0 ? { type: 'default', items } : undefined,
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
},
});
}
/**
* Creates a detail template for a specific playlist showing its tracks
* with play and shuffle actions. Fetches tracks from the API if they're
* not already in the store.
*/
async function createPlaylistDetailTemplate(playlist: Playlist): Promise<ListTemplate> {
let state = store.getState();
let trackIds = state.music.tracks.byPlaylist[playlist.Id] || [];
// Only fetch if tracks are missing
if (trackIds.length === 0) {
console.log('[PlaylistsList] Tracks missing, fetching for playlist:', playlist.Name);
await store.dispatch(fetchTracksByPlaylist(playlist.Id));
state = store.getState();
trackIds = state.music.tracks.byPlaylist[playlist.Id] || [];
}
const tracks = trackIds
.map(id => state.music.tracks.entities[id])
.filter((track): track is NonNullable<typeof track> => track !== undefined);
console.log('[PlaylistsList] Playlist tracks:', tracks.length);
const items = tracks.map((track, index) => ({
type: 'default' as const,
title: { text: track.Name },
detailedText: { text: track.Artists?.join(', ') || t('unknown-artist') },
onPress: async () => {
try {
await playTracks(
trackIds,
state.music.tracks.entities,
state.downloads.entities,
{ playIndex: index }
);
} catch (error) {
console.error('[PlaylistsList] Error playing track:', error);
}
},
}));
const playAction: ImageButton<ListTemplate> = {
type: 'image',
image: { type: 'glyph', name: 'play_arrow', fontScale: 0.8 },
onPress: async () => {
console.log('[PlaylistsList] Play playlist:', playlist.Name);
try {
await playTracks(
trackIds,
state.music.tracks.entities,
state.downloads.entities
);
} catch (error) {
console.error('[PlaylistsList] Error playing playlist:', error);
}
},
};
const shuffleAction: ImageButton<ListTemplate> = {
type: 'image',
image: { type: 'glyph', name: 'shuffle', fontScale: 0.8 },
onPress: async () => {
console.log('[PlaylistsList] Shuffle playlist:', playlist.Name);
try {
await playTracks(
trackIds,
state.music.tracks.entities,
state.downloads.entities,
{ shuffle: true }
);
} catch (error) {
console.error('[PlaylistsList] Error shuffling playlist:', error);
}
},
};
if (items.length === 0) {
return new ListTemplate({
title: { text: playlist.Name },
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
},
},
});
}
return new ListTemplate({
title: { text: playlist.Name },
sections: { type: 'default', items },
headerActions: {
android: {
startHeaderAction: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
endHeaderActions: [playAction, shuffleAction],
},
ios: {
backButton: {
type: 'back',
onPress: () => HybridAutoPlay.popTemplate(),
},
trailingNavigationBarButtons: [playAction, shuffleAction],
},
},
});
}
+1
View File
@@ -166,6 +166,7 @@ export interface Playlist {
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
ChildCount?: number;
Tracks?: string[];
lastRefreshed?: number;
}
+1 -1
View File
@@ -19,7 +19,7 @@ const deviceMap: Record<typeof Platform['OS'], string> = {
* from the Redux store to a HTTP Header that authenticates the user against the
* Jellyfin server.
*/
function generateConfig(credentials: Credentials): RequestInit {
export function generateConfig(credentials: Credentials): RequestInit {
switch (credentials?.type) {
case 'jellyfin':
return {
+1 -1
View File
@@ -6,7 +6,7 @@ const playlistOptions = {
SortOrder: 'Ascending',
IncludeItemTypes: 'Playlist',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,ChildCount',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb'
};
+7 -1
View File
@@ -1,7 +1,7 @@
import { AlbumTrack, CodecMetadata } from '@/store/music/types';
import { Platform } from 'react-native';
import { Track } from 'react-native-track-player';
import { asyncFetchStore, fetchApi, getImage } from './lib';
import { asyncFetchStore, fetchApi, getImage, generateConfig } from './lib';
const trackOptionsOsOverrides: Record<typeof Platform.OS, Record<string, string>> = {
ios: {
@@ -49,6 +49,11 @@ export function generateTrackUrl(trackId: string) {
export async function generateTrack(track: AlbumTrack): Promise<Track> {
// Also construct the URL for the stream
const url = generateTrackUrl(track.Id);
// Get credentials and generate authentication headers
const credentials = asyncFetchStore().getState().settings.credentials;
const config = generateConfig(credentials);
const headers = config.headers;
return {
url,
@@ -58,6 +63,7 @@ export async function generateTrack(track: AlbumTrack): Promise<Track> {
album: track.Album,
duration: track.RunTimeTicks,
artwork: getImage(track),
headers,
bitRate: baseTrackOptions.audioBitRate,
};
}
+143 -127
View File
@@ -3,6 +3,8 @@ import { useCallback } from 'react';
import TrackPlayer, { Track } from 'react-native-track-player';
import { shuffle as shuffleArray } from 'lodash';
import { generateTrack } from './JellyfinApi/track';
import type { AlbumTrack } from '@/store/music/types';
import type { DownloadEntity } from '@/store/downloads/types';
interface PlayOptions {
play: boolean;
@@ -24,137 +26,151 @@ const defaults: PlayOptions = {
method: 'replace',
};
/**
* Core playback logic that can be used outside of React components
* Used by both usePlayTracks hook and CarPlay templates
*/
export async function playTracks(
trackIds: string[] | undefined,
tracks: Record<string, AlbumTrack>,
downloads: Record<string, DownloadEntity>,
options: Partial<PlayOptions> = {},
): Promise<Track[] | undefined> {
if (!trackIds) {
return;
}
// GUARD: Check options and queue
const {
play,
shuffle,
method,
} = Object.assign({}, defaults, options);
// Convert all trackIds to the relevant format for react-native-track-player
const generatedTracks = (await Promise.all(trackIds.map(async (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 = await generateTrack(track);
// Check if a downloaded version exists, and if so rewrite the URL
const download = downloads[trackId];
if (download?.location) {
generatedTrack.url = 'file://' + download.location;
}
if (download?.image) {
generatedTrack.artwork = 'file://' + download.image;
}
return generatedTrack;
}))).filter((t): t is Track => typeof t !== 'undefined');
// Potentially shuffle all tracks
const newTracks = shuffle ? shuffleArray(generatedTracks) : generatedTracks;
console.log('[playTracks] Generated tracks:', newTracks.length, 'tracks');
console.log('[playTracks] First track URL:', newTracks[0]?.url);
// Then, we'll need to check where to add the track
switch(method) {
case 'add-to-end': {
await TrackPlayer.add(newTracks);
console.log('[playTracks] Added tracks to end, queue size:', (await TrackPlayer.getQueue()).length);
// Then we'll skip to it and play it
if (play) {
await TrackPlayer.skip((await TrackPlayer.getQueue()).length - newTracks.length);
await TrackPlayer.play();
console.log('[playTracks] Playback started');
}
break;
}
case 'add-after-currently-playing': {
// Try and locate the current track
const currentTrackIndex = await TrackPlayer.getActiveTrackIndex();
if (currentTrackIndex === undefined) {
break;
}
// TrackPlayer.add wants the index of the track to insert in front of,
// or just one more
const targetTrack = currentTrackIndex + 1;
// Depending on whether this track exists, we either add it there,
// or at the end of the queue.
await TrackPlayer.add(newTracks, targetTrack);
console.log('[playTracks] Added tracks after current');
if (play) {
await TrackPlayer.skip(currentTrackIndex + 1);
await TrackPlayer.play();
console.log('[playTracks] Playback started');
}
break;
}
case 'replace': {
// Reset the queue first
await TrackPlayer.reset();
console.log('[playTracks] Queue reset');
// GUARD: Check if we need to skip to a particular index
if (options.playIndex !== undefined) {
// If so, we'll split the tracks into tracks before the
// index that should be played, and the queue of tracks that
// will start playing
const before = newTracks.slice(0, options.playIndex);
const current = newTracks.slice(options.playIndex);
// First, we'll add the current queue and (optionally) force
// it to start playing.
await TrackPlayer.add(current);
console.log('[playTracks] Added current tracks, starting at index:', options.playIndex);
if (play) {
await TrackPlayer.play();
console.log('[playTracks] Playback started');
}
// Then, we'll insert the "previous" tracks after the queue
// has started playing. This ensures that these tracks won't
// trigger any events on the track player.
await TrackPlayer.add(before, 0);
} else {
await TrackPlayer.add(newTracks);
console.log('[playTracks] Added all tracks, queue size:', (await TrackPlayer.getQueue()).length);
if (play) {
await TrackPlayer.play();
const state = await TrackPlayer.getPlaybackState();
console.log('[playTracks] Playback started, state:', state);
}
}
break;
}
}
return newTracks;
}
/**
* Generate a callback function that starts playing a full album given its
* supplied id.
*/
export default function usePlayTracks() {
const tracks = useTypedSelector(state => state.music.tracks.entities);
const downloads = useTypedSelector(state => state.downloads.entities);
const tracksEntities = useTypedSelector(state => state.music.tracks.entities);
const downloadsEntities = useTypedSelector(state => state.downloads.entities);
return useCallback(async function playTracks(
trackIds: string[] | undefined,
options: Partial<PlayOptions> = {},
): Promise<Track[] | undefined> {
if (!trackIds) {
return;
}
// Retrieve options and queue
const {
play,
shuffle,
method,
} = Object.assign({}, defaults, options);
// Convert all trackIds to the relevant format for react-native-track-player
const generatedTracks = (await Promise.all(trackIds.map(async (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 = await generateTrack(track);
// Check if a downloaded version exists, and if so rewrite the URL
const download = downloads[trackId];
if (download?.location) {
generatedTrack.url = 'file://' + download.location;
}
if (download?.image) {
generatedTrack.artwork = 'file://' + download.image;
}
return generatedTrack;
}))).filter((t): t is Track => typeof t !== 'undefined');
// Potentially shuffle all tracks
const newTracks = shuffle ? shuffleArray(generatedTracks) : generatedTracks;
console.log('[usePlayTracks] Generated tracks:', newTracks.length, 'tracks');
console.log('[usePlayTracks] First track URL:', newTracks[0]?.url);
// Then, we'll need to check where to add the track
switch(method) {
case 'add-to-end': {
await TrackPlayer.add(newTracks);
console.log('[usePlayTracks] Added tracks to end, queue size:', (await TrackPlayer.getQueue()).length);
// Then we'll skip to it and play it
if (play) {
await TrackPlayer.skip((await TrackPlayer.getQueue()).length - newTracks.length);
await TrackPlayer.play();
console.log('[usePlayTracks] Playback started');
}
break;
}
case 'add-after-currently-playing': {
// Try and locate the current track
const currentTrackIndex = await TrackPlayer.getActiveTrackIndex();
if (currentTrackIndex === undefined) {
break;
}
// TrackPlayer.add wants the index of the track to insert in front of,
// or just one more
const targetTrack = currentTrackIndex + 1;
// Depending on whether this track exists, we either add it there,
// or at the end of the queue.
await TrackPlayer.add(newTracks, targetTrack);
console.log('[usePlayTracks] Added tracks after current');
if (play) {
await TrackPlayer.skip(currentTrackIndex + 1);
await TrackPlayer.play();
console.log('[usePlayTracks] Playback started');
}
break;
}
case 'replace': {
// Reset the queue first
await TrackPlayer.reset();
console.log('[usePlayTracks] Queue reset');
// GUARD: Check if we need to skip to a particular index
if (options.playIndex) {
// If so, we'll split the tracks into tracks before the
// index that should be played, and the queue of tracks that
// will start playing
const before = newTracks.slice(0, options.playIndex);
const current = newTracks.slice(options.playIndex);
// First, we'll add the current queue and (optionally) force
// it to start playing.
await TrackPlayer.add(current);
console.log('[usePlayTracks] Added current tracks, starting at index:', options.playIndex);
if (play) {
await TrackPlayer.play();
console.log('[usePlayTracks] Playback started');
}
// Then, we'll insert the "previous" tracks after the queue
// has started playing. This ensures that these tracks won't
// trigger any events on the track player.
await TrackPlayer.add(before, 0);
} else {
await TrackPlayer.add(newTracks);
console.log('[usePlayTracks] Added all tracks, queue size:', (await TrackPlayer.getQueue()).length);
if (play) {
await TrackPlayer.play();
const state = await TrackPlayer.getPlaybackState();
console.log('[usePlayTracks] Playback started, state:', state);
}
}
break;
}
}
}, [downloads, tracks]);
return useCallback(
(trackIds: string[] | undefined, options: Partial<PlayOptions> = {}) =>
playTracks(trackIds, tracksEntities, downloadsEntities, options),
[downloadsEntities, tracksEntities]
);
}