diff --git a/.gitignore b/.gitignore index 20e7c28..1bf2640 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ fastlane/test_output iOSInjectionProject/ .DS_Store +*.xcuserstate diff --git a/Cider Remote.xcodeproj/project.pbxproj b/Cider Remote.xcodeproj/project.pbxproj index df3eaa7..081470a 100644 --- a/Cider Remote.xcodeproj/project.pbxproj +++ b/Cider Remote.xcodeproj/project.pbxproj @@ -7,16 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - B913FE682E17108A005A4680 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = B913FE672E171089005A4680 /* AppIcon.icon */; }; - B99C7C292DBD96E400B6CD36 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B9D289282CC51497008543A7 /* Assets.xcassets */; }; + B98912DB2FC1864A00937AA2 /* LyricsStudioKit in Frameworks */ = {isa = PBXBuildFile; productRef = B98912DA2FC1864A00937AA2 /* LyricsStudioKit */; }; + B98912DD2FC187DF00937AA2 /* LyricsStudioKit in Frameworks */ = {isa = PBXBuildFile; productRef = B98912DC2FC187DF00937AA2 /* LyricsStudioKit */; }; B9A455622CC51C19006AEB89 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = B9A455612CC51C19006AEB89 /* SocketIO */; }; B9CDA83D2CC686AA00FBF580 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9CDA83C2CC686AA00FBF580 /* WidgetKit.framework */; }; B9CDA83F2CC686AA00FBF580 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9CDA83E2CC686AA00FBF580 /* SwiftUI.framework */; }; B9CDA84C2CC686AC00FBF580 /* NowPlayingExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B9CDA83A2CC686AA00FBF580 /* NowPlayingExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B9CDA87B2CC6905C00FBF580 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = B9CDA87A2CC6905C00FBF580 /* SocketIO */; }; - B9CDA87D2CC69A7300FBF580 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CDA87C2CC69A7300FBF580 /* AppDelegate.swift */; }; - B9D2892E2CC51497008543A7 /* Cider_RemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9D2892A2CC51497008543A7 /* Cider_RemoteApp.swift */; }; - B9D289322CC51497008543A7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B9D289282CC51497008543A7 /* Assets.xcassets */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -54,19 +51,24 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - B913FE672E171089005A4680 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; B9BCCEBD2DE2F6F100B003F8 /* NowPlayingExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NowPlayingExtension.entitlements; sourceTree = ""; }; B9CDA83A2CC686AA00FBF580 /* NowPlayingExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NowPlayingExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B9CDA83C2CC686AA00FBF580 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; B9CDA83E2CC686AA00FBF580 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; - B9CDA87C2CC69A7300FBF580 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - B9D289282CC51497008543A7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B9D289292CC51497008543A7 /* Cider Remote.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Cider Remote.entitlements"; sourceTree = ""; }; - B9D2892A2CC51497008543A7 /* Cider_RemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cider_RemoteApp.swift; sourceTree = ""; }; FA14E3472C7CA1C200904A49 /* Cider Remote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Cider Remote.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + B998C1862EA2B54500FF1517 /* Exceptions for "Cider Remote" folder in "NowPlayingExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + AppDelegate.swift, + AppIcon.icon, + Cider_RemoteApp.swift, + "Preview Content/Preview Assets.xcassets", + ); + target = B9CDA8392CC686AA00FBF580 /* NowPlayingExtension */; + }; B9E7DA032D0125E800840996 /* Exceptions for "NowPlaying" folder in "Cider Remote" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -91,9 +93,12 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - B99015492D46931300D4CE93 /* Preview Content */ = { + B998C12B2EA2B54500FF1517 /* Cider Remote */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = "Preview Content"; + exceptions = ( + B998C1862EA2B54500FF1517 /* Exceptions for "Cider Remote" folder in "NowPlayingExtension" target */, + ); + path = "Cider Remote"; sourceTree = ""; }; B9E7D9FC2D0125E800840996 /* NowPlaying */ = { @@ -105,21 +110,6 @@ path = NowPlaying; sourceTree = ""; }; - B9E7DA0A2D0125F100840996 /* Views */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Views; - sourceTree = ""; - }; - B9E7DA182D0125F500840996 /* Components */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Components; - sourceTree = ""; - }; - B9E7DA2F2D01260000840996 /* Data */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Data; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -128,6 +118,7 @@ buildActionMask = 2147483647; files = ( B9CDA83F2CC686AA00FBF580 /* SwiftUI.framework in Frameworks */, + B98912DD2FC187DF00937AA2 /* LyricsStudioKit in Frameworks */, B9CDA83D2CC686AA00FBF580 /* WidgetKit.framework in Frameworks */, B9CDA87B2CC6905C00FBF580 /* SocketIO in Frameworks */, ); @@ -138,6 +129,7 @@ buildActionMask = 2147483647; files = ( B9A455622CC51C19006AEB89 /* SocketIO in Frameworks */, + B98912DB2FC1864A00937AA2 /* LyricsStudioKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -153,27 +145,11 @@ name = Frameworks; sourceTree = ""; }; - B9D2892D2CC51497008543A7 /* Cider Remote */ = { - isa = PBXGroup; - children = ( - B99015492D46931300D4CE93 /* Preview Content */, - B9D289292CC51497008543A7 /* Cider Remote.entitlements */, - B9D2892A2CC51497008543A7 /* Cider_RemoteApp.swift */, - B9CDA87C2CC69A7300FBF580 /* AppDelegate.swift */, - B9E7DA182D0125F500840996 /* Components */, - B9E7DA0A2D0125F100840996 /* Views */, - B9E7DA2F2D01260000840996 /* Data */, - B913FE672E171089005A4680 /* AppIcon.icon */, - B9D289282CC51497008543A7 /* Assets.xcassets */, - ); - path = "Cider Remote"; - sourceTree = ""; - }; FA14E33E2C7CA1C200904A49 = { isa = PBXGroup; children = ( B9BCCEBD2DE2F6F100B003F8 /* NowPlayingExtension.entitlements */, - B9D2892D2CC51497008543A7 /* Cider Remote */, + B998C12B2EA2B54500FF1517 /* Cider Remote */, B9E7D9FC2D0125E800840996 /* NowPlaying */, B9CDA83B2CC686AA00FBF580 /* Frameworks */, FA14E3482C7CA1C200904A49 /* Products */, @@ -205,14 +181,13 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + B998C12B2EA2B54500FF1517 /* Cider Remote */, B9E7D9FC2D0125E800840996 /* NowPlaying */, - B9E7DA0A2D0125F100840996 /* Views */, - B9E7DA182D0125F500840996 /* Components */, - B9E7DA2F2D01260000840996 /* Data */, ); name = NowPlayingExtension; packageProductDependencies = ( B9CDA87A2CC6905C00FBF580 /* SocketIO */, + B98912DC2FC187DF00937AA2 /* LyricsStudioKit */, ); productName = NowPlayingExtension; productReference = B9CDA83A2CC686AA00FBF580 /* NowPlayingExtension.appex */; @@ -234,14 +209,12 @@ B9CDA84B2CC686AC00FBF580 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - B99015492D46931300D4CE93 /* Preview Content */, - B9E7DA0A2D0125F100840996 /* Views */, - B9E7DA182D0125F500840996 /* Components */, - B9E7DA2F2D01260000840996 /* Data */, + B998C12B2EA2B54500FF1517 /* Cider Remote */, ); name = "Cider Remote"; packageProductDependencies = ( B9A455612CC51C19006AEB89 /* SocketIO */, + B98912DA2FC1864A00937AA2 /* LyricsStudioKit */, ); productName = "Cider Remote"; productReference = FA14E3472C7CA1C200904A49 /* Cider Remote.app */; @@ -277,6 +250,7 @@ packageReferences = ( B9FC584E2CC51B110063D6D8 /* XCRemoteSwiftPackageReference "Starscream" */, B9A455602CC51C19006AEB89 /* XCRemoteSwiftPackageReference "socket" */, + B98912D92FC1864A00937AA2 /* XCRemoteSwiftPackageReference "LyricsStudioKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = FA14E3482C7CA1C200904A49 /* Products */; @@ -294,7 +268,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B99C7C292DBD96E400B6CD36 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -302,8 +275,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B913FE682E17108A005A4680 /* AppIcon.icon in Resources */, - B9D289322CC51497008543A7 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -321,8 +292,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B9D2892E2CC51497008543A7 /* Cider_RemoteApp.swift in Sources */, - B9CDA87D2CC69A7300FBF580 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -342,7 +311,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = NowPlayingExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -352,13 +320,13 @@ INFOPLIST_KEY_CFBundleDisplayName = NowPlaying; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSSupportsLiveActivities = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = "sh.cidercollective.Cider-Remote.NowPlaying"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -378,7 +346,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = NowPlayingExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -388,13 +355,13 @@ INFOPLIST_KEY_CFBundleDisplayName = NowPlaying; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSSupportsLiveActivities = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = "sh.cidercollective.Cider-Remote.NowPlaying"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -541,21 +508,24 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Cider-Remote-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Remote; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "This app accesses your Apple Music library to detect currently playing music and send it to Cider for remote playback."; INFOPLIST_KEY_NSCameraUsageDescription = "We need to access your camera to scan QR codes."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We need to access your local network to access Cider clients."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Cider Remote can add artworks to your photo library."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiresFullScreen = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.1; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = "sh.cidercollective.Cider-Remote"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -581,21 +551,24 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Cider-Remote-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Remote; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "This app accesses your Apple Music library to detect currently playing music and send it to Cider for remote playback."; INFOPLIST_KEY_NSCameraUsageDescription = "We need to access your camera to scan QR codes."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We need to access your local network to access Cider clients."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Cider Remote can add artworks to your photo library."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiresFullScreen = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.1; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = "sh.cidercollective.Cider-Remote"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -641,6 +614,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + B98912D92FC1864A00937AA2 /* XCRemoteSwiftPackageReference "LyricsStudioKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/the-amber-team/LyricsStudioKit"; + requirement = { + branch = main; + kind = branch; + }; + }; B9A455602CC51C19006AEB89 /* XCRemoteSwiftPackageReference "socket" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/socketio/socket.io-client-swift"; @@ -660,6 +641,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + B98912DA2FC1864A00937AA2 /* LyricsStudioKit */ = { + isa = XCSwiftPackageProductDependency; + package = B98912D92FC1864A00937AA2 /* XCRemoteSwiftPackageReference "LyricsStudioKit" */; + productName = LyricsStudioKit; + }; + B98912DC2FC187DF00937AA2 /* LyricsStudioKit */ = { + isa = XCSwiftPackageProductDependency; + package = B98912D92FC1864A00937AA2 /* XCRemoteSwiftPackageReference "LyricsStudioKit" */; + productName = LyricsStudioKit; + }; B9A455612CC51C19006AEB89 /* SocketIO */ = { isa = XCSwiftPackageProductDependency; package = B9A455602CC51C19006AEB89 /* XCRemoteSwiftPackageReference "socket" */; diff --git a/Cider Remote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Cider Remote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a660322..75cea02 100644 --- a/Cider Remote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Cider Remote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "5833576ad107b5ee8f4d75f2c39364248b5a238e2abff4e5002227a1c937a190", + "originHash" : "c86d3653a5b9212f939d5cc72eb9e010f989e8f5aeb1c6feab2d3cd9d475cf53", "pins" : [ + { + "identity" : "lyricsstudiokit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/the-amber-team/LyricsStudioKit", + "state" : { + "branch" : "main", + "revision" : "e1a22da3c75c00069ea26554ca6934139be65e25" + } + }, { "identity" : "socket.io-client-swift", "kind" : "remoteSourceControl", diff --git a/Cider Remote.xcodeproj/project.xcworkspace/xcuserdata/lumaa.xcuserdatad/UserInterfaceState.xcuserstate b/Cider Remote.xcodeproj/project.xcworkspace/xcuserdata/lumaa.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index e79b503..0000000 Binary files a/Cider Remote.xcodeproj/project.xcworkspace/xcuserdata/lumaa.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Cider Remote/AppDelegate.swift b/Cider Remote/AppDelegate.swift index 03e1f7a..a83d8c7 100644 --- a/Cider Remote/AppDelegate.swift +++ b/Cider Remote/AppDelegate.swift @@ -22,76 +22,76 @@ public class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { } } - public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - print("registering BG TASKs") - BGTaskScheduler.shared.register(forTaskWithIdentifier: BGIdentifier.refreshLiveActivity.fullString, using: nil) { task in - guard let task = task as? BGAppRefreshTask else { return } - print("EXECUTING BG TASK") - self.handleAppRefresh(task: task) - } - - #if DEBUG - BGTaskScheduler.shared.cancelAllTaskRequests() - #endif - // manually start BGTask with "e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"sh.cidercollective.Cider-Remote.BGTasks.refreshLiveActivity"]" in lldb - BGTaskScheduler.shared.getPendingTaskRequests { tasks in - print("\(tasks.count) PENDING task(s)") - if tasks.isEmpty { - self.scheduleAppRefresh() - } - } - - return true - } - - func scheduleAppRefresh() { - do { - let request = BGAppRefreshTaskRequest(identifier: BGIdentifier.refreshLiveActivity.fullString) - request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // every 15 mins (minimum allowed by iOS) - try BGTaskScheduler.shared.submit(request) - print("SCHEDULED BG TASK") - } catch { - print("Could not schedule app refresh: \(error)") - } - } - - func handleAppRefresh(task: BGAppRefreshTask) { - print("HANDLED BG TASK") - self.scheduleAppRefresh() - - Task { - let success = await updateLiveActivity() - task.setTaskCompleted(success: success) - } - } - - func updateLiveActivity() async -> Bool { - print("BG TASK OPERATING") - - let liveActivity: LiveActivityManager = .shared - if let device = liveActivity.device { - let vm: MusicPlayerViewModel = .init(device: device) - - await vm.getCurrentTrack() - - if let track: Track = vm.currentTrack { - await liveActivity.updateActivity(with: track) - print("UPDATED using BG TASK") - } - } else { - liveActivity.stopActivity() - print("No device for BG TASK") - return false - } - - return true - } - - enum BGIdentifier: String { - case refreshLiveActivity = "refreshLiveActivity" - - var fullString: String { - return "sh.cidercollective.Cider-Remote.BGTasks.\(self.rawValue)" - } - } +// public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { +// print("registering BG TASKs") +// BGTaskScheduler.shared.register(forTaskWithIdentifier: BGIdentifier.refreshLiveActivity.fullString, using: nil) { task in +// guard let task = task as? BGAppRefreshTask else { return } +// print("EXECUTING BG TASK") +// self.handleAppRefresh(task: task) +// } +// +// #if DEBUG +// BGTaskScheduler.shared.cancelAllTaskRequests() +// #endif +// // manually start BGTask with "e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"sh.cidercollective.Cider-Remote.BGTasks.refreshLiveActivity"]" in lldb +// BGTaskScheduler.shared.getPendingTaskRequests { tasks in +// print("\(tasks.count) PENDING task(s)") +// if tasks.isEmpty { +// self.scheduleAppRefresh() +// } +// } +// +// return true +// } +// +// func scheduleAppRefresh() { +// do { +// let request = BGAppRefreshTaskRequest(identifier: BGIdentifier.refreshLiveActivity.fullString) +// request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // every 15 mins (minimum allowed by iOS) +// try BGTaskScheduler.shared.submit(request) +// print("SCHEDULED BG TASK") +// } catch { +// print("Could not schedule app refresh: \(error)") +// } +// } +// +// func handleAppRefresh(task: BGAppRefreshTask) { +// print("HANDLED BG TASK") +// self.scheduleAppRefresh() +// +// Task { +// let success = await updateLiveActivity() +// task.setTaskCompleted(success: success) +// } +// } +// +// func updateLiveActivity() async -> Bool { +// print("BG TASK OPERATING") +// +// let liveActivity: LiveActivityManager = .shared +// if let device = liveActivity.device { +// let vm: MusicPlayerViewModel = .init(device: device) +// +// await vm.getCurrentTrack() +// +// if let track: Track = vm.currentTrack { +// await liveActivity.updateActivity(with: track) +// print("UPDATED using BG TASK") +// } +// } else { +// liveActivity.stopActivity() +// print("No device for BG TASK") +// return false +// } +// +// return true +// } +// +// enum BGIdentifier: String { +// case refreshLiveActivity = "refreshLiveActivity" +// +// var fullString: String { +// return "sh.cidercollective.Cider-Remote.BGTasks.\(self.rawValue)" +// } +// } } diff --git a/Cider Remote/Assets.xcassets/AudioFormat/Contents.json b/Cider Remote/Assets.xcassets/AudioFormat/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Cider Remote/Assets.xcassets/AudioFormat/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Cider Remote/Assets.xcassets/AudioFormat/DolbyAtmos.imageset/413934-Dolby Atmos Horizontal-015e44-original-1641853769.png b/Cider Remote/Assets.xcassets/AudioFormat/DolbyAtmos.imageset/413934-Dolby Atmos Horizontal-015e44-original-1641853769.png new file mode 100644 index 0000000..72b3455 Binary files /dev/null and b/Cider Remote/Assets.xcassets/AudioFormat/DolbyAtmos.imageset/413934-Dolby Atmos Horizontal-015e44-original-1641853769.png differ diff --git a/Cider Remote/Assets.xcassets/AudioFormat/DolbyAtmos.imageset/Contents.json b/Cider Remote/Assets.xcassets/AudioFormat/DolbyAtmos.imageset/Contents.json new file mode 100644 index 0000000..61452bf --- /dev/null +++ b/Cider Remote/Assets.xcassets/AudioFormat/DolbyAtmos.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "413934-Dolby Atmos Horizontal-015e44-original-1641853769.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Cider Remote/Assets.xcassets/AudioFormat/HighRes-Lossless.imageset/Contents.json b/Cider Remote/Assets.xcassets/AudioFormat/HighRes-Lossless.imageset/Contents.json new file mode 100644 index 0000000..47e19ef --- /dev/null +++ b/Cider Remote/Assets.xcassets/AudioFormat/HighRes-Lossless.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "iOS-HiResLossless-EN.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : false + } +} diff --git a/Cider Remote/Assets.xcassets/AudioFormat/HighRes-Lossless.imageset/iOS-HiResLossless-EN.svg b/Cider Remote/Assets.xcassets/AudioFormat/HighRes-Lossless.imageset/iOS-HiResLossless-EN.svg new file mode 100644 index 0000000..cc0cfcf --- /dev/null +++ b/Cider Remote/Assets.xcassets/AudioFormat/HighRes-Lossless.imageset/iOS-HiResLossless-EN.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Cider Remote/Assets.xcassets/AudioFormat/Lossless-Wordmark.imageset/Contents.json b/Cider Remote/Assets.xcassets/AudioFormat/Lossless-Wordmark.imageset/Contents.json new file mode 100644 index 0000000..29f8058 --- /dev/null +++ b/Cider Remote/Assets.xcassets/AudioFormat/Lossless-Wordmark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iOS-Lossless-EN.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Cider Remote/Assets.xcassets/AudioFormat/Lossless-Wordmark.imageset/iOS-Lossless-EN.svg b/Cider Remote/Assets.xcassets/AudioFormat/Lossless-Wordmark.imageset/iOS-Lossless-EN.svg new file mode 100644 index 0000000..e1de1f2 --- /dev/null +++ b/Cider Remote/Assets.xcassets/AudioFormat/Lossless-Wordmark.imageset/iOS-Lossless-EN.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Cider Remote/Assets.xcassets/AudioFormat/Lossless.symbolset/Contents.json b/Cider Remote/Assets.xcassets/AudioFormat/Lossless.symbolset/Contents.json new file mode 100644 index 0000000..e19eb34 --- /dev/null +++ b/Cider Remote/Assets.xcassets/AudioFormat/Lossless.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "lossless.svg", + "idiom" : "universal" + } + ] +} diff --git a/Cider Remote/Assets.xcassets/AudioFormat/Lossless.symbolset/lossless.svg b/Cider Remote/Assets.xcassets/AudioFormat/Lossless.symbolset/lossless.svg new file mode 100644 index 0000000..1c2ef5c --- /dev/null +++ b/Cider Remote/Assets.xcassets/AudioFormat/Lossless.symbolset/lossless.svg @@ -0,0 +1,147 @@ + + + lossless + + + + + + + Weight/Scale Variations + + + Ultralight + + + Thin + + + Light + + + Regular + + + Medium + + + Semibold + + + Bold + + + Heavy + + + Black + + + + + + + + + + + + + Design Variations + + + Symbols are supported in up to nine weights and three scales. + + + For optimal layout with text and other symbols, vertically align + + + symbols with the adjacent text. + + + + + + + + Margins + + + Leading and trailing margins on the left and right side of each symbol + + + can be adjusted by modifying the x-location of the margin guidelines. + + + Modifications are automatically applied proportionally to all + + + scales and weights. + + + + + + Exporting + + + Symbols should be outlined when exporting to ensure the + + + design is preserved when submitting to Xcode. + + + Template v.3.0 + + + Requires Xcode 13 or greater + + + Generated from lossless + + + Typeset at 100 points + + + Small + + + Medium + + + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Cider Remote/Assets.xcassets/CiderBack.colorset/Contents.json b/Cider Remote/Assets.xcassets/CiderBack.colorset/Contents.json new file mode 100644 index 0000000..dea201f --- /dev/null +++ b/Cider Remote/Assets.xcassets/CiderBack.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x09", + "green" : "0x0A", + "red" : "0x0C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Cider Remote/Assets.xcassets/GlassIcon.imageset/1024x1024.png b/Cider Remote/Assets.xcassets/GlassIcon.imageset/1024x1024.png new file mode 100644 index 0000000..2ec6ceb Binary files /dev/null and b/Cider Remote/Assets.xcassets/GlassIcon.imageset/1024x1024.png differ diff --git a/Cider Remote/Assets.xcassets/GlassIcon.imageset/1024x1024_Dark.png b/Cider Remote/Assets.xcassets/GlassIcon.imageset/1024x1024_Dark.png new file mode 100644 index 0000000..f86454e Binary files /dev/null and b/Cider Remote/Assets.xcassets/GlassIcon.imageset/1024x1024_Dark.png differ diff --git a/Cider Remote/Assets.xcassets/GlassIcon.imageset/Contents.json b/Cider Remote/Assets.xcassets/GlassIcon.imageset/Contents.json new file mode 100644 index 0000000..627feb7 --- /dev/null +++ b/Cider Remote/Assets.xcassets/GlassIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "1024x1024.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "1024x1024_Dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Cider Remote/Assets.xcassets/Symbols/BoxNote.symbolset/Contents.json b/Cider Remote/Assets.xcassets/Symbols/BoxNote.symbolset/Contents.json new file mode 100644 index 0000000..630f763 --- /dev/null +++ b/Cider Remote/Assets.xcassets/Symbols/BoxNote.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "music.note.square.svg", + "idiom" : "universal" + } + ] +} diff --git a/Cider Remote/Assets.xcassets/Symbols/BoxNote.symbolset/music.note.square.svg b/Cider Remote/Assets.xcassets/Symbols/BoxNote.symbolset/music.note.square.svg new file mode 100644 index 0000000..389ce60 --- /dev/null +++ b/Cider Remote/Assets.xcassets/Symbols/BoxNote.symbolset/music.note.square.svg @@ -0,0 +1,109 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.5.0 + Requires Xcode 15 or greater + Generated from square + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Cider Remote/Assets.xcassets/macOS.imageset/Contents.json b/Cider Remote/Assets.xcassets/macOS.imageset/Contents.json index fb7119b..8e037cf 100644 --- a/Cider Remote/Assets.xcassets/macOS.imageset/Contents.json +++ b/Cider Remote/Assets.xcassets/macOS.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "macos-26-logo.png", + "filename" : "MacOS_logo_(2017).svg", "idiom" : "universal" } ], diff --git a/Cider Remote/Assets.xcassets/macOS.imageset/MacOS_logo_(2017).svg b/Cider Remote/Assets.xcassets/macOS.imageset/MacOS_logo_(2017).svg new file mode 100644 index 0000000..38c7146 --- /dev/null +++ b/Cider Remote/Assets.xcassets/macOS.imageset/MacOS_logo_(2017).svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Cider Remote/Assets.xcassets/macOS.imageset/macos-26-logo.png b/Cider Remote/Assets.xcassets/macOS.imageset/macos-26-logo.png deleted file mode 100644 index 69d35e7..0000000 Binary files a/Cider Remote/Assets.xcassets/macOS.imageset/macos-26-logo.png and /dev/null differ diff --git a/Cider Remote/Cider_RemoteApp.swift b/Cider Remote/Cider_RemoteApp.swift index d589ef4..1ec87ca 100644 --- a/Cider Remote/Cider_RemoteApp.swift +++ b/Cider Remote/Cider_RemoteApp.swift @@ -15,11 +15,17 @@ struct Cider_RemoteApp: App { var body: some Scene { WindowGroup { - ContentView() - .onAppear { - Self.delegate = self.delegate - RemoteShortcuts.updateAppShortcutParameters() - } + ZStack { + if UserDefaults.standard.value(forKey: "onboarded") != nil ? UserDefaults.standard.bool(forKey: "onboarded") : false { + ContentView() + } else { + OnboardingView() + } + } + .onAppear { + Self.delegate = self.delegate + RemoteShortcuts.updateAppShortcutParameters() + } } } } diff --git a/Cider Remote/Components/ActivityViewController.swift b/Cider Remote/Components/ActivityViewController.swift index 5bf9cd1..21c4b3d 100644 --- a/Cider Remote/Components/ActivityViewController.swift +++ b/Cider Remote/Components/ActivityViewController.swift @@ -12,7 +12,7 @@ struct ActivityViewController: UIViewControllerRepresentable { if let url: URL = URL(string: "https://music.apple.com/us/song/\(track.catalogId)") { return UIActivityViewController(activityItems: [url], applicationActivities: nil) } else { - let ui: UIImage = track.getArtwork() + let ui: UIImage = track.getArtworkLocally() return UIActivityViewController(activityItems: [ui], applicationActivities: nil) } diff --git a/Cider Remote/Components/AnimatedMeshGradientView.swift b/Cider Remote/Components/AnimatedMeshGradientView.swift new file mode 100644 index 0000000..3089bf7 --- /dev/null +++ b/Cider Remote/Components/AnimatedMeshGradientView.swift @@ -0,0 +1,112 @@ +// Made by Lumaa + +import SwiftUI + +struct AnimatedMeshGradientView: View { + @State private var points: [SIMD2] = initialPoints() + + @Binding var colors: [Color] + + var amplify: Float = 1.0 + + static var length: Int = 5 + + var body: some View { + MeshGradient( + width: Self.length, + height: Self.length, + points: points, + colors: colors + ) + .onAppear { + animate() + } + } + + private static func initialPoints() -> [SIMD2] { + var pts: [SIMD2] = [] + for y in 0.. [SIMD2] { + let innerCount = Self.length - 2 + var targetPoints = Array(repeating: SIMD2(0, 0), count: Self.length * Self.length) + + // Set x coordinates + for j in 0.. Void - let size: ElementSize - let geometry: GeometryProxy - - var body: some View { - Button(action: action) { - HStack { - Image(systemName: systemImage) - Text(title) - } - .font(.system(size: adjustedFontSize)) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity) - .frame(height: adjustedHeight) - .background(Color.secondary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - } - - private var adjustedFontSize: CGFloat { - switch size { - case .small: return 12 - case .medium: return 14 - case .large: return 16 - } - } - - private var adjustedHeight: CGFloat { - switch size { - case .small: return 30 - case .medium: return 34 - case .large: return 38 - } - } -} - -struct LargeButton: View { - let title: String - let systemImage: String - let action: () -> Void - let size: ElementSize - let geometry: GeometryProxy - - var body: some View { - Button(action: action) { - HStack { - Image(systemName: systemImage) - Text(title) - } - .font(.system(size: adjustedFontSize)) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity) - .frame(height: adjustedHeight) - .background(Color.secondary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - } - - private var adjustedFontSize: CGFloat { - min(size.fontSize * 0.8, 22) // Reduce font size and set a maximum - } - - private var adjustedHeight: CGFloat { - min(size.dimension * 1.2, 60) // Adjust height and set a maximum - } -} - -// MARK: - Button Styles - -struct PrimaryButtonStyle: ButtonStyle { - @EnvironmentObject var colorScheme: ColorSchemeManager - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(colorScheme.primaryColor) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .scaleEffect(configuration.isPressed ? 0.95 : 1) - } -} - -struct SecondaryButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(Color.secondary.opacity(0.1)) - .foregroundStyle(.primary) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .scaleEffect(configuration.isPressed ? 0.95 : 1) - } -} - -struct ScaleButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.9 : 1) - .animation(.easeInOut(duration: 0.2), value: configuration.isPressed) - } -} - -struct SpringyButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .contentShape(Rectangle()) // Makes the entire frame tappable - .scaleEffect(configuration.isPressed ? 0.9 : 1.0) - .opacity(configuration.isPressed ? 0.6 : 1.0) - .animation(.spring(response: 0.3, dampingFraction: 0.6, blendDuration: 0), value: configuration.isPressed) - } -} - -struct ResponsiveButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.9 : 1.0) - .opacity(configuration.isPressed ? 0.6 : 1.0) - .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) - } -} - -extension Button { - @ViewBuilder - func plainGlassButton() -> some View { - if #available(iOS 26.0, *) { - self - } else { - self - .buttonStyle(.plain) - } - } -} diff --git a/Cider Remote/Components/CustomSlider.swift b/Cider Remote/Components/CustomSlider.swift index 08a4a58..4b510e4 100644 --- a/Cider Remote/Components/CustomSlider.swift +++ b/Cider Remote/Components/CustomSlider.swift @@ -4,16 +4,17 @@ import SwiftUI struct CustomSlider: View { @Binding var value: Double - @EnvironmentObject var colorScheme: ColorSchemeManager - let bounds: ClosedRange @Binding var isDragging: Bool - let onEditingChanged: (Bool) -> Void - @State private var lastDragValue: Double? + var bounds: ClosedRange = 1...10 + var onEditingChanged: (Bool) -> Void = {_ in} + + @State private var lastDragValue: Double? = nil + var body: some View { GeometryReader { geometry in - let sliderHeight: CGFloat = isDragging ? 14 : 8 + let sliderHeight: CGFloat = isDragging ? 20 : 8 ZStack(alignment: .leading) { Rectangle() @@ -30,7 +31,7 @@ struct CustomSlider: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { gestureValue in - withAnimation(.interactiveSpring.speed(0.35)) { + withAnimation(.interactiveSpring) { isDragging = true } let newValue = bounds.lowerBound + (bounds.upperBound - bounds.lowerBound) * Double(gestureValue.location.x / geometry.size.width) @@ -46,7 +47,7 @@ struct CustomSlider: View { } } .onEnded { _ in - withAnimation(.interactiveSpring.speed(0.35)) { + withAnimation(.interactiveSpring) { isDragging = false } lastDragValue = nil diff --git a/Cider Remote/Components/ListHelper.swift b/Cider Remote/Components/ListHelper.swift index 6984f8b..5b17efb 100644 --- a/Cider Remote/Components/ListHelper.swift +++ b/Cider Remote/Components/ListHelper.swift @@ -7,14 +7,15 @@ struct GuideStep: View { let text: String var body: some View { - HStack(alignment: .top, spacing: 15) { + HStack(alignment: .firstTextBaseline, spacing: 15) { Text("\(number)") .font(.headline) .foregroundStyle(.white) .frame(width: 30, height: 30) - .background(Circle().fill(Color.blue)) - + .background(Circle().fill(Color.cider)) + Text(text) + .font(.callout) } } } diff --git a/Cider Remote/Components/LyricButton.swift b/Cider Remote/Components/LyricButton.swift new file mode 100644 index 0000000..c643a12 --- /dev/null +++ b/Cider Remote/Components/LyricButton.swift @@ -0,0 +1,17 @@ +// Made by Lumaa + +import SwiftUI + +struct LyricButton: ButtonStyle { + let lyric: LyricLine + + init(_ lyric: LyricLine) { + self.lyric = lyric + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background(configuration.isPressed ? Color.gray.opacity(0.3) : Color.clear) + .scaleEffect(configuration.isPressed ? 1.05 : 1.0, anchor: lyric.altVoice ? .trailing : .leading) + } +} diff --git a/Cider Remote/Components/SidedStack.swift b/Cider Remote/Components/SidedStack.swift new file mode 100644 index 0000000..d0498f4 --- /dev/null +++ b/Cider Remote/Components/SidedStack.swift @@ -0,0 +1,49 @@ +// Made by Lumaa + +import SwiftUI + +public struct SidedStack: View { + let side: Self.Side + let left: LeftContent + let right: RightContent + + init(side: Side = .left, @ViewBuilder left: () -> LeftContent, @ViewBuilder right: () -> RightContent) { + self.side = side + self.left = left() + self.right = right() + } + + @ViewBuilder + public var body: some View { + HStack { + l.frame(maxWidth: .infinity, maxHeight: .infinity) + + r.frame(maxWidth: .infinity, maxHeight: .infinity) + } + .ignoresSafeArea() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var l: some View { + if side == .left { + left + } else { + right + } + } + + @ViewBuilder + private var r: some View { + if side == .left { + right + } else { + left + } + } + + public enum Side { + case left + case right + } +} diff --git a/Cider Remote/Components/UninteractableVideoPlayer.swift b/Cider Remote/Components/UninteractableVideoPlayer.swift new file mode 100644 index 0000000..e899f38 --- /dev/null +++ b/Cider Remote/Components/UninteractableVideoPlayer.swift @@ -0,0 +1,27 @@ +// Made by Lumaa + +import AVKit +import UIKit +import SwiftUI + +struct UninteractableVideoPlayer: UIViewControllerRepresentable { + let player: AVPlayer + + func updateUIViewController(_ uiViewController: UninteractableAVVideoPlayer, context: Context) {} + + func makeUIViewController(context: Context) -> UninteractableAVVideoPlayer { + let customPlayerVC = UninteractableAVVideoPlayer() + customPlayerVC.player = player // Set the AVPlayer + customPlayerVC.showsPlaybackControls = false + customPlayerVC.showsTimecodes = false + return customPlayerVC + } +} + +class UninteractableAVVideoPlayer: AVPlayerViewController { + override func viewDidLoad() { + super.viewDidLoad() + self.showsPlaybackControls = false + self.showsTimecodes = false + } +} diff --git a/Cider Remote/Data/Browser/LibraryAlbum.swift b/Cider Remote/Data/Browser/LibraryAlbum.swift deleted file mode 100644 index de91689..0000000 --- a/Cider Remote/Data/Browser/LibraryAlbum.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Made by Lumaa - -import Foundation - -struct LibraryAlbum: Identifiable, Hashable { - let id: String - let title: String - let artist: String - let artwork: String - - var tracks: [LibraryTrack]? = nil - - init(id: String, title: String, artist: String, artwork: String) { - self.id = id - self.title = title - self.artist = artist - self.artwork = artwork - } - - init(data: [String: Any]) { - let attributes: [String: Any] = data["attributes"] as! [String: Any] - - self.id = data["id"] as! String - self.title = attributes["name"] as! String - self.artist = attributes["artistName"] as! String - - if let artwork: [String: Any] = attributes["artwork"] as? [String: Any] { - if let w = artwork["width"] as? Int { - self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(w)") - } else { - self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(700)") - } - } else { - self.artwork = "" - } - } -} diff --git a/Cider Remote/Data/CiderPC/APIResponse.swift b/Cider Remote/Data/CiderPC/APIResponse.swift new file mode 100644 index 0000000..aa558bf --- /dev/null +++ b/Cider Remote/Data/CiderPC/APIResponse.swift @@ -0,0 +1,35 @@ +// Made by Lumaa + +import Foundation + +/// A json-decoded API response from Cider PC +/// +/// How to use: +/// ```swift +/// let data: Data = device.sendRequest(endpoint: "queue/position") +/// let encoder: APIResponse? = try? JSONDecoder().decode(APIResponse.self, from: data) +/// ``` +struct APIResponse: Decodable { + let data: ResponseData + let meta: MetaData? + + private init(data: ResponseData, meta: MetaData? = nil) { + self.data = data + self.meta = meta + } + + init(from decoder: any Decoder) throws { + let container: KeyedDecodingContainer.CodingKeys> = try decoder.container( + keyedBy: APIResponse.CodingKeys.self + ) + self.data = try container.decode(ResponseData.self, forKey: APIResponse.CodingKeys.data) + self.meta = try container.decodeIfPresent(MetaData.self, forKey: APIResponse.CodingKeys.meta) + } + + enum CodingKeys: CodingKey { + case data + case meta + } +} + +struct AirResponse: Decodable {} diff --git a/Cider Remote/Data/CiderPC/AuthRequest.swift b/Cider Remote/Data/CiderPC/AuthRequest.swift new file mode 100644 index 0000000..bb9c32f --- /dev/null +++ b/Cider Remote/Data/CiderPC/AuthRequest.swift @@ -0,0 +1,164 @@ +// Made by Lumaa + +import Foundation + +struct AuthRequest: Encodable { + let name: String + let image: URL? + let scopes: [Self.Scopes] + + init(name: String, image: URL? = nil, scopes: [Self.Scopes]) { + self.name = name + self.image = image + self.scopes = scopes + } + + init(name: String, image: String? = nil, scopes: [Self.Scopes]) { + self.name = name + self.image = image != nil ? URL(string: image!) : nil + self.scopes = scopes + } + + struct Result: Decodable { + let token: String + let scopes: [AuthRequest.Scopes] + + init(token: String, scopes: [AuthRequest.Scopes]) { + self.token = token + self.scopes = scopes + } + + init(from decoder: any Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: Self.CodingKeys.self) + self.token = try container.decode(String.self, forKey: AuthRequest.Result.CodingKeys.token) + self.scopes = try container.decode([AuthRequest.Scopes].self, forKey: AuthRequest.Result.CodingKeys.scopes) + } + + enum CodingKeys: CodingKey { + case token + case scopes + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: Self.CodingKeys.self) + try container.encode(self.name, forKey: .name) + try container.encodeIfPresent(self.image, forKey: .image) + try container.encode(self.scopes, forKey: .scopes) + } + + enum CodingKeys: String, CodingKey { + case name = "app_name" + case image = "app_image" + case scopes = "scopes" + } + + enum Scopes: String, Codable { + case playback + case queue + case library + case audio + } + + enum Error: String, Decodable, CaseIterable { + /// Body shape bad, unknown scope, or empty scopes array + case invalid = "INVALID_REQUEST" + + /// User clicked Deny + case denied = "AUTH_REQUEST_DENIED" + + /// 2 min elapsed without user action + case timeout = "AUTH_REQUEST_TIMEOUT" + + /// Another auth dialog is already pending + case busy = "AUTH_REQUEST_BUSY" + + /// Rate limit (see `Retry-After` header) + case cooldown = "AUTH_REQUEST_COOLDOWN" + + /// Rate limit (see `Retry-After` header) + case banned = "AUTH_REQUEST_BANNED" + + var code: Int { + switch self { + case .invalid: + return 400 + case .denied: + return 403 + case .timeout: + return 408 + case .busy: + return 409 + case .cooldown, .banned: + return 429 + } + } + + var description: String { + switch self { + case .invalid: + "Authentication request is invalid, contact Cider Collective to fix this issue." + case .denied: + "You have denied the authentication request" + case .timeout: + "The authentication request expired" + case .busy: + "You are already authenticating a third-party service" + case .cooldown, .banned: + "Can you slow down? Try again in 5 minutes..." + } + } + + static func matchCode(with code: Int) -> Self? { + return self.allCases.filter { $0.code == code }.first + } + } +} + +fileprivate struct CompleteResponse: Decodable { + let data: CiderClient + + private init(data: CiderClient) { + self.data = data + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.data = try container.decode(CiderClient.self, forKey: .data) + } + + enum CodingKeys: CodingKey { + case data + } +} + +extension AuthRequest { + static var remoteRequest: AuthRequest { + return .init(name: "Cider Remote", image: .remoteImage, scopes: [.playback, .queue, .audio, .library]) + } +} + +extension AuthRequest.Result { + func getConnection() async throws -> ConnectionInfo { + guard let url = URL(string: "http://localhost:\(Int.defaultPort)/api/v2/client/info") else { throw NetworkError.invalidURL } + + var request = URLRequest(url: url) + request.addValue(self.token, forHTTPHeaderField: "apptoken") + + let response: (Data, URLResponse) = try await URLSession.shared.data(for: request) + if let http: HTTPURLResponse = response.1 as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw NetworkError.serverError(String(data: response.0, encoding: .utf8) ?? "*No data*") + } + + if let clientResponse: CompleteResponse = try? JSONDecoder().decode(CompleteResponse.self, from: response.0) { + return .init(from: clientResponse.data, using: self) + } + throw NetworkError.invalidResponse + } +} + +extension URL { + static var remoteImage: URL? { + return URL(string: "https://files.lumaa.fr/p/1024x1024.png") // temp path until cider.sh includes it :pray: + } +} diff --git a/Cider Remote/Data/CiderPC/CiderClient.swift b/Cider Remote/Data/CiderPC/CiderClient.swift new file mode 100644 index 0000000..28b9bbb --- /dev/null +++ b/Cider Remote/Data/CiderPC/CiderClient.swift @@ -0,0 +1,100 @@ +// Made by Lumaa + +import Foundation + +struct CiderClient: Decodable { + let framework: Self.Framework + let platform: Self.Platform + let osVersion: String + let port: Int + let production: Bool + /// Currently running Cider version + let version: String + + init(framework: Self.Framework, platform: Self.Platform, osVersion: String, port: Int, production: Bool, version: String) { + self.framework = framework + self.platform = platform + self.osVersion = osVersion + self.port = port + self.production = production + self.version = version + } + + init(device: Device) { + self.framework = device.platform + self.platform = device.os ?? .unknown + self.osVersion = "Unknown" + self.port = device.host.contains(":") ? Int(device.host.split(separator: ":")[1]) ?? .defaultPort : .defaultPort + self.production = true + self.version = device.version + } + + var useV2: Bool { + let core: String = String(version.split(separator: ".")[0]) // "4.0.0" > 4 + let int: Int = Int(core) ?? 0 + return int >= 4 + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.framework = try container.decode(Self.Framework.self, forKey: .framework) + self.platform = try container.decode(Self.Platform.self, forKey: .platform) + self.osVersion = try container.decode(String.self, forKey: .osVersion) + self.port = try container.decode(Int.self, forKey: .port) + self.production = try container.decode(Bool.self, forKey: .production) + self.version = try container.decode(String.self, forKey: .version) + } + + enum Framework: String, Codable { + case dotnet = "dotnet" + case genten = "genten" + case universal = "universal" + case unknown = "unknown" + + var display: String { + switch self { + case .dotnet: + ".NET" + case .genten: + "GenTen" + case .universal: + "Universal" + case .unknown: + "Unknown" + } + } + } + + enum Platform: String, Codable { + case windows = "win32" + case macos = "darwin" + case linux = "linux" + case web = "web" + case unknown = "unknown" + + var display: String { + switch self { + case .windows: + "Windows" + case .macos: + "macOS" + case .linux: + "Linux" + case .web: + "Web" + case .unknown: + "Unknown" + } + } + } + + enum CodingKeys: CodingKey { + case framework + case platform + case osVersion + case port + case production + case version + } +} + diff --git a/Cider Remote/Data/CiderPC/ConnectionInfo.swift b/Cider Remote/Data/CiderPC/ConnectionInfo.swift new file mode 100644 index 0000000..1344c2a --- /dev/null +++ b/Cider Remote/Data/CiderPC/ConnectionInfo.swift @@ -0,0 +1,78 @@ +// Made by Lumaa + +import Foundation + +struct ConnectionInfo: Decodable { + let address: String + let token: String + let method: ConnectionMethod + let initialData: InitialData + + init( + from client: CiderClient, + using auth: AuthRequest.Result, + address: String = "localhost", + connectionMethod: ConnectionMethod = .lan + ) { + self.address = address + self.token = auth.token + self.method = connectionMethod + self.initialData = .init(using: client) + } + + enum CodingKeys: CodingKey { + case address + case token + case method + case initialData + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.address = try container.decode(String.self, forKey: .address) + self.token = try container.decode(String.self, forKey: .token) + self.method = try container.decode(ConnectionMethod.self, forKey: .method) + self.initialData = try container.decode(InitialData.self, forKey: .initialData) + } +} + +enum ConnectionMethod: String, Codable { + case lan + case tunnel +} + +// {"address":"192.168.1.15","token":"abcdefghijklmopqrstuvwxyz","method":"lan","initialData":{"version":"400","platform":"genten","os":"darwin"}} + +struct InitialData: Decodable { + let version: String + let platform: CiderClient.Framework + let os: CiderClient.Platform + + init(version: String, platform: CiderClient.Framework, os: CiderClient.Platform) { + self.version = version + self.platform = platform + self.os = os + } + + init(using client: CiderClient) { + self.version = client.version + self.platform = client.framework + self.os = client.platform + } + + // We'll use CodingKeys to handle the missing 'arch' field + enum CodingKeys: String, CodingKey { + case version, platform, os + } + + // Custom initializer to set a default value for 'arch' + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let oneVersion = try container.decode(String.self, forKey: .version) + version = "\(oneVersion[0]).\(oneVersion[1]).\(oneVersion.count > 3 ? "\(oneVersion[2...oneVersion.count - 1])" : "\(oneVersion[2])")" + + platform = try container.decode(CiderClient.Framework.self, forKey: .platform) + os = try container.decode(CiderClient.Platform.self, forKey: .os) + } +} diff --git a/Cider Remote/Data/Device.swift b/Cider Remote/Data/CiderPC/Device.swift similarity index 50% rename from Cider Remote/Data/Device.swift rename to Cider Remote/Data/CiderPC/Device.swift index 1c98eca..da4fead 100644 --- a/Cider Remote/Data/Device.swift +++ b/Cider Remote/Data/CiderPC/Device.swift @@ -12,19 +12,37 @@ class Device: Identifiable, Codable, ObservableObject, Hashable { let friendlyName: String let creationTime: Int let version: String - let platform: String - let backend: String - let os: String? - let connectionMethod: String + let platform: CiderClient.Framework + /// = platform "for now" + let backend: CiderClient.Framework + let os: CiderClient.Platform? + let connectionMethod: ConnectionMethod @Published var isActive: Bool = false @Published var isRefreshing: Bool = false + @Published var client: CiderClient? = nil + + var useV2: Bool { + return self.client?.useV2 ?? true + } enum CodingKeys: String, CodingKey { case id, host, token, friendlyName, creationTime, version, platform, backend, isActive, connectionMethod, os } - init(id: UUID = UUID(), host: String, token: String, friendlyName: String, creationTime: Int, version: String, platform: String, backend: String, connectionMethod: String, isActive: Bool = false, os: String? = nil) { + init( + id: UUID = UUID(), + host: String, + token: String, + friendlyName: String, + creationTime: Int, + version: String, + platform: CiderClient.Framework, + backend: CiderClient.Framework, + connectionMethod: ConnectionMethod, + isActive: Bool = false, + os: CiderClient.Platform? = nil + ) { self.id = id self.host = host self.token = token @@ -38,6 +56,27 @@ class Device: Identifiable, Codable, ObservableObject, Hashable { self.os = os } + init( + using client: CiderClient, + from auth: AuthRequest.Result, + host: String = "localhost", + friendlyName: String, + connectionMethod: ConnectionMethod = .lan, + isActive: Bool = true + ) { + self.id = UUID() + self.host = host + self.token = auth.token + self.friendlyName = friendlyName + self.creationTime = Int(Date().timeIntervalSince1970) + self.version = client.version + self.platform = client.framework + self.backend = client.framework + self.connectionMethod = connectionMethod + self.isActive = isActive + self.os = client.platform + } + required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -48,14 +87,14 @@ class Device: Identifiable, Codable, ObservableObject, Hashable { friendlyName = try container.decode(String.self, forKey: .friendlyName) creationTime = try container.decode(Int.self, forKey: .creationTime) version = try container.decodeIfPresent(String.self, forKey: .version) ?? "Unknown" - platform = try container.decodeIfPresent(String.self, forKey: .platform) ?? "Unknown" - backend = try container.decodeIfPresent(String.self, forKey: .backend) ?? "Unknown" - + platform = try container.decodeIfPresent(CiderClient.Framework.self, forKey: .platform) ?? .unknown + backend = try container.decodeIfPresent(CiderClient.Framework.self, forKey: .backend) ?? .unknown + // For connectionMethod, use "lan" as default if not present - connectionMethod = try container.decodeIfPresent(String.self, forKey: .connectionMethod) ?? "lan" - + connectionMethod = try container.decodeIfPresent(ConnectionMethod.self, forKey: .connectionMethod) ?? .lan + isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? false - os = try container.decodeIfPresent(String.self, forKey: .os) + os = try container.decodeIfPresent(CiderClient.Platform.self, forKey: .os) } func encode(to encoder: Encoder) throws { @@ -83,25 +122,40 @@ class Device: Identifiable, Codable, ObservableObject, Hashable { var fullAddress: String { switch connectionMethod { - case "tunnel": - return "https://\(host)" - default: // "lan" or any other value - return "http://\(host):10767" + case .tunnel: + return "https://\(host)" + default: // "lan" or any other value + return "http://\(host):10767" } } } +extension String { + static let defaultPort: String = "10767" +} + +extension Int { + static let defaultPort: Int = 10767 +} + +// {"address":"192.168.1.15","token":"jf69gnaglxv68923ire62lfo","method":"lan","initialData":{"version":"400","platform":"genten","os":"darwin"}} + extension Device { - func runAppleMusicAPI(path: String) async throws -> Any { + func runAppleMusicAPI(path: String, returnContent: Bool = true) async throws -> Any { do { - let data = try await sendRequest(endpoint: "amapi/run-v3", method: "POST", body: ["path": path]) + let data = try await sendRequest(endpoint: "amapi/run-v3", method: "POST", body: ["path": path], version: "v1") + print(data) if let jsonDict = data as? [String: Any], let data = jsonDict["data"] as? [String: Any] { + guard returnContent else { return jsonDict } + if let subdata = data["data"] as? [String: Any] { // object return subdata } else if let subdata = data["data"] as? [[String: Any]] { // array of objects return subdata } - } + } else if let jsonDict = data as? [String: Any], let arrayData = jsonDict["data"] as? [[String: Any]] { + return arrayData + } return data } catch { @@ -110,14 +164,17 @@ extension Device { } } - func sendRequest(endpoint: String, method: String = "GET", body: [String: Any]? = nil) async throws -> Any { - let baseURL = self.connectionMethod == "tunnel" - ? "https://\(self.host)" - : "http://\(self.host):10767" - guard let url = URL(string: "\(baseURL)/api/v1/\(endpoint)") else { + func sendRequest(endpoint: String, method: String = "GET", body: [String: Any]? = nil, queries: [URLQueryItem] = [], version: String? = nil) async throws -> Any { + let clientVersion: String = self.useV2 ? "v2" : "v1" + let v: String = version ?? clientVersion + + let baseURL = self.connectionMethod == .tunnel ? "https://\(self.host)" : "http://\(self.host):10767" + guard var url = URL(string: "\(baseURL)/api/\(v)/\(endpoint)") else { throw NetworkError.invalidURL } + url.append(queryItems: queries) + print("Sending request to: \(url.absoluteString)") var request = URLRequest(url: url) @@ -131,7 +188,7 @@ extension Device { } let (data, response) = try await URLSession.shared.data(for: request) - // print("Response raw: \(String(data: data, encoding: .utf8) ?? "[No data]")") + print("Response raw: \(String(data: data, encoding: .utf8) ?? "[No data]")") guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.invalidResponse @@ -145,11 +202,43 @@ extension Device { do { let json = try JSONSerialization.jsonObject(with: data, options: []) - // print("Received data: \(json)") - return json + if self.useV2 { + let jsonData = (json as! [String: Any])["data"]! + print(jsonData) + return jsonData + } else { +// print("Received data: \(json)") + return json + } } catch { print(error) throw NetworkError.decodingError } } + + func sendForData(endpoint: String, method: String = "GET", body: [String: Any]? = nil, queries: [URLQueryItem] = [], version: String? = nil) async throws -> APIResponse { + guard let res: [String: Any] = try await self.sendRequest( + endpoint: endpoint, + method: method, + body: body, + queries: queries, + version: version + ) as? [String: Any] else { throw NetworkError.invalidResponse } + + let data: Data = try JSONSerialization.data(withJSONObject: res) + return try JSONDecoder().decode(APIResponse.self, from: data) + } + + func sendForMetaData(endpoint: String, method: String = "GET", body: [String: Any]? = nil, queries: [URLQueryItem] = [], version: String? = nil) async throws -> APIResponse { + guard let res: [String: Any] = try await self.sendRequest( + endpoint: endpoint, + method: method, + body: body, + queries: queries, + version: version + ) as? [String: Any] else { throw NetworkError.invalidResponse } + + let data: Data = try JSONSerialization.data(withJSONObject: res) + return try JSONDecoder().decode(APIResponse.self, from: data) + } } diff --git a/Cider Remote/Data/ColorSchemeManager.swift b/Cider Remote/Data/ColorSchemeManager.swift deleted file mode 100644 index be3668d..0000000 --- a/Cider Remote/Data/ColorSchemeManager.swift +++ /dev/null @@ -1,76 +0,0 @@ -// Made by Lumaa - -import SwiftUI - -class ColorSchemeManager: ObservableObject { - @Published var primaryColor: Color = Color.cider - @Published var secondaryColor: Color = .white - @Published var backgroundColor: Color = .black.opacity(0.8) - @Published var dominantColors: [Color] = [] - @AppStorage("useAdaptiveColors") var useAdaptiveColors: Bool = true { - didSet { - if useAdaptiveColors { - applyColors() - } else { - resetToDefaultColors() - } - } - } - - private var lastImageColors: [Color] = [] - private var lastImage: UIImage? - private var currentColorScheme: ColorScheme = .light - - func updateColorScheme(_ colorScheme: ColorScheme) { - currentColorScheme = colorScheme - applyColors() - } - - func updateColors(from image: UIImage) { - lastImage = image - let colors = image.dominantColors(count: 5) - lastImageColors = colors - applyColors() - } - - func applyColors() { - if useAdaptiveColors && !lastImageColors.isEmpty { - dominantColors = lastImageColors - primaryColor = lastImageColors.first ?? Color(hex: "#fa2f48") - secondaryColor = lastImageColors.count > 1 ? lastImageColors[1] : .white - backgroundColor = (lastImageColors.count > 2 ? lastImageColors[2] : .black).opacity(0.8) - } else { - resetToDefaultColors() - } - updateGlobalAppearance() - } - - func resetToDefaultColors() { - primaryColor = Color(hex: "#fa2f48") - secondaryColor = lightDarkColor - backgroundColor = .black.opacity(0.8) - dominantColors = [] - updateGlobalAppearance() - } - - func reapplyAdaptiveColors() { - if useAdaptiveColors, let lastImage = lastImage { - updateColors(from: lastImage) - } else { - resetToDefaultColors() - } - } - - private func updateGlobalAppearance() { - DispatchQueue.main.async { - UITabBar.appearance().tintColor = UIColor(self.primaryColor) - UINavigationBar.appearance().tintColor = UIColor(self.secondaryColor) - UISlider.appearance().minimumTrackTintColor = UIColor(self.primaryColor) - UISlider.appearance().maximumTrackTintColor = UIColor(self.secondaryColor.opacity(0.5)) - } - } - - private var lightDarkColor: Color { - currentColorScheme == .dark ? .white : .black - } -} diff --git a/Cider Remote/Data/ConnectionInfo.swift b/Cider Remote/Data/ConnectionInfo.swift deleted file mode 100644 index 4142994..0000000 --- a/Cider Remote/Data/ConnectionInfo.swift +++ /dev/null @@ -1,34 +0,0 @@ -// Made by Lumaa - -import Foundation - -struct ConnectionInfo: Codable { - let address: String - let token: String - let method: ConnectionMethod - let initialData: InitialData -} - -enum ConnectionMethod: String, Codable { - case lan - case tunnel -} - -struct InitialData: Codable { - let version: String - let platform: String - let os: String - - // We'll use CodingKeys to handle the missing 'arch' field - enum CodingKeys: String, CodingKey { - case version, platform, os - } - - // Custom initializer to set a default value for 'arch' - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - version = try container.decode(String.self, forKey: .version) - platform = try container.decode(String.self, forKey: .platform) - os = try container.decode(String.self, forKey: .os) - } -} diff --git a/Cider Remote/Data/DeviceManager.swift b/Cider Remote/Data/DeviceManager.swift index 3057061..65205f3 100644 --- a/Cider Remote/Data/DeviceManager.swift +++ b/Cider Remote/Data/DeviceManager.swift @@ -17,23 +17,31 @@ class DeviceManager: ObservableObject { } func add(_ device: Device) { - self.devices.append(device) - self.saveDevices() + DispatchQueue.main.async { + self.devices.append(device) + self.saveDevices() + } } func set(_ device: Device, at: Int) { - self.devices[at] = device - self.saveDevices() + DispatchQueue.main.async { + self.devices[at] = device + self.saveDevices() + } } func remove(_ device: Device) { - self.devices.removeAll { $0.id == device.id } - self.saveDevices() + DispatchQueue.main.async { + self.devices.removeAll { $0.id == device.id } + self.saveDevices() + } } func clear() { - self.devices.removeAll() - self.saveDevices() + DispatchQueue.main.async { + self.devices.removeAll() + self.saveDevices() + } } func checkDeviceActivity(_ device: Device) async { @@ -47,10 +55,9 @@ class DeviceManager: ObservableObject { } } - guard let url = URL(string: "\(device.fullAddress)/api/v1/playback/active") else { - print("Invalid URL for device: \(device.friendlyName)") - return - } + let v2: Bool = CiderClient(device: device).useV2 + let url: URL? = v2 ? URL(string: "\(device.fullAddress)/api/v2/client/info") : URL(string: "\(device.fullAddress)/api/v1/playback/active") + guard let url else { print("[DeviceManager+checkDeviceActivity] Couldn't fetch device version and/or API path (\(device.friendlyName)"); return } var request = URLRequest(url: url) request.addValue(device.token, forHTTPHeaderField: "apptoken") @@ -83,7 +90,7 @@ class DeviceManager: ObservableObject { return decodedDevices } catch { print("Error decoding saved devices: \(error)") - UserDefaults.standard.set(nil, forKey: "savedDevices") // remove to avoid corruption + UserDefaults.standard.set(nil, forKey: "savedDevices") // remove devices to avoid corruption return [] } } diff --git a/Cider Remote/Data/MediaPlayer/LiveActivityManager.swift b/Cider Remote/Data/MediaPlayer/LiveActivityManager.swift index 461192f..65a352c 100644 --- a/Cider Remote/Data/MediaPlayer/LiveActivityManager.swift +++ b/Cider Remote/Data/MediaPlayer/LiveActivityManager.swift @@ -26,8 +26,12 @@ class LiveActivityManager { return } - do { - let cont: NowPlayingLiveActivity.NowPlayingAttributes.ContentState = .init(trackInfo: track) + Task { + let display: DisplayingTrack = Self.DisplayingTrack(from: track) + let cont: NowPlayingLiveActivity.NowPlayingAttributes.ContentState = .init( + trackInfo: display + ) + if #available(iOS 16.2, *) { self.lastActivity = try Activity .request( @@ -38,13 +42,12 @@ class LiveActivityManager { self.lastActivity = try Activity.request(attributes: .init(device: device), contentState: cont) } print("STARTED LIVE ACTIVITY") - } catch { - print("Error while starting Live Activity: \(error)") } } func updateActivity(with content: NowPlayingLiveActivity.NowPlayingAttributes.ContentState) async { guard let activity else { return } + await activity .update( .init(state: content, staleDate: nil), @@ -59,9 +62,14 @@ class LiveActivityManager { func updateActivity(with track: Track) async { guard let activity else { return } + + let display: DisplayingTrack = Self.DisplayingTrack(from: track) + let state: NowPlayingLiveActivity.NowPlayingAttributes.ContentState = .init( + trackInfo: display + ) + await activity - .update( - .init(state: .init(trackInfo: track), staleDate: nil), + .update(.init(state: state, staleDate: nil), alertConfiguration: alertLiveActivity ? .init( title: "Cider Remote", body: "Now Playing: \(track.title) by \(track.artist)", @@ -83,4 +91,38 @@ class LiveActivityManager { print("STOPPED LIVE ACTIVITY") } } + + struct DisplayingTrack: Identifiable, Codable, Equatable { + let id: String + let title: String + let artist: String + let artworkURL: URL? + + init(title: String, artist: String, artworkURL: URL? = nil) { + self.id = UUID().uuidString + self.title = title + self.artist = artist + self.artworkURL = artworkURL + } + + init(from track: Track) { + self.id = track.id + self.title = track.title + self.artist = track.artist + self.artworkURL = URL(string: track.artwork) + } + + func getArtworkData() async -> Data? { + guard let artworkURL else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: artworkURL) + return data + } catch { + print("Error loading image: \(error)") + } + return nil + } + } } + diff --git a/Cider Remote/Data/MediaPlayer/MusicPlayerViewModel.swift b/Cider Remote/Data/MediaPlayer/MusicPlayerViewModel.swift deleted file mode 100644 index b497fe7..0000000 --- a/Cider Remote/Data/MediaPlayer/MusicPlayerViewModel.swift +++ /dev/null @@ -1,825 +0,0 @@ -// Made by Lumaa - -import UIKit -import WidgetKit -import SocketIO -import Combine - -@MainActor -class MusicPlayerViewModel: ObservableObject { - let device: Device - - /// The "Now Playing" activity - @Published var nowPlaying: NowPlaying? = nil - /// Everything Live Activity for the playing song - @Published var liveActivity: LiveActivityManager = LiveActivityManager.shared - @Published var queueItems: [Track] = [] - @Published var sourceQueue: Queue? - @Published var currentTrack: Track? - @Published var trackUrl: URL? = nil - @Published var isPlaying: Bool = false - @Published var currentTime: Double = 0 - @Published var duration: Double = 0 - @Published var volume: Double = 0.5 - @Published var isLiked: Bool = false - @Published var isInLibrary: Bool = false - @Published var needsColorUpdate: Bool = false - @Published var showLibraryPopup = false - @Published var showFavoritePopup = false - @Published var showingLyrics = false - @Published var showingQueue = false - @Published var errorMessage: String? - @Published var lyrics: [LyricLine]? = nil - @Published var lyricsProvider: Parser.LyricProvider? = nil - - private var manager: SocketManager? - private var socket: SocketIOClient? - private var cancellables = Set() - - private var volumeDebouncer: Debouncer? - private var seekDebouncer: Debouncer? - private var imageCache = NSCache() - private var lyricCache: [String: [LyricLine]] = [:] - private var storefrontCache: String? = nil - - private var colorSchemeManager: ColorSchemeManager - - init(device: Device, colorSchemeManager: ColorSchemeManager = .init()) { - self.device = device - self.colorSchemeManager = colorSchemeManager - self.volumeDebouncer = Debouncer(delay: 0.3) { [weak self] in - guard let self = self else { return } - Task { - await self.adjustVolumeDebounced() - } - } - self.seekDebouncer = Debouncer(delay: 0.3) { [weak self] in - guard let self = self else { return } - Task { - await self.seekToTimeDebounced() - } - } - self.liveActivity.device = device - } - - func startListening() { - print("Attempting to connect to socket") - let socketURL = device.connectionMethod == "tunnel" - ? "https://\(device.host)" - : "http://\(device.host):10767" - manager = SocketManager(socketURL: URL(string: socketURL)!, config: [.log(false), .compress]) - socket = manager?.defaultSocket - - setupSocketEventHandlers() - socket?.connect() - } - - private func setupSocketEventHandlers() { - socket?.on(clientEvent: .connect) { [weak self] data, ack in - print("Socket connected") - - Task { - await self?.getCurrentTrack() - self?.nowPlaying = .init(viewModel: self!) - self?.nowPlaying?.setNowPlayingInfo() - self?.nowPlaying?.setNowPlayingPlaybackInfo() - - if let currentTrack = self?.currentTrack { - self!.liveActivity.startActivity(using: currentTrack) - } - - AppDelegate.shared.scheduleAppRefresh() - if #available(iOS 18.0, *) { - ControlCenter.shared.reloadControls(ofKind: "sh.cider.CiderRemote.PlayPauseControl") - } - } - } - - socket?.on("API:Playback") { [weak self] data, ack in - guard let self = self, - let playbackData = data[0] as? [String: Any], - let type = playbackData["type"] as? String else { - print("Invalid playback data received") - return - } - -// print("Received playback event: \(type)") - - DispatchQueue.main.async { - switch type { - case "playbackStatus.nowPlayingStatusDidChange": - if let info = playbackData["data"] as? [String: Any] { - self.setAdaptiveData(info) - } - case "playbackStatus.nowPlayingItemDidChange": - if let info = playbackData["data"] as? [String: Any] { - self.updateTrackInfo(info) - if let currentTrack = self.currentTrack { - self.liveActivity.startActivity(using: currentTrack) - } - } - case "playbackStatus.playbackStateDidChange": - if let info = playbackData["data"] as? [String: Any] { - self.setPlaybackStatus(info) - } - case "playbackStatus.playbackTimeDidChange": - if let info = playbackData["data"] as? [String: Any], - let isPlaying = info["isPlaying"] as? Int, - let currentPlaybackTime = info["currentPlaybackTime"] as? Double { - self.isPlaying = isPlaying == 1 ? true : false - self.currentTime = currentPlaybackTime - self.nowPlaying?.setNowPlayingPlaybackInfo() - } - default: - print("Unhandled event type: \(type)") - } - } - } - } - - func stopListening() { - print("Disconnecting socket") - socket?.disconnect() - } - - func initializePlayer() async { - await getCurrentTrack() - await getCurrentVolume() - await fetchQueueItems() - } - - func refreshCurrentTrack() { - Task { - await getCurrentTrack() - await getCurrentVolume() - - if let currentTrack, queueItems.first?.id == currentTrack.id { - queueItems.removeFirst() - } else { - await fetchQueueItems() - } - - reconnectSocketIfNeeded() - } - } - - private func reconnectSocketIfNeeded() { - if socket?.status != .connected { - print("Socket not connected, reconnecting...") - socket?.connect() - } - } - - func fetchQueueItems() async { - guard let currentTrack else { print("[QUEUE] Need currentTrack to get current queue"); return } - - print("Fetching current queue") - do { - let data = try await sendRequest(endpoint: "playback/queue") - if let jsonDict = data as? [[String: Any]] { - let attributes: [[String : Any]] = jsonDict.compactMap { $0["attributes"] as? [String : Any] } - let queue: [Track] = attributes.map { getTrack(using: $0) } - - var queueItem: Queue = .init(tracks: queue) - queueItem.defineCurrent(track: currentTrack) - - self.sourceQueue = queueItem // after defining offset - self.queueItems = queueItem.tracks - } - } catch { - handleError(error) - } - } - - func getCurrentTrack() async { - print("Fetching current track") - do { - let data = try await sendRequest(endpoint: "playback/now-playing", method: "GET") - if let jsonDict = data as? [String: Any], - let info = jsonDict["info"] as? [String: Any] { - updateTrackInfo(info, alt: true) - nowPlaying?.setNowPlayingInfo() - } else { - throw NetworkError.decodingError - } - } catch { - handleError(error) - } - } - - func fetchAllLyrics() async { - let success: Bool = await self.fetchLyricsAm() // apple music - if !success { - _ = await self.fetchLyricsMxm() // musixmatch - } - } - - /// Returns true if the lyrics were found and fetched - func fetchLyricsMxm() async -> Bool { - guard let currentTrack else { return false } - - print("Current track ID: \(currentTrack.id)") - - if let cachedLyrics = lyricCache[currentTrack.id] { - print("Using cached lyrics for track: \(currentTrack.id)") - self.lyricsProvider = .cache - self.lyrics = cachedLyrics - return true - } - - self.lyrics = nil - guard let lyricsUrl = URL(string: "https://rise.cider.sh/api/v1/lyrics/mxm") else { return false } - - do { - print("Fetching lyrics ONLINE for track: \(currentTrack.id)") - - let lyricReq: Track.RequestLyrics = .init(track: currentTrack) - let encoder: JSONEncoder = .init() - let body: Data = try encoder.encode(lyricReq) - - var req = URLRequest(url: lyricsUrl, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: .infinity) - req.addValue("application/json", forHTTPHeaderField: "Content-Type") - - req.httpMethod = "POST" - req.httpBody = body - - let (data, response) = try await URLSession.shared.data(for: req) - - if let http = response as? HTTPURLResponse, http.statusCode == 200 { - let decoder: JSONDecoder = .init() - print(String(data: data, encoding: .utf8) ?? "wtf?") - let mxm = try decoder.decode(Track.MxmLyrics.self, from: data) - - let lines = mxm.decodeHtml() - print("Parsed \(lines.count) lyric lines") - if lines.count > 0 { - DispatchQueue.main.async { - self.lyricsProvider = .mxm - self.lyrics = lines - self.lyricCache[currentTrack.id] = self.lyrics - } - return true - } - } else { - self.lyrics = [] - throw NetworkError.serverError("Couldn't reach server") - } - } catch { - self.lyrics = [] - print(error) - handleError(error) - } - return false - } - - /// Returns true if the lyrics were found and fetched - func fetchLyricsAm() async -> Bool { - guard let currentTrack else { return false } - - print("Current track ID: \(currentTrack.id)") - - if let cachedLyrics = lyricCache[currentTrack.id] { - print("Using cached lyrics for track: \(currentTrack.id)") - self.lyricsProvider = .cache - self.lyrics = cachedLyrics - return true - } - - do { - guard let storefront = await self.getStorefront() else { return false } - - print("Fetching lyrics FROM CLIENT for track: \(currentTrack.id)") - let path: String = "/v1/catalog/\(storefront)/songs/\(currentTrack.catalogId)/lyrics?l=en-US&platform=web&art[url]=f" - let data = try await sendRequest(endpoint: "amapi/run-v3", method: "POST", body: ["path": path]) - - print(data) - if let jsonDict = data as? [String: Any], let data = jsonDict["data"] as? [String: Any], let subdata = data["data"] as? [[String: Any]], let lyricsData = subdata[0]["attributes"] as? [String: Any] { - guard let lyricsXml = lyricsData["ttml"] as? String, let data = lyricsXml.data(using: .utf8) else { - print("-- After fetch decoding error --") - throw NetworkError.decodingError - } - - let xmlParser = XMLParser(data: data) - let ttmlParser = Parser(provider: .am) - xmlParser.delegate = ttmlParser - xmlParser.parse() - - self.lyricsProvider = .am - self.lyrics = ttmlParser.lyrics - self.lyricCache[currentTrack.id] = self.lyrics - return true - } else { - throw NetworkError.invalidResponse - } - } catch { - print("Error fetching lyrics: \(error)") - handleError(error) - } - return false - } - - func getStorefront() async -> String? { - do { - let data = try await sendRequest(endpoint: "amapi/run-v3", method: "POST", body: ["path": "/v1/me/storefront?limit=1"]) - print(data) - if let jsonDict = data as? [String: Any], let data = jsonDict["data"] as? [String: Any], let subdata = data["data"] as? [[String: Any]], let storefrontId = subdata[0]["id"] as? String { - self.storefrontCache = storefrontId - return storefrontId - } - } catch { - print("Error fetching storefront: \(error)") - handleError(error) - } - - return nil - } - - func getTrackUrl() async -> URL? { - guard let currentTrack else { return nil } - var storefront: String? = self.storefrontCache - if self.storefrontCache == nil, let newStorefront = await self.getStorefront() { - storefront = newStorefront - } - - if let storefront { - return URL(string: "https://music.apple.com/\(storefront)/song/\(currentTrack.catalogId)") - } else { - return nil - } - } - - private func setPlaybackStatus(_ info: [String: Any]) { - print("Setting playback status: \(info)") - if let state = info["state"] as? String { - self.isPlaying = (state == "playing") - } - } - - private func setAdaptiveData(_ info: [String: Any]) { - print("Setting adaptive data: \(info)") - DispatchQueue.main.async { - if let isLiked = info["inFavorites"] as? Int, isLiked == 1 { - self.isLiked = true - } else { - self.isLiked = false - } - - if let isInLibrary = info["inLibrary"] as? Int, isInLibrary == 1 { - self.isInLibrary = true - } else { - self.isInLibrary = false - } - - if let currentPlaybackTime = info["currentPlaybackTime"] as? Double { - self.currentTime = currentPlaybackTime - } - if let durationInMillis = info["durationInMillis"] as? Double { - self.duration = durationInMillis / 1000 - } - } - } - - private func updateTrackInfo(_ info: [String: Any], alt: Bool = false) { - print("Updating track info: \(info)") - - // Extract ID from playParams - var id: String? - var amId: String? - - if let playParams = info["playParams"] as? [String: Any] { - id = playParams["id"] as? String - amId = playParams["catalogId"] as? String - } - - let title = info["name"] as? String ?? "" - let artist = info["artistName"] as? String ?? "" - let album = info["albumName"] as? String ?? "" - let duration = info["durationInMillis"] as? Double ?? 0 - - if let artwork = info["artwork"] as? [String: Any], - var artworkUrl = artwork["url"] as? String { - // Replace placeholders in artwork URL - artworkUrl = artworkUrl.replacingOccurrences(of: "{w}", with: "1024") - artworkUrl = artworkUrl.replacingOccurrences(of: "{h}", with: "1024") - - let data: Data? = nil - -// Task { -// let image = await self.loadImage(for: URL(string: artworkUrl)!) -// if let imgData = image?.pngData() { -// data = imgData -// } -// } - - let newTrack = Track(id: id ?? "", - catalogId: amId ?? "", - title: title, - artist: artist, - album: album, - artwork: artworkUrl, - duration: duration / 1000, - artworkData: data ?? Data() - ) - - if self.currentTrack != newTrack { - self.currentTrack = newTrack - self.needsColorUpdate = self.colorSchemeManager.useAdaptiveColors - self.lyrics = [] // Clear lyrics when track changes - Task { - await self.updateQueue(newTrack: newTrack) - await self.fetchAllLyrics() -// await self.fetchLyrics() // Fetch lyrics for the new track - } - } - } - - if alt { - self.isLiked = info["inFavorites"] as? Bool ?? false - self.isInLibrary = info["inLibrary"] as? Bool ?? false - } - self.duration = duration / 1000 - - if let currentPlaybackTime = info["currentPlaybackTime"] as? Double { - self.currentTime = currentPlaybackTime - } - - self.isPlaying = false - - print("Updated currentTrack: \(String(describing: self.currentTrack))") - print("isPlaying: \(self.isPlaying)") - } - - private func updateQueue(newTrack: Track) async { - print("[QUEUE] smart update") - if newTrack.id == queueItems.first?.id { // newTrack is the next playing song in the queue - queueItems = Array(queueItems.dropFirst()) - } else { - await fetchQueueItems() - } - } - - func playFromQueue(_ track: Track) async { - guard let sourceQueue, let index = sourceQueue.tracks.firstIndex(where: { $0.id == track.id }) else { return } - print("[QUEUE] play from queue") - - do { - _ = try await sendRequest( - endpoint: "playback/queue/change-to-index", - method: "POST", - body: ["index" : index + sourceQueue.offset] - ) - await updateQueue(newTrack: track) - } catch { - handleError(error) - } - } - - func moveQueue(from startIndex: Int, to destinationIndex: Int) async { - guard let sourceQueue, startIndex != destinationIndex else { return } - do { - _ = try await sendRequest(endpoint: "playback/queue/move-to-position", - method: "POST", - body: ["startIndex" : startIndex + sourceQueue.offset, "destinationIndex": destinationIndex + sourceQueue.offset] - ) - try? await Task.sleep(nanoseconds: 500_000_000) // we don't wait, then the *fetchQueueItems* will error - await fetchQueueItems() - } catch { - handleError(error) - } - } - - func removeQueue(index: Int) async { - guard let sourceQueue else { return } - do { - _ = try await sendRequest(endpoint: "playback/queue/remove-by-index", - method: "POST", - body: ["index": index + sourceQueue.offset] - ) - } catch { - handleError(error) - } - } - - private func getTrack(using info: [String: Any]) -> Track { - // Extract ID from playParams - var id: String? - var amId: String? - - if let playParams = info["playParams"] as? [String: Any] { - id = playParams["id"] as? String - amId = playParams["catalogId"] as? String - } - - let title = info["name"] as? String ?? "" - let artist = info["artistName"] as? String ?? "" - let album = info["albumName"] as? String ?? "" - let duration = info["durationInMillis"] as? Double ?? 0 - - if let artwork = info["artwork"] as? [String: Any], - var artworkUrl = artwork["url"] as? String { - // Replace placeholders in artwork URL - artworkUrl = artworkUrl.replacingOccurrences(of: "{w}", with: "1024") - artworkUrl = artworkUrl.replacingOccurrences(of: "{h}", with: "1024") - - let data: Data? = nil - - return Track(id: id ?? "", - catalogId: amId ?? "", - title: title, - artist: artist, - album: album, - artwork: artworkUrl, - duration: duration / 1000, - artworkData: data ?? Data() - ) - } else { - return Track(id: id ?? "", - catalogId: amId ?? "", - title: title, - artist: artist, - album: album, - artwork: "", - duration: duration / 1000, - artworkData: Data() - ) - } - } - - func getCurrentVolume() async { - print("Fetching current volume") - do { - let data = try await sendRequest(endpoint: "playback/volume", method: "GET") - if let jsonDict = data as? [String: Any], - let volume = jsonDict["volume"] as? Double { - self.volume = volume - print("Current volume: \(volume)") - } else { - throw NetworkError.decodingError - } - } catch { - handleError(error) - } - } - - func togglePlayPause() async { - print("Toggling play/pause") - isPlaying.toggle() // Immediately update UI - do { - _ = try await sendRequest(endpoint: "playback/playpause", method: "POST") - // Server confirmed the change, no need to update UI again - if #available(iOS 18.0, *) { - ControlCenter.shared.reloadControls(ofKind: "sh.cider.CiderRemote.PlayPauseControl") - } - } catch { - // Revert the UI change if the server request failed - isPlaying.toggle() - handleError(error) - } - } - - func nextTrack() async { - print("Skipping to next track") - do { - _ = try await sendRequest(endpoint: "playback/next", method: "POST") - await getCurrentTrack() // Refresh track info after skipping - } catch { - handleError(error) - } - } - - func previousTrack() async { - print("Going to previous track") - do { - _ = try await sendRequest(endpoint: "playback/previous", method: "POST") - await getCurrentTrack() // Refresh track info after going to previous track - } catch { - handleError(error) - } - } - - func seekToTime() async { - print("Seeking to time: \(currentTime)") - do { - _ = try await sendRequest(endpoint: "playback/seek", method: "POST", body: ["position": currentTime]) - } catch { - handleError(error) - } - } - - func toggleLike() async { - let newRating = isLiked ? 0 : 1 - print("Toggling like status to: \(newRating)") - do { - _ = try await sendRequest(endpoint: "playback/set-rating", method: "POST", body: ["rating": newRating]) - isLiked.toggle() - showFavoritePopup = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.showFavoritePopup = false - } - } catch { - handleError(error) - } - } - - func toggleAddToLibrary() async { - if !isInLibrary { - print("Adding to library") - do { - _ = try await sendRequest(endpoint: "playback/add-to-library", method: "POST") - isInLibrary = true - showLibraryPopup = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.showLibraryPopup = false - } - } catch { - handleError(error) - } - } - } - - func adjustVolume() { - volumeDebouncer?.call() - } - - private func adjustVolumeDebounced() async { - print("Adjusting volume to: \(volume)") - do { - let data = try await sendRequest(endpoint: "playback/volume", method: "POST", body: ["volume": volume]) - if let jsonDict = data as? [String: Any], - let newVolume = jsonDict["volume"] as? Double { - self.volume = newVolume - print("Volume adjusted to: \(newVolume)") - } else { - throw NetworkError.decodingError - } - } catch { - handleError(error) - } - } - - func searchSong(query: String) async -> [Track] { - print("Searching for: \(query)") - do { - let data = try await sendRequest(endpoint: "amapi/run-v3", method: "POST", body: ["path": "/v1/catalog/us/search?term=\(query)&types=songs"]) - - if let jsonDict = data as? [String: Any], let data = jsonDict["data"] as? [String: Any], let _results = data["results"] as? [String: Any] { - guard let songs = _results["songs"] as? [String: Any], let results = songs["data"] as? [[String: Any]] else { - print("Couldn't decrypt stuff") - return [] - } - - var searchResults: [Track] = [] - for result in results { - guard let attributes = result["attributes"] as? [String: Any], let artwork = attributes["artwork"] as? [String: Any] else { - print("Oopsy, couldn't add search result") - return [] - } - - searchResults - .append( - .init( - id: attributes["isrc"] as! String, - catalogId: attributes["isrc"] as! String, - title: attributes["name"] as! String, - artist: attributes["artistName"] as! String, - album: attributes["albumName"] as! String, - artwork: String((artwork["url"] as! String).replacing(/{(w|h)}/, with: "500")), - duration: (Double(attributes["durationInMillis"] as? String ?? "0") ?? 0.0) / 1000, - artworkData: Data(), - songHref: (result["href"] as! String) - ) - ) - } - - print("[searchSong] RETURNING \(searchResults.count) results") - return searchResults - } else { - throw NetworkError.decodingError - } - } catch { - handleError(error) - } - - return [] - } - - func playHref(href: String) async { - print("Playing song using HREF") - - do { - _ = try await sendRequest(endpoint: "playback/play-item-href", method: "POST", body: ["href": href]) - } catch { - handleError(error) - } - } - - func playTrackHref(_ track: Track) async { - guard let href = track.songHref else { fatalError("No HREF in this Track") } - print("Playing TRACK song using HREF") - - do { - _ = try await sendRequest(endpoint: "playback/play-item-href", method: "POST", body: ["href": href]) - } catch { - handleError(error) - } - } - - func seekToTime() { - seekDebouncer?.call() - } - - private func seekToTimeDebounced() async { - print("Seeking to time: \(currentTime)") - do { - _ = try await sendRequest(endpoint: "playback/seek", method: "POST", body: ["position": currentTime]) - } catch { - handleError(error) - } - } - - func loadImage(for url: URL) async -> UIImage? { - // Check cache first - if let cachedImage = imageCache.object(forKey: url.absoluteString as NSString) { - return cachedImage - } - - do { - let (data, _) = try await URLSession.shared.data(from: url) - if let image = UIImage(data: data) { - // Cache the image - imageCache.setObject(image, forKey: url.absoluteString as NSString) - return image - } - } catch { - print("Error loading image: \(error)") - } - return nil - } - - func loadArtwork() async -> UIImage? { - guard let artwork = self.currentTrack?.artwork else { return nil } - let url: URL = URL(string: artwork)! - return await self.loadImage(for: url) - } - - private func sendRequest(endpoint: String, method: String = "GET", body: [String: Any]? = nil) async throws -> Any { - let baseURL = device.connectionMethod == "tunnel" - ? "https://\(device.host)" - : "http://\(device.host):10767" - guard let url = URL(string: "\(baseURL)/api/v1/\(endpoint)") else { - throw NetworkError.invalidURL - } - - print("Sending request to: \(url.absoluteString)") - - var request = URLRequest(url: url) - request.httpMethod = method - request.addValue(device.token, forHTTPHeaderField: "apptoken") - - if let body = body { - request.httpBody = try? JSONSerialization.data(withJSONObject: body) - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - print("Request body: \(body)") - } - - let (data, response) = try await URLSession.shared.data(for: request) -// print("Response raw: \(String(data: data, encoding: .utf8) ?? "[No data]")") - - guard let httpResponse = response as? HTTPURLResponse else { - throw NetworkError.invalidResponse - } - - print("Response status code: \(httpResponse.statusCode)") - - guard (200...299).contains(httpResponse.statusCode) else { - throw NetworkError.serverError("Server responded with status code \(httpResponse.statusCode)") - } - - do { - let json = try JSONSerialization.jsonObject(with: data, options: []) -// print("Received data: \(json)") - return json - } catch { - print(error) - throw NetworkError.decodingError - } - } - - private func handleError(_ error: Error) { - if let networkError = error as? NetworkError { - switch networkError { - case .invalidURL: - errorMessage = "Invalid URL" - case .invalidResponse: - errorMessage = "Invalid response from server" - case .decodingError: - errorMessage = "Error decoding data" - case .serverError(let message): - errorMessage = "Server error: \(message)" - } - } else { - errorMessage = error.localizedDescription - } - print("Error: \(errorMessage ?? "Unknown error")") - } -} diff --git a/Cider Remote/Data/MediaPlayer/NowPlaying.swift b/Cider Remote/Data/MediaPlayer/NowPlaying.swift deleted file mode 100644 index c59fca5..0000000 --- a/Cider Remote/Data/MediaPlayer/NowPlaying.swift +++ /dev/null @@ -1,184 +0,0 @@ -// Made by Lumaa - -import Foundation -import SwiftUI -import UIKit -import MediaPlayer - -// MARK: As of right now, any of this works UNLESS Apple allows developers to set NowPlaying info without playing audio... - -enum NowPlayableCommand: CaseIterable { - case play, pause, togglePlayPause, - nextTrack, previousTrack, - changePlaybackRate, changePlaybackPosition, - skipForward, skipBackward, - seekForward, seekBackward -} - -// MARK: - MPRemoteCommand - -extension NowPlayableCommand { - var remoteCommand: MPRemoteCommand { - let commandCenter = MPRemoteCommandCenter.shared() - - switch self { - case .play: - return commandCenter.playCommand - case .pause: - return commandCenter.pauseCommand - case .togglePlayPause: - return commandCenter.togglePlayPauseCommand - case .nextTrack: - return commandCenter.nextTrackCommand - case .previousTrack: - return commandCenter.previousTrackCommand - case .changePlaybackRate: - return commandCenter.changePlaybackRateCommand - case .changePlaybackPosition: - return commandCenter.changePlaybackPositionCommand - case .skipForward: - return commandCenter.skipForwardCommand - case .skipBackward: - return commandCenter.skipBackwardCommand - - case .seekForward: - return commandCenter.seekForwardCommand - case .seekBackward: - return commandCenter.seekBackwardCommand - } - } - // Adding Handler and accepting an escaping closure for event handling for a praticular remote command - func addHandler(remoteCommandHandler: @escaping (NowPlayableCommand, MPRemoteCommandEvent)->(MPRemoteCommandHandlerStatus)) { - switch self { - case .skipBackward: - MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [10.0] - - case .skipForward: - MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [10.0] - - default: - break - } - self.remoteCommand.addTarget { event in - remoteCommandHandler(self,event) - } - } - - func removeHandler() { - self.remoteCommand.removeTarget(self) - } -} - -protocol NowPlayable { - var supportedNowPlayableCommands: [NowPlayableCommand] { get } - - func configureRemoteCommands(remoteCommandHandler: @escaping (NowPlayableCommand, MPRemoteCommandEvent)->(MPRemoteCommandHandlerStatus)) - func handleRemoteCommand(for type: NowPlayableCommand, with event: MPRemoteCommandEvent) async-> MPRemoteCommandHandlerStatus - -// func handleNowPlayingItemChange() -// func handleNowPlayingItemPlaybackChange() - -// func addNowPlayingObservers() - - func setNowPlayingInfo() async - func setNowPlayingPlaybackInfo() async - -// func resetNowPlaying() -} - -struct NowPlaying: NowPlayable { - var viewModel: MusicPlayerViewModel - - init(viewModel: MusicPlayerViewModel) { - self.viewModel = viewModel - } - - var supportedNowPlayableCommands: [NowPlayableCommand] { - return [ - .togglePlayPause, - .pause, - .play, - .nextTrack, - .previousTrack, - .changePlaybackPosition - ] - } - - func configureRemoteCommands(remoteCommandHandler: @escaping (NowPlayableCommand, MPRemoteCommandEvent) -> (MPRemoteCommandHandlerStatus)) { - guard supportedNowPlayableCommands.count > 1 else { - assertionFailure("Fatal error, atleast one remote command needs to be registered") - return - } - - supportedNowPlayableCommands.forEach { nowPlayableCommand in - nowPlayableCommand.removeHandler() - nowPlayableCommand.addHandler(remoteCommandHandler: remoteCommandHandler) - } - } - - @MainActor func handleRemoteCommand(for type: NowPlayableCommand, with event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - switch (type) { - case .togglePlayPause: - Task { await viewModel.togglePlayPause() } - return .success - case .play: - Task { await viewModel.togglePlayPause() } - return .success - case .pause: - Task { await viewModel.togglePlayPause() } - return .success - case .nextTrack: - Task { await viewModel.nextTrack() } - return .success - case .previousTrack: - Task { await viewModel.previousTrack() } - return .success - case .changePlaybackPosition: - guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } - viewModel.currentTime = event.positionTime - Task { await viewModel.seekToTime() } - return .success - default: - return .commandFailed - } - } - - /// Static - @MainActor func setNowPlayingInfo() { - var nowPlayingInfo: [String: Any] = [ - MPMediaItemPropertyPlaybackDuration: self.viewModel.currentTime, - MPNowPlayingInfoPropertyElapsedPlaybackTime: self.viewModel.duration, - MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, - MPNowPlayingInfoPropertyPlaybackRate: 1.0, - MPMediaItemPropertyArtist: self.viewModel.currentTrack?.artist ?? "", - MPMediaItemPropertyTitle: self.viewModel.currentTrack?.title ?? "", - MPNowPlayingInfoPropertyIsLiveStream: false - ] - - Task { - if let image: UIImage = await self.viewModel.loadArtwork() { - let artwork = MPMediaItemArtwork.init(boundsSize: image.size, requestHandler: { (size) -> UIImage in - return image - }) - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork - } - } - - print("** NEW Now Playing ** \(self.viewModel.currentTrack?.title ?? "UNKNOWN TITLE")") - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - } - - /// Dynamic - @MainActor func setNowPlayingPlaybackInfo() { - let d = MPNowPlayingInfoCenter.default() - var nowPlayingInfo: [String: Any] = d.nowPlayingInfo ?? [:] - - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = self.viewModel.duration - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.viewModel.currentTime - nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 - - d.nowPlayingInfo = nowPlayingInfo - } -} diff --git a/Cider Remote/Data/Parser.swift b/Cider Remote/Data/MediaPlayer/Parser.swift similarity index 84% rename from Cider Remote/Data/Parser.swift rename to Cider Remote/Data/MediaPlayer/Parser.swift index 7fba1f0..0db1fc9 100644 --- a/Cider Remote/Data/Parser.swift +++ b/Cider Remote/Data/MediaPlayer/Parser.swift @@ -1,4 +1,4 @@ -// Made by Lumaa & ChatGPT +// Made by Lumaa, ChatGPT & Grok import Foundation @@ -9,12 +9,14 @@ class Parser: NSObject, XMLParserDelegate { private var currentText: String private var currentBegin: String? + private var currentAgent: String? init(provider: Parser.LyricProvider, lyrics: [LyricLine] = []) { self.provider = provider self.lyrics = lyrics self.currentText = "" self.currentBegin = nil + self.currentAgent = nil } func parser(_ parser: XMLParser, @@ -24,8 +26,9 @@ class Parser: NSObject, XMLParserDelegate { attributes attributeDict: [String : String] = [:]) { if elementName == "p" { - // Save the 'begin' time (as string) when

starts, reset text. + // Save the 'begin' time and 'ttm:agent' (if present) when

starts, reset text. currentBegin = attributeDict["begin"] + currentAgent = attributeDict["ttm:agent"] currentText = "" } } @@ -69,14 +72,18 @@ class Parser: NSObject, XMLParserDelegate { timestamp = seconds + milliseconds / 1000 } + // Set altVoice based on ttm:agent value: "v1" -> false, "v2" -> true, default to false if not specified + let altVoice = currentAgent == "v2" ? true : false let lyricLine = LyricLine(text: trimmedText, timestamp: timestamp, - isMainLyric: isMain) + isMainLyric: isMain, + altVoice: altVoice) lyrics.append(lyricLine) } // Reset for next

currentBegin = nil + currentAgent = nil currentText = "" } } @@ -84,6 +91,7 @@ class Parser: NSObject, XMLParserDelegate { enum LyricProvider { case mxm case am + case studio case cache } } diff --git a/Cider Remote/Data/Browser/BrowserTab.swift b/Cider Remote/Data/MusicObjects/Browser/BrowserTab.swift similarity index 100% rename from Cider Remote/Data/Browser/BrowserTab.swift rename to Cider Remote/Data/MusicObjects/Browser/BrowserTab.swift diff --git a/Cider Remote/Data/MusicObjects/Browser/LibraryAlbum.swift b/Cider Remote/Data/MusicObjects/Browser/LibraryAlbum.swift new file mode 100644 index 0000000..eea0154 --- /dev/null +++ b/Cider Remote/Data/MusicObjects/Browser/LibraryAlbum.swift @@ -0,0 +1,85 @@ +// Made by Lumaa + +import Foundation + +struct LibraryAlbum: Identifiable, Hashable { + let id: String + let title: String + let artist: String + let artwork: String + var audioType: Track.AudioType = .unknown + + var tracks: [LibraryTrack]? = nil + + init(id: String, title: String, artist: String, artwork: String) { + self.id = id + self.title = title + self.artist = artist + self.artwork = artwork + } + + init(data: [String: Any]) { + let attributes: [String: Any] = data["attributes"] as! [String: Any] + + self.id = data["id"] as! String + self.title = attributes["name"] as! String + self.artist = attributes["artistName"] as! String + + if let artwork: [String: Any] = attributes["artwork"] as? [String: Any] { + if let w = artwork["width"] as? Int { + self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(w)") + } else { + self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(700)") + } + } else { + self.artwork = "[NONE]" + } + } + + func getAnimatedCover(using device: Device, size: Self.AnimatedCover = .square) async -> (URL?, Track.AudioType) { + do { + guard let data = try await device.runAppleMusicAPI(path: "/v1/me/library/albums/\(self.id)/catalog?extend=editorialVideo") as? [[String: Any]], data.count > 0 else { + return (nil, .unknown) + } + + if let attributes: [String: Any] = data[0]["attributes"] as? [String: Any] { + print(attributes) + + var audio: Track.AudioType = .unknown + if let audioTraits: [String] = attributes["audioTraits"] as? [String] { + print(audioTraits) + audio = Track.AudioType.find(audioTraits) + } + + if let videos: [String: Any] = attributes["editorialVideo"] as? [String: Any], let squareObj: [String: Any] = videos[size.rawValue] as? [String: Any], let squareStr: String = squareObj["video"] as? String { + return (URL(string: squareStr), audio) + } else { + return (nil, audio) + } + } + + return (nil, .unknown) + } catch { + print("Error getting album details: \(error)") + return (nil, .unknown) + } + } + + enum AnimatedCover: String { + case square = "motionDetailSquare" + case tall = "motionDetailTall" + + var px: CGSize { + switch self { + case .square: + return .init(width: 3840, height: 3840) + case .tall: + return .init(width: 2048, height: 2732) + } + } + + var ratio: CGFloat { + return self.px.width / self.px.height + } + } +} diff --git a/Cider Remote/Data/Browser/LibraryElement.swift b/Cider Remote/Data/MusicObjects/Browser/LibraryElement.swift similarity index 100% rename from Cider Remote/Data/Browser/LibraryElement.swift rename to Cider Remote/Data/MusicObjects/Browser/LibraryElement.swift diff --git a/Cider Remote/Data/Browser/LibraryPlaylist.swift b/Cider Remote/Data/MusicObjects/Browser/LibraryPlaylist.swift similarity index 62% rename from Cider Remote/Data/Browser/LibraryPlaylist.swift rename to Cider Remote/Data/MusicObjects/Browser/LibraryPlaylist.swift index 02f7970..ed48f61 100644 --- a/Cider Remote/Data/Browser/LibraryPlaylist.swift +++ b/Cider Remote/Data/MusicObjects/Browser/LibraryPlaylist.swift @@ -17,13 +17,13 @@ struct LibraryPlaylist: Identifiable, Hashable { } init(data: [String: Any]) { - let attributes: [String: Any] = data["attributes"] as! [String: Any] - let playParams: [String: Any] = attributes["playParams"] as! [String: Any] + let attributes: [String: Any]? = data["attributes"] as? [String: Any] + let playParams: [String: Any]? = attributes?["playParams"] as? [String: Any] - self.id = playParams["id"] as? String ?? data["id"] as! String // use all IDs known to fucking AM - self.name = attributes["name"] as! String + self.id = playParams?["id"] as? String ?? data["id"] as! String // use all IDs known to fucking AM + self.name = attributes?["name"] as? String ?? "Unknown Playlist" - if let artwork: [String: Any] = attributes["artwork"] as? [String: Any] { + if let artwork: [String: Any] = attributes?["artwork"] as? [String: Any] { if let w = artwork["width"] as? Int { self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(w)") } else { diff --git a/Cider Remote/Data/Browser/LibraryTrack.swift b/Cider Remote/Data/MusicObjects/Browser/LibraryTrack.swift similarity index 79% rename from Cider Remote/Data/Browser/LibraryTrack.swift rename to Cider Remote/Data/MusicObjects/Browser/LibraryTrack.swift index 722b2c6..f5de117 100644 --- a/Cider Remote/Data/Browser/LibraryTrack.swift +++ b/Cider Remote/Data/MusicObjects/Browser/LibraryTrack.swift @@ -41,7 +41,6 @@ struct LibraryTrack: Identifiable, Hashable { init(data: [String: Any], from album: LibraryAlbum? = nil) { let attributes: [String: Any] = data["attributes"] as! [String: Any] - let artwork: [String: Any] = attributes["artwork"] as! [String: Any] let playParams: [String: Any]? = attributes["playParams"] as? [String: Any] self.album = album @@ -59,10 +58,14 @@ struct LibraryTrack: Identifiable, Hashable { self.catalogId = "[UNKNOWN]" } - if let w = artwork["width"] as? Int { - self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(w)") + if let artwork: [String: Any] = attributes["artwork"] as? [String: Any] { + if let w = artwork["width"] as? Int { + self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(w)") + } else { + self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(700)") + } } else { - self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(700)") + self.artwork = "[NONE]" } } } diff --git a/Cider Remote/Data/MusicObjects/CurrentMusicService.swift b/Cider Remote/Data/MusicObjects/CurrentMusicService.swift new file mode 100644 index 0000000..f0a37e6 --- /dev/null +++ b/Cider Remote/Data/MusicObjects/CurrentMusicService.swift @@ -0,0 +1,388 @@ +import Foundation +import MediaPlayer +import MusicKit + +class CurrentMusicService: ObservableObject { + static let shared = CurrentMusicService() + + @Published var currentTrack: CurrentTrack? + @Published var isPlaying: Bool = false + @Published var hasMediaAccess: Bool = false + + private init() { + checkMediaPermissions() + setupNowPlayingObserver() + } + + deinit { + MPMusicPlayerController.systemMusicPlayer.endGeneratingPlaybackNotifications() + NotificationCenter.default.removeObserver(self) + } + + private func checkMediaPermissions() { + let status = MPMediaLibrary.authorizationStatus() + hasMediaAccess = status == .authorized + + if status == .notDetermined { + MPMediaLibrary.requestAuthorization { [weak self] newStatus in + DispatchQueue.main.async { + self?.hasMediaAccess = newStatus == .authorized + if newStatus == .authorized { + self?.updateCurrentTrack() + } + } + } + } + } + + private func setupNowPlayingObserver() { + // Enable notifications for system music player + MPMusicPlayerController.systemMusicPlayer.beginGeneratingPlaybackNotifications() + + NotificationCenter.default.addObserver( + self, + selector: #selector(nowPlayingInfoDidChange), + name: .MPMusicPlayerControllerNowPlayingItemDidChange, + object: MPMusicPlayerController.systemMusicPlayer + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(nowPlayingInfoDidChange), + name: .MPMusicPlayerControllerPlaybackStateDidChange, + object: MPMusicPlayerController.systemMusicPlayer + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(nowPlayingInfoDidChange), + name: .MPMusicPlayerControllerNowPlayingItemDidChange, + object: nil + ) + } + + @objc private func nowPlayingInfoDidChange() { + updateCurrentTrack() + } + + func updateCurrentTrack() { + guard hasMediaAccess else { + checkMediaPermissions() + DispatchQueue.main.async { + self.currentTrack = nil + self.isPlaying = false + } + return + } + + // Try to get info from the system music player + let systemMusicPlayer = MPMusicPlayerController.systemMusicPlayer + + // First try to get info from system music player + guard let nowPlayingItem = systemMusicPlayer.nowPlayingItem else { + // Fallback to MPNowPlayingInfoCenter for apps that use it + let nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo + + guard let nowPlayingInfo = nowPlayingInfo else { + DispatchQueue.main.async { + self.currentTrack = nil + self.isPlaying = false + } + return + } + + // Handle MPNowPlayingInfoCenter data + self.processNowPlayingInfo(nowPlayingInfo) + return + } + + // Handle MPMediaItem from system music player + self.processNowPlayingItem(nowPlayingItem, player: systemMusicPlayer) + } + + private func processNowPlayingItem(_ item: MPMediaItem, player: MPMusicPlayerController) { + let title = item.title ?? "Unknown Title" + let artist = item.artist ?? "Unknown Artist" + let album = item.albumTitle ?? "Unknown Album" + let duration = item.playbackDuration + let playbackTime = player.currentPlaybackTime + + // Get artwork if available + var artworkData: Data? + if let artwork = item.artwork { + let size = CGSize(width: 300, height: 300) + if size.width > 0 && size.height > 0 && size.width.isFinite && size.height.isFinite { + let image = artwork.image(at: size) + artworkData = image?.pngData() + } + } + + let track = CurrentTrack( + title: title, + artist: artist, + album: album, + duration: duration.isFinite ? duration : 0, + currentTime: playbackTime.isFinite ? playbackTime : 0, + artworkData: artworkData + ) + + DispatchQueue.main.async { + self.currentTrack = track + self.isPlaying = player.playbackState == .playing + } + } + + private func processNowPlayingInfo(_ nowPlayingInfo: [String: Any]) { + let title = nowPlayingInfo[MPMediaItemPropertyTitle] as? String ?? "Unknown Title" + let artist = nowPlayingInfo[MPMediaItemPropertyArtist] as? String ?? "Unknown Artist" + let album = nowPlayingInfo[MPMediaItemPropertyAlbumTitle] as? String ?? "Unknown Album" + let duration = nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] as? Double ?? 0 + let playbackTime = nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? Double ?? 0 + + // Get artwork if available, with proper dimension validation + var artworkData: Data? + if let artwork = nowPlayingInfo[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork { + let size = CGSize(width: 300, height: 300) + // Validate dimensions to prevent "Invalid frame dimension" error + if size.width > 0 && size.height > 0 && size.width.isFinite && size.height.isFinite { + let image = artwork.image(at: size) + artworkData = image?.pngData() + } + } + + let track = CurrentTrack( + title: title, + artist: artist, + album: album, + duration: duration.isFinite ? duration : 0, + currentTime: playbackTime.isFinite ? playbackTime : 0, + artworkData: artworkData + ) + + DispatchQueue.main.async { + self.currentTrack = track + self.isPlaying = true // Assume playing if we have info + } + } + + /// Send the current track to a Cider device + func sendToCider(device: Device) async -> Bool { + guard let track = currentTrack else { + return false + } + + guard track.hasValidData else { + return false + } + + // Search for the track on Apple Music using Cider's API + return await searchAndPlayTrack(track: track, device: device) + } + + private func searchAndPlayTrack(track: CurrentTrack, device: Device) async -> Bool { + do { + // First, get the storefront + guard let storefront = await getStorefront(device: device) else { + return false + } + + // Try multiple search strategies + let searchStrategies = [ + "\(track.title) \(track.artist)", // Original approach + track.title, // Title only + "\"\(track.title)\" \(track.artist)", // Quoted title + "\(track.artist) \(track.title)" // Artist first + ] + + // Also try the legacy search API as fallback + let legacyResult = await tryLegacySearch(track: track, device: device) + if legacyResult { + return true + } + + for searchTerm in searchStrategies { + guard let encodedQuery = searchTerm.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + continue + } + + let searchPath = "/v1/catalog/\(storefront)/search?term=\(encodedQuery)&types=songs&limit=5" + let searchData = try await device.runAppleMusicAPI(path: searchPath) + + // Parse response structure + var songData: [[String: Any]] = [] + + if let searchResult = searchData as? [String: Any] { + // Try the correct structure: data.results.songs.data + if let data = searchResult["data"] as? [String: Any], + let results = data["results"] as? [String: Any], + let songs = results["songs"] as? [String: Any], + let songsData = songs["data"] as? [[String: Any]] { + songData = songsData + } + // Fallback to old structure: results.songs.data + else if let results = searchResult["results"] as? [String: Any], + let songs = results["songs"] as? [String: Any], + let resultsData = songs["data"] as? [[String: Any]] { + songData = resultsData + } + } else if let searchArray = searchData as? [[String: Any]] { + songData = searchArray + } + + if !songData.isEmpty { + // Look for the best match + for song in songData { + let songId = song["id"] as? String ?? "unknown" + let attributes = song["attributes"] as? [String: Any] ?? [:] + let songTitle = attributes["name"] as? String ?? "unknown" + let songArtist = attributes["artistName"] as? String ?? "unknown" + + // Simple matching - if title contains our search term or vice versa + if titleMatches(original: track.title, found: songTitle) && + artistMatches(original: track.artist, found: songArtist) { + return await playTrack(songId: songId, track: track, device: device) + } + } + + // If no exact match, try the first result as fallback + if let firstSong = songData.first, + let songId = firstSong["id"] as? String { + return await playTrack(songId: songId, track: track, device: device) + } + } + } + + return false + } catch { + return false + } + } + + private func tryLegacySearch(track: CurrentTrack, device: Device) async -> Bool { + do { + // Try the original search method from the codebase + let searchData: [String: Any] = [ + "term": "\(track.artist) \(track.title)", + "types": ["songs"], + "limit": 5 + ] + + let searchResult = try await device.sendRequest( + endpoint: "amapi/search", + method: "POST", + body: searchData + ) + + // Parse response similar to the original implementation + if let resultDict = searchResult as? [String: Any], + let songs = resultDict["songs"] as? [[String: Any]], + let firstSong = songs.first, + let songId = firstSong["id"] as? String { + + return await playTrack(songId: songId, track: track, device: device) + } + + } catch { + // Legacy search failed, continue with other methods + } + + return false + } + + private func titleMatches(original: String, found: String) -> Bool { + let cleaningSet = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters) + let originalClean = original.lowercased().trimmingCharacters(in: cleaningSet) + let foundClean = found.lowercased().trimmingCharacters(in: cleaningSet) + + return originalClean.contains(foundClean) || foundClean.contains(originalClean) || + originalClean == foundClean + } + + private func artistMatches(original: String, found: String) -> Bool { + let cleaningSet = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters) + let originalClean = original.lowercased().trimmingCharacters(in: cleaningSet) + let foundClean = found.lowercased().trimmingCharacters(in: cleaningSet) + + return originalClean.contains(foundClean) || foundClean.contains(originalClean) || + originalClean == foundClean + } + + private func playTrack(songId: String, track: CurrentTrack, device: Device) async -> Bool { + do { + // First get the song's href URL which is needed for play-item-href + let songHref = "/v1/catalog/in/songs/\(songId)" + + // Clear the current queue to ensure our song plays + let apath: String = device.useV2 ? "queue" : "playback/queue/clear-queue" + let method: String = device.useV2 ? "DELETE" : "POST" + + do { + _ = try await device.sendRequest( + endpoint: apath, + method: method + ) + } catch { + // Continue anyway if queue clear fails + } + + // Use play-item-href to start the new song + let bpath: String = device.useV2 ? "playback/play-href" : "playback/play-item-href" + _ = try await device.sendRequest( + endpoint: bpath, + method: "POST", + body: ["href": songHref] + ) + + // Wait for the song to load and start + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + + // Seek to the correct position if needed + if track.currentTime > 0 { + let seekData: [String: Any] = ["position": track.currentTime] + do { + _ = try await device.sendRequest( + endpoint: "playback/seek", + method: "POST", + body: seekData + ) + } catch { + // Continue anyway if seek fails + } + } + + return true + } catch { + return false + } + } + + private func getStorefront(device: Device) async -> String? { + do { + let data = try await device.runAppleMusicAPI(path: "/v1/me/storefront?limit=1") + + guard let storefrontData = data as? [[String: Any]], + let firstStorefront = storefrontData.first, + let storefrontId = firstStorefront["id"] as? String else { + return nil + } + + return storefrontId + } catch { + return nil + } + } +} + +/// Represents the current track playing on the device +struct CurrentTrack { + let title: String + let artist: String + let album: String + let duration: Double + let currentTime: Double + let artworkData: Data? + + var hasValidData: Bool { + return !title.isEmpty && !artist.isEmpty && title != "Unknown Title" + } +} diff --git a/Cider Remote/Data/MusicObjects/Queue.swift b/Cider Remote/Data/MusicObjects/Queue.swift new file mode 100644 index 0000000..48ea563 --- /dev/null +++ b/Cider Remote/Data/MusicObjects/Queue.swift @@ -0,0 +1,121 @@ +// Made by Lumaa + +import Foundation + +struct Queue { + var tracks: [Track] + let source: Self.Source? + + private(set) var offset: Int = -1 + + init(tracks: [Track], source: Self.Source? = nil) { + self.tracks = tracks + self.source = source + } + + struct Source { + let name: String + let artworkURL: URL? + let type: String + } + + /// Use only for v1 endpoint, helps defining the `offset`, and then find all the next tracks + mutating func defineCurrent(track: Track) { + guard let index = self.tracks.firstIndex(where: { $0.id == track.id }), self.tracks.count > 1 else { return } + + if index == self.tracks.count - 1 { + self.tracks = [] + self.offset = self.tracks.count + return + } + + let fx = self.tracks[index + 1...max(self.tracks.count - 1, index + 1)] + self.tracks = Array(fx) + self.offset = index + 1 + } + + /// Use only for v2, fetches the `offset` from `GET /queue/position`, then fetches all tracks using `offset` query in `GET /queue` + mutating func fetchCurrent(device: Device, fetchQueue: Bool = true) async throws { + guard device.useV2 else { throw NetworkError.invalidURL } + + guard let reqRes: [String: Any] = try await device.sendRequest(endpoint: "queue/position") as? [String: Any] else { throw NetworkError.invalidResponse } + let data: Data = try JSONSerialization.data(withJSONObject: reqRes) + let pos = try JSONDecoder().decode(QueuePosition.self, from: data) + + if pos.position >= pos.total - 1 { + self.tracks = [] + self.offset = pos.total + return + } + + self.offset = pos.position + if fetchQueue { + guard let queue: [[String: Any]] = (try await device.sendRequest(endpoint: "queue", queries: [.init(name: "limit", value: "20"), .init(name: "offset", value: "\(pos.position + 1)")]) as? [String: Any])?["items"] as? [[String: Any]] else { + throw NetworkError.invalidResponse + } // this is so ass code for real TODO: fix this shit next version with actual `Codable`s + + self.tracks = queue.compactMap { getTrack(using: $0) } + } else { + let fx = self.tracks[pos.position + 1...max(self.tracks.count - 1, pos.position + 1)] + self.tracks = Array(fx) + } + } + + mutating func remove(set: IndexSet) { + guard let first = set.first, let last = set.last else { return } + + self.tracks.remove(atOffsets: IndexSet(integersIn: first + offset...last + offset)) + } + + mutating func move(from: IndexSet, to: Int) { + guard let first = from.first, let last = from.last else { return } + + print("first: \(first + offset)") + print("last: \(last + offset)") + + print("to: \(to + offset)") + self.tracks.move(fromOffsets: IndexSet(integersIn: (first + offset)...(last + offset)), toOffset: to + offset) + } + + func firstIndex(of track: Track) -> Int { + guard let i = tracks.firstIndex(of: track) else { return -1 } + return i + offset + } + + private func getTrack(using info: [String: Any]) -> Track? { + guard let attributes = (info["track"] as? [String : Any])?["attributes"] as? [String: Any] else { return nil } + + let id = (info["track"] as? [String : Any])?["id"] as? String ?? "" + let title = attributes["name"] as? String ?? "" + let artist = attributes["artistName"] as? String ?? "" + let album = attributes["albumName"] as? String ?? "" + let duration = attributes["durationInMillis"] as? Double ?? 0 + + if let artwork = attributes["artwork"] as? [String: Any], + var artworkUrl = artwork["url"] as? String { + // Replace placeholders in artwork URL + artworkUrl = artworkUrl.replacingOccurrences(of: "{w}", with: "1024") + artworkUrl = artworkUrl.replacingOccurrences(of: "{h}", with: "1024") + + return Track(id: id, + catalogId: id, + title: title, + artist: artist, + album: album, + artwork: artworkUrl, + duration: duration / 1000, + artworkData: Data() + ) + } else { + return Track(id: id, + catalogId: id, + title: title, + artist: artist, + album: album, + artwork: "", + duration: duration / 1000, + artworkData: Data() + ) + } + } +} diff --git a/Cider Remote/Data/MusicObjects/QueuePosition.swift b/Cider Remote/Data/MusicObjects/QueuePosition.swift new file mode 100644 index 0000000..62a023b --- /dev/null +++ b/Cider Remote/Data/MusicObjects/QueuePosition.swift @@ -0,0 +1,8 @@ +// Made by Lumaa + +struct QueuePosition: Decodable { + /// Indexed position in the queue + let position: Int + /// Total number of tracks in the queue + let total: Int +} diff --git a/Cider Remote/Data/Track.swift b/Cider Remote/Data/MusicObjects/Track.swift similarity index 84% rename from Cider Remote/Data/Track.swift rename to Cider Remote/Data/MusicObjects/Track.swift index 4d7efe8..46d93b8 100644 --- a/Cider Remote/Data/Track.swift +++ b/Cider Remote/Data/MusicObjects/Track.swift @@ -1,9 +1,10 @@ // Made by Lumaa import Foundation +import SwiftUI import UIKit -struct Track: Codable, Equatable { +struct Track: Codable, Identifiable, Equatable { let id: String let catalogId: String let title: String @@ -14,7 +15,6 @@ struct Track: Codable, Equatable { var artworkData: Data var songHref: String? = nil - init( id: String, catalogId: String, @@ -23,7 +23,7 @@ struct Track: Codable, Equatable { album: String, artwork: String, duration: Double, - artworkData: Data, + artworkData: Data = Data(), songHref: String? = nil ) { self.id = id @@ -62,23 +62,7 @@ struct Track: Codable, Equatable { return nil } - func getArtwork() -> UIImage? { - var ui: UIImage? = nil - Task { - do { - let url: URL = URL(string: self.artwork)! - let (data, _) = try await URLSession.shared.data(from: url) - if let image = UIImage(data: data) { - ui = image - } - } catch { - print("Error loading image: \(error)") - } - } - return ui - } - - func getArtwork() -> UIImage { + func getArtworkLocally() -> UIImage { return UIImage(data: self.artworkData) ?? UIImage.logo } @@ -88,6 +72,59 @@ struct Track: Codable, Equatable { } } + enum AudioType: String, Equatable { + case unknown = "unknown" + case lossless = "lossless" + case hiResLossless = "hi-res-lossless" + case dolbyAtmos = "atmos" + + @ViewBuilder + var view: some View { + switch self { + case .unknown: + Image(systemName: "circle.slash") + case .lossless: + Label("Lossless", image: .lossless) + .foregroundStyle(Color.secondary) + .font(.callout) + case .hiResLossless: + Label("Hi-Res Lossless", image: .lossless) + .foregroundStyle(Color.secondary) + .font(.callout) + case .dolbyAtmos: + Image(.dolbyAtmos) + .resizable() + .scaledToFit() + .colorInvert() + .frame(height: 10.0) + } + } + + static func find(_ str: String) -> Self { + if str == self.lossless.rawValue { + return self.lossless + } else if str == self.hiResLossless.rawValue { + return self.hiResLossless + } else if str == self.dolbyAtmos.rawValue { + return self.dolbyAtmos + } + + return .unknown + } + + static func find(_ strs: [String]) -> Self { + if strs.contains(self.dolbyAtmos.rawValue) { + return self.dolbyAtmos + } else if strs.contains(self.hiResLossless.rawValue) { + return self.hiResLossless + } else if strs.contains(self.lossless.rawValue) { + return self.lossless + } + + return .unknown + } + } + struct RequestLyrics: Identifiable, Encodable { let id: String let name: String diff --git a/Cider Remote/Data/Prompt.swift b/Cider Remote/Data/Prompt.swift index 09fe7ec..15e6bd0 100644 --- a/Cider Remote/Data/Prompt.swift +++ b/Cider Remote/Data/Prompt.swift @@ -55,13 +55,15 @@ struct Prompt { .glassEffect(.regular.interactive().tint(Color.cider)) } - Button { - dismiss() - } label: { - Text("Cancel") - .foregroundStyle(Color(uiColor: UIColor.label)) - .frame(maxWidth: .infinity, minHeight: 50) - .glassEffect(.regular.interactive()) + if showCancel { + Button { + dismiss() + } label: { + Text("Cancel") + .foregroundStyle(Color(uiColor: UIColor.label)) + .frame(maxWidth: .infinity, minHeight: 50) + .glassEffect(.regular.interactive()) + } } } } else { @@ -70,14 +72,12 @@ struct Prompt { Button("Cancel") { dismiss() } - .buttonStyle(SecondaryButtonStyle()) } Button(self.actionLabel) { self.action() dismiss() } - .buttonStyle(PrimaryButtonStyle()) } } } diff --git a/Cider Remote/Data/Queue.swift b/Cider Remote/Data/Queue.swift index 84046f3..0bc2268 100644 --- a/Cider Remote/Data/Queue.swift +++ b/Cider Remote/Data/Queue.swift @@ -19,6 +19,7 @@ struct Queue { let type: String } + /// Use only for v1 endpoint, helps defining the `offset`, and then find all the next tracks mutating func defineCurrent(track: Track) { guard let index = self.tracks.firstIndex(where: { $0.id == track.id }), self.tracks.count > 1 else { return } @@ -33,6 +34,13 @@ struct Queue { self.offset = index + 1 } + /// Use only for v2, fetches the `offset` from `GET /queue/position`, then fetches all tracks using `offset` query in `GET /queue` + mutating func fetchCurrent(device: Device) async { + guard device.useV2 else { return } + + + } + mutating func remove(set: IndexSet) { guard let first = set.first, let last = set.last else { return } diff --git a/Cider Remote/Data/UserDevice.swift b/Cider Remote/Data/UserDevice.swift index aec58ac..703e882 100644 --- a/Cider Remote/Data/UserDevice.swift +++ b/Cider Remote/Data/UserDevice.swift @@ -17,9 +17,12 @@ class UserDevice: ObservableObject { get { prevOrientation = _horizontalOrientation switch self.orientation { - case .unknown, .portrait, .portraitUpsideDown: + case .unknown, .portrait: _horizontalOrientation = .portrait return .portrait + case .portraitUpsideDown: + _horizontalOrientation = .portraitDown + return .portraitDown case .landscapeLeft: _horizontalOrientation = .landscapeLeft return .landscapeLeft @@ -55,5 +58,13 @@ class UserDevice: ObservableObject { case portraitDown case landscapeLeft case landscapeRight + + func isPortrait() -> Bool { + return self == .portrait || self == .portraitDown + } + + func isLandscape() -> Bool { + return self == .landscapeLeft || self == .landscapeRight + } } } diff --git a/Cider Remote/Data/Font+Extension.swift b/Cider Remote/Extensions/Font+Extension.swift similarity index 100% rename from Cider Remote/Data/Font+Extension.swift rename to Cider Remote/Extensions/Font+Extension.swift diff --git a/Cider Remote/Extensions/String+Sub.swift b/Cider Remote/Extensions/String+Sub.swift new file mode 100644 index 0000000..d17e81c --- /dev/null +++ b/Cider Remote/Extensions/String+Sub.swift @@ -0,0 +1,26 @@ +// Made by Lumaa + +import Foundation + +/// https://stackoverflow.com/a/24144365 +extension StringProtocol { + subscript(offset: Int) -> Character { self[index(startIndex, offsetBy: offset)] } + + subscript(range: Range) -> SubSequence { + let startIndex = index(self.startIndex, offsetBy: range.lowerBound) + return self[startIndex..) -> SubSequence { + let startIndex = index(self.startIndex, offsetBy: range.lowerBound) + return self[startIndex..) -> SubSequence { self[index(startIndex, offsetBy: range.lowerBound)...] } + + subscript(range: PartialRangeThrough) -> SubSequence { self[...index(startIndex, offsetBy: range.upperBound)] } + + subscript(range: PartialRangeUpTo) -> SubSequence { self[.. 1 @@ -126,57 +140,102 @@ struct LibraryAlbumView: View { var header: some View { LazyVStack { - AsyncImage(url: URL(string: album.artwork)) { image in - image - .resizable() - .frame(width: 220, height: 220) - .clipShape(RoundedRectangle(cornerRadius: 7)) - } placeholder: { - ZStack { - ProgressView() - .progressViewStyle(.circular) - .zIndex(20) - - Rectangle() - .fill(Color.gray) + if let player { + UninteractableVideoPlayer(player: player) + .aspectRatio(LibraryAlbum.AnimatedCover.tall.ratio, contentMode: .fit) + .frame(maxWidth: .infinity) + .overlay(alignment: .bottom) { + VStack { + Text(self.album.title) + .font(.body.bold()) + .lineLimit(2) + .multilineTextAlignment(.center) + + Text(self.album.artist) + .font(.body) + .lineLimit(1) + .foregroundStyle(Color.secondary) + + if self.album.audioType != .unknown { + self.album.audioType.view + } + } + .padding(10.0) + .frame(width: 250, alignment: .center) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 15.0)) + .padding(.vertical, 25.0) + } + } else { + AsyncImage(url: URL(string: album.artwork)) { image in + image + .resizable() + .frame(width: 220, height: 220) .clipShape(RoundedRectangle(cornerRadius: 7)) - .zIndex(10) + } placeholder: { + ZStack { + ProgressView() + .progressViewStyle(.circular) + .zIndex(20) + + Rectangle() + .fill(Color.gray) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .zIndex(10) + } + .frame(width: 220, height: 220) } - .frame(width: 220, height: 220) - } - .contextMenu { - Button { - Task { - guard let url = URL(string: album.artwork), - let (data, _) = try? await URLSession.shared.data(from: url), - let image = UIImage(data: data) else { - return + .contextMenu { + Button { + Task { + guard let url = URL(string: album.artwork), + let (data, _) = try? await URLSession.shared.data(from: url), + let image = UIImage(data: data) else { + return + } + + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + } label: { + Label("Save artwork", systemImage: "photo.badge.plus") + } + + Button { + Task { + guard let url = URL(string: album.artwork), + let (data, _) = try? await URLSession.shared.data(from: url), + let image = UIImage(data: data) else { + return + } + self.sharingImage = image } - self.sharingImage = image + } label: { + Label("Share image", systemImage: "square.and.arrow.up") } - } label: { - Label("Share image", systemImage: "square.and.arrow.up") } - } - .sheet(item: Binding( - get: { sharingImage }, - set: { newValue in sharingImage = newValue } - )) { image in - ActivityViewController(item: .image(images: [image])) - .presentationDetents([.medium, .large]) - } + .sheet(item: Binding( + get: { sharingImage }, + set: { newValue in sharingImage = newValue } + )) { image in + ActivityViewController(item: .image(images: [image])) + .presentationDetents([.medium, .large]) + } + + Text(self.album.title) + .font(.body.bold()) + .lineLimit(2) + .multilineTextAlignment(.center) - Text(self.album.title) - .font(.body.bold()) - .lineLimit(2) - .multilineTextAlignment(.center) + Text(self.album.artist) + .font(.body) + .lineLimit(1) + .foregroundStyle(Color.secondary) - Text(self.album.artist) - .font(.body) - .lineLimit(1) - .foregroundStyle(Color.secondary) + if self.album.audioType != .unknown { + self.album.audioType.view + } + } } - .padding(.horizontal, 5.0) + .padding(.horizontal, self.videoURL == nil ? 5.0 : 0.0) } @ViewBuilder @@ -197,6 +256,20 @@ struct LibraryAlbumView: View { .clipShape(RoundedRectangle(cornerRadius: 10.0)) } } + + private func setupPlayer() { + guard player == nil, let videoURL else { return } + + let newPlayer = AVPlayer(url: videoURL) + self.player = newPlayer + + NotificationCenter.default.addObserver(forName: AVPlayerItem.didPlayToEndTimeNotification, object: newPlayer.currentItem, queue: .main) { _ in + newPlayer.seek(to: .zero) + newPlayer.play() + } + + newPlayer.play() + } } extension UIImage: @retroactive Identifiable { @@ -208,7 +281,8 @@ extension UIImage: @retroactive Identifiable { extension LibraryAlbumView { func playHref(href: String) async { do { - _ = try await device.sendRequest(endpoint: "playback/play-item-href", method: "POST", body: ["href": href]) + let path: String = device.useV2 ? "playback/play-href" : "playback/play-item-href" + _ = try await device.sendRequest(endpoint: path, method: "POST", body: ["href": href]) } catch { print("Error playing track: \(error)") } @@ -216,7 +290,10 @@ extension LibraryAlbumView { func clearQueue() async { do { - _ = try await device.sendRequest(endpoint: "playback/queue/clear-queue", method: "POST") + let path: String = device.useV2 ? "queue" : "playback/queue/clear-queue" + let method: String = device.useV2 ? "DELETE" : "POST" + + _ = try await device.sendRequest(endpoint: path, method: method) } catch { print("Error clearing queue: \(error)") } @@ -233,7 +310,8 @@ extension LibraryAlbumView { func playLater(from playingTrack: LibraryTrack) { Task { do { - let _ = try await device.sendRequest(endpoint: "playback/play-later", method: "POST", body: ["id": playingTrack.id, "type": "song"]) + let path: String = device.useV2 ? "playback/add-later" : "playback/play-later" + let _ = try await device.sendRequest(endpoint: path, method: "POST", body: ["id": playingTrack.id, "type": "song"]) } catch { print("Error playing next: \(error)") } @@ -243,7 +321,8 @@ extension LibraryAlbumView { func playNext(from playingTrack: LibraryTrack) { Task { do { - let _ = try await device.sendRequest(endpoint: "playback/play-next", method: "POST", body: ["id": playingTrack.id, "type": "song"]) + let path: String = device.useV2 ? "playback/add-next" : "playback/play-next" + let _ = try await device.sendRequest(endpoint: path, method: "POST", body: ["id": playingTrack.id, "type": "song"]) } catch { print("Error playing next: \(error)") } @@ -277,7 +356,8 @@ extension LibraryAlbumView { func getAlbum(using track: LibraryTrack) async { do { - guard let data = try await device.runAppleMusicAPI(path: "/v1/catalog/us/songs/\(track.catalogId)/albums") as? [[String: Any]] else { return } + guard let data: [[String: Any]] = try await device.runAppleMusicAPI(path: "/v1/catalog/us/songs/\(track.catalogId)/albums") as? [[String: Any]], data.count > 0 else { return } + if let attributes: [String: Any] = data[0]["attributes"] as? [String: Any], attributes["isPrerelease"] as? Int == 1 { let dateFormat: DateFormatter = .init() dateFormat.dateFormat = "YYYY-MM-dd" diff --git a/Cider Remote/Views/Browser/LibraryPlaylistView.swift b/Cider Remote/Views/Browser/LibraryPlaylistView.swift index 203faa5..b3d21d1 100644 --- a/Cider Remote/Views/Browser/LibraryPlaylistView.swift +++ b/Cider Remote/Views/Browser/LibraryPlaylistView.swift @@ -8,9 +8,12 @@ struct LibraryPlaylistView: View { @State var playlist: LibraryPlaylist @State private var isLoading: Bool = true + @State private var sharingTrack: LibraryTrack? = nil @State private var sharingImage: UIImage? = nil + @State private var viewingAlbum: LibraryAlbum? = nil + init(_ playlist: LibraryPlaylist) { self.playlist = playlist } @@ -73,8 +76,19 @@ struct LibraryPlaylistView: View { } label: { Label("Play Later", image: "PlayLater") } + + Divider() + + Button { + Task { + self.viewingAlbum = await self.getAlbum(of: track) + } + } label: { + Label("View Album", image: "BoxNote") + } } label: { Image(systemName: "ellipsis") + .padding(7.0) } .disabled(track.catalogId == "[UNKNOWN]") .tint(Color(uiColor: UIColor.label)) @@ -89,6 +103,10 @@ struct LibraryPlaylistView: View { } .navigationTitle(Text(self.playlist.name)) .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $viewingAlbum) { album in + LibraryAlbumView(album) + .environmentObject(device) + } .task { defer { self.isLoading = false } self.playlist.tracks = await self.getTracks(from: self.playlist) @@ -120,6 +138,20 @@ struct LibraryPlaylistView: View { .frame(width: 220, height: 220) } .contextMenu { + Button { + Task { + guard let url = URL(string: self.playlist.artwork), + let (data, _) = try? await URLSession.shared.data(from: url), + let image = UIImage(data: data) else { + return + } + + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + } label: { + Label("Save artwork", systemImage: "photo.badge.plus") + } + Button { Task { guard let url = URL(string: self.playlist.artwork), @@ -130,7 +162,7 @@ struct LibraryPlaylistView: View { self.sharingImage = image } } label: { - Label("Share image", systemImage: "square.and.arrow.up") + Label("Share artwork", systemImage: "square.and.arrow.up") } } .sheet(item: Binding( @@ -151,49 +183,55 @@ struct LibraryPlaylistView: View { } extension LibraryPlaylistView { - func playHref(href: String) async { - do { - _ = try await device.sendRequest(endpoint: "playback/play-item-href", method: "POST", body: ["href": href]) - } catch { - print("Error playing track: \(error)") - } - } + func playHref(href: String) async { + do { + let path: String = device.useV2 ? "playback/play-href" : "playback/play-item-href" + _ = try await device.sendRequest(endpoint: path, method: "POST", body: ["href": href]) + } catch { + print("Error playing track: \(error)") + } + } - func clearQueue() async { - do { - _ = try await device.sendRequest(endpoint: "playback/queue/clear-queue", method: "POST") - } catch { - print("Error clearing queue: \(error)") - } - } + func clearQueue() async { + do { + let path: String = device.useV2 ? "queue" : "playback/queue/clear-queue" + let method: String = device.useV2 ? "DELETE" : "POST" - func skipTrack() async { - do { - _ = try await device.sendRequest(endpoint: "playback/next", method: "POST") - } catch { - print("Error playing next track: \(error)") - } - } + _ = try await device.sendRequest(endpoint: path, method: method) + } catch { + print("Error clearing queue: \(error)") + } + } - func playLater(from playingTrack: LibraryTrack) { - Task { - do { - let _ = try await device.sendRequest(endpoint: "playback/play-later", method: "POST", body: ["id": playingTrack.id, "type": "song"]) - } catch { - print("Error playing next: \(error)") - } - } - } + func skipTrack() async { + do { + _ = try await device.sendRequest(endpoint: "playback/next", method: "POST") + } catch { + print("Error playing next track: \(error)") + } + } - func playNext(from playingTrack: LibraryTrack) { - Task { - do { - let _ = try await device.sendRequest(endpoint: "playback/play-next", method: "POST", body: ["id": playingTrack.id, "type": "song"]) - } catch { - print("Error playing next: \(error)") - } - } - } + func playLater(from playingTrack: LibraryTrack) { + Task { + do { + let path: String = device.useV2 ? "playback/add-later" : "playback/play-later" + let _ = try await device.sendRequest(endpoint: path, method: "POST", body: ["id": playingTrack.id, "type": "song"]) + } catch { + print("Error playing next: \(error)") + } + } + } + + func playNext(from playingTrack: LibraryTrack) { + Task { + do { + let path: String = device.useV2 ? "playback/add-next" : "playback/play-next" + let _ = try await device.sendRequest(endpoint: path, method: "POST", body: ["id": playingTrack.id, "type": "song"]) + } catch { + print("Error playing next: \(error)") + } + } + } func getTracks(from playlist: LibraryPlaylist) async -> [LibraryTrack] { do { @@ -213,4 +251,17 @@ extension LibraryPlaylistView { return [] } + + func getAlbum(of track: LibraryTrack) async -> LibraryAlbum? { + do { + guard let data = try await device.runAppleMusicAPI(path: "/v1/me/library/songs/\(track.id)/albums") as? [[String: Any]] else { return nil } + print(data) + + return LibraryAlbum(data: data[0]) + } catch { + print("Error getting library: \(error)") + } + + return nil + } } diff --git a/Cider Remote/Views/ChangelogsView.swift b/Cider Remote/Views/ChangelogsView.swift index 8daa900..62fffeb 100644 --- a/Cider Remote/Views/ChangelogsView.swift +++ b/Cider Remote/Views/ChangelogsView.swift @@ -6,7 +6,7 @@ struct ChangelogsView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.openURL) private var openURL: OpenURLAction - private static let changelogs: [Changelog] = [.v310, .v303, .v302, .v301, .v300] + private static let changelogs: [Changelog] = [.v400, .v311, .v310, .v303, .v302, .v301, .v300] @State private var selectedChangelog: Changelog? = nil @@ -133,166 +133,218 @@ struct Changelog: Hashable, Identifiable { } func view(colorScheme: ColorScheme = .light, openURL: OpenURLAction, dismiss: @escaping () -> Void) -> some View { - ScrollView { - LazyVStack(alignment: .leading) { - HStack { - Text("Remote \(self.version)") - .font(.title.bold()) - .lineLimit(1) - - Spacer() + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading) { + if let header { + Text(header) + .font(.subheadline.bold()) + .padding(.horizontal, 15.0) + .padding(.vertical, 10.0) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + colorScheme == .light ? Color.gray + .opacity(0.3) : Color(uiColor: UIColor.tertiarySystemBackground) + .opacity(0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 5.0)) + } - Button { - dismiss() - } label: { - if #available(iOS 26.0, *) { - Image(systemName: "xmark") - .foregroundStyle(Color(uiColor: UIColor.label)) - .padding(12) - .glassEffect(.regular.interactive()) - } else { - Image(systemName: "xmark.circle.fill") - .font(.title2) - .foregroundStyle(Color.cider) + if !self.additions.isEmpty { + VStack(alignment: .leading, spacing: 8.0) { + Text("Added:") + .font(.title2.bold()) + .lineLimit(1) + + ForEach(self.additions, id: \.self) { added in + HStack(alignment: .top) { + Image(systemName: "plus.circle.fill") + .imageScale(.small) + .foregroundStyle(Color.white, Color.green) + + Text(added) + .font(.callout) + } + } } + .padding(.vertical) } - } - .frame(maxWidth: .infinity) - - if let header { - Text(header) - .font(.subheadline.bold()) - .padding(.horizontal, 15.0) - .padding(.vertical, 10.0) - .background( - colorScheme == .light ? Color.gray - .opacity(0.3) : Color(uiColor: UIColor.tertiarySystemBackground) - .opacity(0.5) - ) - .clipShape(RoundedRectangle(cornerRadius: 5.0)) - } - - if !self.additions.isEmpty { - VStack(alignment: .leading, spacing: 8.0) { - Text("Added:") - .font(.title2.bold()) - .lineLimit(1) - ForEach(self.additions, id: \.self) { added in - HStack(alignment: .top) { - Image(systemName: "plus.circle.fill") - .imageScale(.small) - .foregroundStyle(Color.white, Color.green) - - Text(added) - .font(.callout) + if !self.modifications.isEmpty { + VStack(alignment: .leading, spacing: 8.0) { + Text("Changed:") + .font(.title2.bold()) + .lineLimit(1) + + ForEach(self.modifications, id: \.self) { modified in + HStack(alignment: .top) { + Image(systemName: "pencil.circle.fill") + .imageScale(.small) + .foregroundStyle(Color.white, Color.yellow) + + Text(modified) + .font(.callout) + } } } + .padding(.vertical) } - .padding(.vertical) - } - - if !self.modifications.isEmpty { - VStack(alignment: .leading, spacing: 8.0) { - Text("Changed:") - .font(.title2.bold()) - .lineLimit(1) - ForEach(self.modifications, id: \.self) { modified in - HStack(alignment: .top) { - Image(systemName: "pencil.circle.fill") - .imageScale(.small) - .foregroundStyle(Color.white, Color.yellow) - - Text(modified) - .font(.callout) + if !self.removals.isEmpty { + VStack(alignment: .leading, spacing: 8.0) { + Text("Removed:") + .font(.title2.bold()) + .lineLimit(1) + + ForEach(self.removals, id: \.self) { removed in + HStack(alignment: .top) { + Image(systemName: "minus.circle.fill") + .imageScale(.small) + .foregroundStyle(Color.white, Color.red) + + Text(removed) + .font(.callout) + } } } + .padding(.vertical) + } + + if let footer { + Text(footer) + .font(.subheadline) + .padding(.horizontal, 15.0) + .padding(.vertical, 10.0) + .background( + colorScheme == .light ? Color.gray + .opacity(0.3) : Color(uiColor: UIColor.tertiarySystemBackground) + .opacity(0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 5.0)) } - .padding(.vertical) - } - if !self.removals.isEmpty { VStack(alignment: .leading, spacing: 8.0) { - Text("Removed:") + Text("Contributors for \(self.version):") .font(.title2.bold()) .lineLimit(1) - ForEach(self.removals, id: \.self) { removed in - HStack(alignment: .top) { - Image(systemName: "minus.circle.fill") - .imageScale(.small) - .foregroundStyle(Color.white, Color.red) - - Text(removed) - .font(.callout) + HStack { + ForEach(self.authors, id: \.self) { author in + Text(author) + .font(.callout.width(.expanded)) + .padding(.horizontal, 10.0) + .padding(.vertical, 5.0) + .background( + colorScheme == .light ? Color.gray + .opacity(0.3) : Color(uiColor: UIColor.tertiarySystemBackground) + .opacity(0.5) + ) + .clipShape(Capsule()) } } } - .padding(.vertical) - } - if let footer { - Text(footer) - .font(.subheadline) - .padding(.horizontal, 15.0) - .padding(.vertical, 10.0) - .background( - colorScheme == .light ? Color.gray - .opacity(0.3) : Color(uiColor: UIColor.tertiarySystemBackground) - .opacity(0.5) - ) - .clipShape(RoundedRectangle(cornerRadius: 5.0)) - } + if let url = self.compareUrl { + Button { + openURL(url) + } label: { + HStack(spacing: 8.0) { + Text("View changes") + .bold() - VStack(alignment: .leading, spacing: 8.0) { - Text("Contributors for \(self.version):") - .font(.title2.bold()) - .lineLimit(1) - - HStack { - ForEach(self.authors, id: \.self) { author in - Text(author) - .font(.callout.width(.expanded)) - .padding(.horizontal, 10.0) - .padding(.vertical, 5.0) - .background( - colorScheme == .light ? Color.gray - .opacity(0.3) : Color(uiColor: UIColor.tertiarySystemBackground) - .opacity(0.5) - ) - .clipShape(Capsule()) + Image(systemName: "arrow.up.right.square") + } + .foregroundStyle(Color.white) } + .tint(Color.cider) + .buttonStyle(.borderedProminent) } } - - if let url = self.compareUrl { + .padding() + } + .navigationTitle(Text("Remote v\(self.version)")) + .navigationBarTitleDisplayMode(.large) + .toolbarTitleDisplayMode(.inlineLarge) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button { - openURL(url) + dismiss() } label: { - HStack(spacing: 8.0) { - Text("View changes") - .bold() - - Image(systemName: "arrow.up.right.square") - } - .foregroundStyle(Color.white) + Label("Close", systemImage: "xmark") } - .tint(Color.cider) - .buttonStyle(.borderedProminent) } } - .padding() } } } -// MARK: Changelogs are HERE +// MARK: - Changelogs are HERE extension Changelog { + /// Remote 4.0.0 + static var v400: Changelog { + var temp = Changelog(version: "4.0.0", authors: ["Lumaa", "Deadfrost"], commits: "41dc79a...v4") + temp = temp + .setChanges(additions: [ + "A brand new onboarding screen, showcasing Remote's best features", + "Compatibility with Cider 4.x", + "Cross-compatibility with previous versions of Cider", + "Cider Collective team in the contributors screen", + "New Cider 4.x authentication method (default)", + "Animated album covers", + "Shuffle, repeat and autoplay buttons at the top of the queue (thanks gabrielzv1233!)", + "Horizontal Layout can now have the queue enabled", + "Remote can get lyrics from Taproom's Lyrics Studio (using LyricsStudioKit)", + "Share lyrics by tap-and-holding a lyric (except Immersive Lyrics)", + "Tap a lyric to go and listen to it (except Immersive Lyrics)", + "Left-to-right and right-to-left lyrics (except Immersive Lyrics)", + "Remote now displays the audio quality (Dolby Atmos, Lossless, Hi-Res Lossless...)", + "Added a \"Show Album\" button in the Library Browser when viewing a playlist", + "Slowly moving color gradient in the background", + "Moved close button to the ellipsis menu: \"Close device\"", + "Swipe a Cider device to the left to send your iPhone's/iPad's playing song to Cider", + "New \"Cider Remote\" title integrated in the screen's top bar" + ], modifications: [ + "Temporary?: iOS 26+ only", + "The \"Cider Devices\" title has been replaced with \"Cider Remote\"", + "\"Library Browser\" button is always displayed now", + "A more Apple Music-like user interface when remote-ing", + "Much simpler, less buggy device list", + "Unified design in the changelogs screen and connection guide screen", + "More Liquid Glass", + "Contributors screen moved in the copyright section", + "Smaller \"Library Browser\" button to fit its new location", + "The message about Cider 4.x & Remote v4.0.0 will now re-appear with new text", + "Changed macOS icon for default macOS icon", + "Changed Connection Guide icon", + "Updated Connection Guide for Cider 4.x", + "Connection Steps now uses the Cider color", + "Fix: Live Activity button should work properly whatever the device order", + "Fix: Random crash when opening the QR code scan", + "Fix: Library Browser cover images should load for most of them (still not all...)", + "Fix: Ellipsis button was barely tappable (thanks MatyBachy!)", + "Fix: Lyrics now correctly load", + ], removals: [ + "Lyrics cache", + "Background with blurred song cover", + "\"Use Dynamic Colors\", \"Button Size\" settings, and contributors text in the settings", + "View Models" + ]) + temp = temp.setNotes(headerNote: "Remote v4.0.0 is full redesign of the remote app and goes along with Cider 4's new logistic") + return temp + } + + /// Remote 3.1.1 + static var v311: Changelog { + var temp = Changelog(version: "3.1.1", authors: ["Lumaa"], commits: "2021475...41dc79a") + temp = temp + .setChanges(additions: ["A message about Cider 4 & Remote v4.0.0 will now appear"], modifications: ["Fix: Playlists won't crash the app anymore"]) + return temp + } + /// Remote 3.1.0 static var v310: Changelog { - var temp = Changelog(version: "3.1.0", authors: ["Lumaa"], commits: "7b5dd1...main") + var temp = Changelog(version: "3.1.0", authors: ["Lumaa"], commits: "7b5dd1...2021475") temp = temp .setChanges(additions: [ "iOS 26 support", diff --git a/Cider Remote/Views/ConnectionGuideView.swift b/Cider Remote/Views/ConnectionGuideView.swift new file mode 100644 index 0000000..4cc6683 --- /dev/null +++ b/Cider Remote/Views/ConnectionGuideView.swift @@ -0,0 +1,78 @@ +// Made by Lumaa + +import SwiftUI + +struct ConnectionGuideView: View { + @Environment(\.dismiss) var dismiss: DismissAction + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Prerequisites:") + .font(.title2.bold()) + BulletedList(items: [ + "Cider 2.5.3+ is installed (... > Updates)", + "Cider installed and running on your computer (Windows, macOS, or Linux)", + "Your Device and Cider on the same local network (if using LAN)", + "Cider's RPC & WebSocket server enabled (Settings > Connectivity)" + ]) + + Text("Connection Steps:") + .font(.title2.bold()) + VStack(alignment: .leading, spacing: 15) { + GuideStep(number: 1, text: "Launch the Cider Remote app on your iPhone, and tap the plus icon, in the top right corner.") + GuideStep(number: 2, text: "On Cider on your computer, a consent dialog should have appeared. Allow Cider Remote with all the selected scopes.") + GuideStep(number: 3, text: "If a QR code scanner appeared, on Cider on your computer, visit 'Help > Connect a Remote app' and create a device.") + GuideStep(number: 4, text: "Give a name to your scanned device, make it simple to understand for clarity.") + GuideStep(number: 5, text: "Your iPhone should now be paired with Cider.") + } + + Text("Troubleshooting:") + .font(.title2.bold()) + Text("If you can't connect:") + .font(.callout) + BulletedList(items: [ + "Ensure both devices are on the same network", + "Check if Cider's RPC server is running (port 10767)", + "Restart both Cider and Cider Remote", + "Check firewall settings (see below)" + ]) + + Text("Firewall Settings:") + .font(.title2.bold()) + BulletedList(items: [ + "Windows: Allow Cider through Windows Defender Firewall (Inbound Port 10767)", + "macOS: Add Cider to allowed apps in Security & Privacy > Firewall", + "Linux: Use your distribution's firewall tool to allow port 10767" + ]) + + Text("For QR code scanning issues:") + .font(.title2.bold()) + BulletedList(items: [ + "Check if Remote has access to your camera", + "Ensure the QR code is clearly visible and well-lit", + "Try adjusting the distance between your phone and the screen" + ]) + + Text("For further assistance, please visit our [Discord server](https://discord.gg/applemusic) or [GitHub issues](https://github.com/ciderapp/Cider-Remote/issues) page.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top) + } + .padding() + } + .navigationTitle("Connection Guide") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } + } + } +} diff --git a/Cider Remote/Views/ContentView.swift b/Cider Remote/Views/ContentView.swift index d7f6ac1..9eb4fa7 100644 --- a/Cider Remote/Views/ContentView.swift +++ b/Cider Remote/Views/ContentView.swift @@ -8,8 +8,6 @@ import SwiftUI struct ContentView: View { - @StateObject private var colorScheme = ColorSchemeManager() - @State private var showingSettings = false @StateObject private var prompt: AppPrompt = .shared @@ -37,7 +35,6 @@ struct ContentView: View { } } } - .tint(Color.cider) if !isGlass { if AppPrompt.shared.showingPrompt == .newDevice { @@ -57,7 +54,6 @@ struct ContentView: View { } } } - .environmentObject(colorScheme) .sheet(isPresented: $showingSettings) { SettingsView() } @@ -80,7 +76,11 @@ struct ContentView: View { } } .onAppear { - if !UserDefaults.standard.bool(forKey: "updatePopup") { + #if DEBUG + UserDefaults.standard.removeObject(forKey: "updatePopup_v4") + #endif + + if !UserDefaults.standard.bool(forKey: "updatePopup_v4") { AppPrompt.shared.showingPrompt = .update } } @@ -90,12 +90,12 @@ struct ContentView: View { struct UpdatePromptView: View { var prompt: Prompt { var p: Prompt = Prompt( - symbol: "arrow.down.app.dashed.trianglebadge.exclamationmark", - title: "Remote v4.0.0 & Cider 4", + symbol: "wand.and.sparkles.inverse", + title: "Remote v4.0.0", view: AnyView(self.txt), actionLabel: "OK", action: { - UserDefaults.standard.set(false, forKey: "updatePopup") + UserDefaults.standard.set(false, forKey: "updatePopup_v4") } ) return p.cancellable(false) @@ -108,9 +108,9 @@ struct UpdatePromptView: View { } var txt: some View { - Text("The upcoming Cider version, Cider 4, might cause compatibility issues with Cider Remote v3.1.1 and less. Please remember to **update Cider Remote** along with Cider to have the best music-listening experience.\n\nYou will not be shown this message later.") + Text("Welcome to Remote Beta v4.0.0. Please remember to report bugs and issues within the TestFlight feedback feature or GitHub issues. Cheers, Lumaa") .font(.subheadline) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .padding(.horizontal) } } @@ -187,7 +187,7 @@ struct FriendlyNamePromptView: View { version: connectionInfo.initialData.version, platform: connectionInfo.initialData.platform, backend: connectionInfo.initialData.platform, // Using platform as backend for now - connectionMethod: connectionInfo.method.rawValue, + connectionMethod: connectionInfo.method, isActive: false, os: connectionInfo.initialData.os ) @@ -237,18 +237,6 @@ struct CameraPromptView: View { } } -struct LazyView: View { - let build: () -> Content - - init(_ build: @autoclosure @escaping () -> Content) { - self.build = build - } - - var body: Content { - build() - } -} - struct StatusIndicator: View { let status: DeviceStatus @@ -281,78 +269,11 @@ struct StatusIndicator: View { } } -struct ConnectionGuideView: View { - @Environment(\.presentationMode) var presentationMode - - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - Text("Prerequisites:") - .font(.headline) - BulletedList(items: [ - "Cider 2.5.3+ is installed (... > Updates)", - "Cider installed and running on your computer (Windows, macOS, or Linux)", - "Your Device and Cider on the same local network (If using LAN)", - "Cider's RPC & WebSocket server enabled (Settings > Connectivity)" - ]) - - Text("Connection Steps:") - .font(.headline) - VStack(alignment: .leading, spacing: 15) { - GuideStep(number: 1, text: "Open Cider Remote: Launch the Cider Remote app on your iPhone.") - GuideStep(number: 2, text: "Prepare Cider: Open Cider on your Computer, tap the '...' menu, and visit 'Help > Connect a Remote' and create a device. (Some devices may prefer WAN over LAN.)") - GuideStep(number: 3, text: "Scan QR Code: In Cider Remote, tap 'Add a New Cider Device' and use the camera to scan the QR code displayed in Cider.") - GuideStep(number: 4, text: "Confirm Connection: Your iPhone should now be paired with Cider.") - } - - Text("Troubleshooting:") - .font(.headline) - Text("If you can't connect:") - .font(.subheadline) - BulletedList(items: [ - "Ensure both devices are on the same network", - "Check if Cider's RPC server is running (port 10767)", - "Restart both Cider and Cider Remote", - "Check firewall settings (see below)" - ]) - - Text("Firewall Settings:") - .font(.subheadline) - BulletedList(items: [ - "Windows: Allow Cider through Windows Defender Firewall (Inbound Port 10767)", - "macOS: Add Cider to allowed apps in Security & Privacy > Firewall", - "Linux: Use your distribution's firewall tool to allow port 10767" - ]) - - Text("For QR code scanning issues:") - .font(.subheadline) - BulletedList(items: [ - "Ensure the code is clearly visible and well-lit", - "Try adjusting the distance between your phone and the screen" - ]) - - Text("For further assistance, please visit our support forum or GitHub issues page.") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top) - } - .padding() - } - .navigationBarTitle("Connection Guide") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: Button("Close") { - presentationMode.wrappedValue.dismiss() - }) - } - } -} - struct DeviceIconView: View { let device: Device var body: some View { - Image(uiImage: deviceImage) + deviceImage .resizable() .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) @@ -361,17 +282,18 @@ struct DeviceIconView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } - var deviceImage: UIImage { - let osType = device.os ?? device.platform - switch osType.lowercased() { - case "win32": - return UIImage(named: "Windows") ?? UIImage(systemName: "desktopcomputer")! - case "darwin": - return UIImage(named: "macOS") ?? UIImage(systemName: "desktopcomputer")! - case "linux": - return UIImage(named: "Linux") ?? UIImage(systemName: "desktopcomputer")! - default: - return UIImage(systemName: "desktopcomputer")! + var deviceImage: Image { + let osType = device.os + + switch osType { + case .windows: + return Image("Windows") + case .macos: + return Image("macOS") + case .linux: + return Image("Linux") + default: + return Image(systemName: "desktopcomputer") } } } diff --git a/Cider Remote/Views/ContributorsView.swift b/Cider Remote/Views/ContributorsView.swift index 226b3cf..2084668 100644 --- a/Cider Remote/Views/ContributorsView.swift +++ b/Cider Remote/Views/ContributorsView.swift @@ -1,5 +1,5 @@ // Made by Lumaa -// For current contributors & future contributors +// For current, past and future contributors import SwiftUI @@ -15,7 +15,7 @@ struct ContributorsView: View { var body: some View { List { if !fetchingData && fetchedContribs.count > 0 { - Section(footer: Text("From the official [Cider Remote repository](https://github.com/ciderapp/Cider-Remote), tap on a user's profile to know more about their coding experience and GitHub repository.")) { + Section(header: Text(String("Cider Remote")),footer: Text("From the official [Cider Remote repository](https://github.com/ciderapp/Cider-Remote), tap on a user's profile to know more about their coding experience and GitHub repository.")) { ForEach(self.fetchedContribs) { contrib in Button { openURL(contrib.ghLink) @@ -44,15 +44,39 @@ struct ContributorsView: View { } } .listRowBackground(Color.clear) + .frame(maxWidth: .infinity, alignment: .center) + } + + Section(header: Text(String("Cider Collective"))) { + LazyVGrid(columns: [.init(.fixed(170)), .init(.fixed(170))]) { + ForEach(Contrib.collective) { contrib in + Button { + openURL(contrib.ghLink) + } label: { + self.collectiveView(contrib) + } + .tint(Color(uiColor: UIColor.label)) + .buttonStyle(.plain) + } + } + + Button { + if let url = URL(string: "https://cider.sh/about") { + openURL(url) + } + } label: { + Text("More about Cider Collective") + } } } + .listSectionSpacing(30) .navigationTitle(Text("Contributors")) .navigationBarTitleDisplayMode(.large) } @ViewBuilder private func contribView(_ contrib: Self.Contrib) -> some View { - let imageSize: CGFloat = 35.0 + let imageSize: CGFloat = 45.0 HStack { AsyncImage(url: contrib.pfp) { image in @@ -72,14 +96,43 @@ struct ContributorsView: View { .font(.title2.bold()) .lineLimit(1) - Text("^[\(contrib.commitCount) contribution](inflect: true)") // auto pluralizes - .font(.caption) - .foregroundStyle(Color.gray) + if contrib.commitCount > 0 { + Text("^[\(contrib.commitCount) contribution](inflect: true)") // auto pluralizes + .font(.caption) + .foregroundStyle(Color.gray) + } else { + Text("No contributions") + .font(.caption) + .foregroundStyle(Color.gray) + } } .padding(.horizontal) } } + @ViewBuilder + private func collectiveView(_ contrib: Self.Contrib) -> some View { + let imageSize: CGFloat = 65.0 + + VStack(alignment: .center) { + AsyncImage(url: contrib.pfp) { image in + image + .resizable() + .scaledToFit() + .frame(width: imageSize, height: imageSize) + .clipShape(Circle()) + } placeholder: { + Circle() + .fill(Color.gray.opacity(0.2)) + .frame(width: imageSize, height: imageSize) + } + + Text(contrib.name) + .font(.title2.bold()) + .lineLimit(1) + } + } + /// Get the ciderapp/Cider-Remote's contributors list private func getContributors() async throws -> [Self.Contrib]? { // 20s timeout - no cookies cause no tracking @@ -114,7 +167,22 @@ struct ContributorsView: View { let pfp: URL? let commitCount: Int - init(id: String, name: String, ghLink: URL, commits: Int = 0, pfp: URL? = nil) { + /// Cider Collective (05/23/2026) + static let collective: [Contrib] = [ + .init(name: "cryptofyre", ghLink: URL(string: "https://github.com/cryptofyre")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/33162551?v=4")), + .init(name: "Core", ghLink: URL(string: "https://github.com/coredev-uk")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/64542347?v=4")), + .init(name: "booploops", ghLink: URL(string: "https://github.com/booploops")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/49113086?v=4")), + .init(name: "Maikiwi", ghLink: URL(string: "https://github.com/maikirakiwi")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/74925636?v=4")), + .init(name: "yazninja", ghLink: URL(string: "https://github.com/yazninja")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/71800112?v=4")), + .init(name: "luckieluke", ghLink: URL(string: "https://github.com/lockieluke")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/25424409?v=4")), + .init(name: "Monochromish", ghLink: URL(string: "https://github.com/Monochromish")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/79590499?v=4")), + .init(name: "Quacksire", ghLink: URL(string: "https://github.com/quacksire")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/19170969?v=4")), + .init(name: "Amaru", ghLink: URL(string: "https://github.com/Amaru8")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/52407090?v=4")), + .init(name: "Swiftzerr", ghLink: URL(string: "https://github.com/elliotjarnit")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/67812203?v=4")), + .init(name: "Lumaa", ghLink: URL(string: "https://github.com/lumaa-dev")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/93350976?v=4")) + ] + + init(id: String = UUID().uuidString, name: String, ghLink: URL, commits: Int = 0, pfp: URL? = nil) { self.id = id self.name = name self.ghLink = ghLink diff --git a/Cider Remote/Views/Devices/AddDeviceView.swift b/Cider Remote/Views/Devices/AddDeviceView.swift index 7455da6..9e47256 100644 --- a/Cider Remote/Views/Devices/AddDeviceView.swift +++ b/Cider Remote/Views/Devices/AddDeviceView.swift @@ -9,42 +9,85 @@ struct AddDeviceView: View { @State private var jsonTxt: String = "" - var fetchAction: (String) -> Void + @State private var authenticating: Bool = false + @State private var hasError: Bool = false + @State private var errorItem: AuthRequest.Error? = nil + + var fetchAction: (ConnectionInfo) -> Void var body: some View { Button { - let status = AVCaptureDevice.authorizationStatus(for: .video) - var isAuthorized = status == .authorized - - if isAuthorized { - isShowingScanner = true - } else { - if status == .notDetermined { - Task { - isAuthorized = await AVCaptureDevice.requestAccess(for: .video) - - if isAuthorized { - isShowingScanner = true - } - } - } else { - AppPrompt.shared.showingPrompt = .accesCamera - } - } + Task { + do { + self.authenticating = true + let authRes: Any = try await self.sendAuth() + if authRes is AuthRequest.Error { + self.authenticating = false + self.errorItem = (authRes as! AuthRequest.Error) + self.hasError = true + } else if let res = authRes as? [String: Any] { + guard let data: Data = try? JSONSerialization.data(withJSONObject: res), let authAllow: AuthRequest.Result = try? JSONDecoder().decode(AuthRequest.Result.self, from: data) else { throw NetworkError.decodingError } + self.authenticating = false + + let newInfo: ConnectionInfo = try await authAllow.getConnection() + return self.fetchAction(newInfo) + } else { + throw NetworkError.decodingError + } + } catch { + print(error) + + // if we're here, it probably means that `tryAuth` didn't return an auth error, but threw an http error + self.authenticating = false + await self.useScanner() + } + } } label: { - Label("Add New Cider Device", systemImage: "plus.circle") + if self.authenticating { + ProgressView() + } else { + Label("Add New Cider Device", systemImage: "plus") + .foregroundStyle(Color.cider) + } } + .disabled(self.authenticating) + .alert("Integration Failed", isPresented: $hasError) { + Button(role: .confirm) { + Task { + await self.useScanner() + } + } label: { + Text("Scan a QR code") + } + + Button(role: .cancel) {} + } message: { + if let errorItem { + Text("Error \(errorItem.code): \(errorItem.description)") + } else { + Text("Unknown Error") + } + } .sheet(isPresented: $isShowingScanner) { #if targetEnvironment(simulator) VStack { Text(String("Enter the JSON below:")) - TextField(String("{\"address\":\"123.456.7.89\",\"token\":\"abcdefghijklmnopqrstuvwx\",\"method\":\"lan\",\"initialData\":{\"version\":\"2.0.3\",\"platform\":\"genten\",\"os\":\"darwin\"}}"), text: $jsonTxt) + TextField(String("{\"address\":\"123.456.7.89\",\"token\":\"abcdefghijklmnopqrstuvwx\",\"method\":\"lan\",\"initialData\":{\"version\":\"400\",\"platform\":\"genten\",\"os\":\"darwin\"}}"), text: $jsonTxt) .padding() .textFieldStyle(.roundedBorder) Button { - fetchAction(jsonTxt) - isShowingScanner = false + self.jsonTxt = "{\"address\":\"\",\"token\":\"\",\"method\":\"lan\",\"initialData\":{\"version\":\"400\",\"platform\":\"genten\",\"os\":\"darwin\"}}" + } label: { + Text(String("Sample Data (add token & address)")) + } + .buttonStyle(.bordered) + + Button { + if let fetched = self.fetchDevices(from: jsonTxt) { + fetchAction(fetched) + isShowingScanner = false + } } label: { Text(String("Fetch device")) } @@ -53,24 +96,6 @@ struct AddDeviceView: View { #else if AVCaptureDevice.authorizationStatus(for: .video) == .authorized { QRScannerView(scannedCode: $scannedCode) - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - Text("Scan the Remote QR code") - .font(.caption) - .padding(.horizontal) - .padding(.vertical, 7.5) - .glassEffect() - .padding(.top, 22.5) - } else { - Text("Scan the Remote QR code") - .font(.caption) - .padding(.horizontal) - .padding(.vertical, 7.5) - .background(Material.thin) - .clipShape(.rect(cornerRadius: 15.5)) - .padding(.top, 22.5) - } - } } else { Text("Cider Remote cannot access the camera") .font(.title2.bold()) @@ -79,12 +104,89 @@ struct AddDeviceView: View { #endif } .onChange(of: scannedCode) { _, newValue in - if let code = newValue { - fetchAction(code) + if let code = newValue, let fetched = self.fetchDevices(from: code) { + fetchAction(fetched) isShowingScanner = false } } } + + private func useScanner() async { + let status = AVCaptureDevice.authorizationStatus(for: .video) + var isAuthorized = status == .authorized + + if isAuthorized { + isShowingScanner = true + } else { + if status == .notDetermined { + isAuthorized = await AVCaptureDevice.requestAccess(for: .video) + + if isAuthorized { + isShowingScanner = true + } + } else { + AppPrompt.shared.showingPrompt = .accesCamera + } + } + } + + private func sendAuth(authRequest: AuthRequest = .remoteRequest) async throws -> Any { + guard let url = URL(string: "http://localhost:\(Int.defaultPort)/api/v2/auth/request") else { + throw NetworkError.invalidURL + } + + print("Sending request to: \(url.absoluteString)") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + + if let data = try? JSONEncoder().encode(authRequest), let body = try? JSONSerialization.jsonObject(with: data) { + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + print("Request body: \(body)") + } + + let (data, response) = try await URLSession.shared.data(for: request) + print("Response raw: \(String(data: data, encoding: .utf8) ?? "[No data]")") + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + + print("Response status code: \(httpResponse.statusCode)") + + guard (200...299).contains(httpResponse.statusCode) else { + if let authError: AuthRequest.Error = AuthRequest.Error.matchCode(with: httpResponse.statusCode) { + return authError + } else { + throw NetworkError.serverError("Server responded with status code \(httpResponse.statusCode)") + } + } + + let json = try JSONSerialization.jsonObject(with: data, options: []) + let jsonData = (json as! [String: Any])["data"]! + print(jsonData) + return jsonData + } + + func fetchDevices(from jsonString: String) -> ConnectionInfo? { + print("Received JSON string: \(jsonString)") // Log the received JSON string + + guard let jsonData = jsonString.data(using: .utf8) else { + print("Error: Unable to convert JSON string to Data") + AppPrompt.shared.showingPrompt = .oldDevice + return nil + } + + do { + let connectionInfo = try JSONDecoder().decode(ConnectionInfo.self, from: jsonData) + return connectionInfo + } catch { + print("Error decoding ConnectionInfo: \(error)") + AppPrompt.shared.showingPrompt = .oldDevice + return nil + } + } } class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { @@ -162,7 +264,10 @@ class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsD // Create a backdrop view var backdropView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) if #available(iOS 26.0, *) { - backdropView = UIVisualEffectView(effect: UIGlassEffect()) + let glass: UIGlassEffect = UIGlassEffect(style: .regular) + glass.isInteractive = true + + backdropView = UIVisualEffectView(effect: glass) } backdropView.layer.cornerRadius = closeButtonSize / 2 @@ -211,7 +316,8 @@ class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsD private func startRunning() { DispatchQueue.global(qos: .background).async { [weak self] in - self?.captureSession.startRunning() + guard let self else { return } + self.captureSession.startRunning() } } diff --git a/Cider Remote/Views/Devices/DeviceRowView.swift b/Cider Remote/Views/Devices/DeviceRowView.swift index 5d36406..829e22d 100644 --- a/Cider Remote/Views/Devices/DeviceRowView.swift +++ b/Cider Remote/Views/Devices/DeviceRowView.swift @@ -4,6 +4,7 @@ import SwiftUI struct DeviceRowView: View { @ObservedObject var device: Device + var hasCurrentMusic: Bool = false @AppStorage("deviceDetails") private var deviceDetails: Bool = false @@ -24,11 +25,11 @@ struct DeviceRowView: View { VStack(alignment: .leading, spacing: 4) { Text(device.friendlyName) - .font(.headline) + .font(.title2.bold()) .lineLimit(1) if deviceDetails { Text("\(device.version) | \(device.platform)") - .font(.subheadline) + .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) Text("Host: \(device.host)") @@ -39,6 +40,13 @@ struct DeviceRowView: View { } Spacer() + + if hasCurrentMusic && device.isActive { + Image(systemName: "music.note") + .foregroundStyle(.secondary) + .font(.caption) + .padding(.trailing, 8) + } StatusIndicator(status: self.status) } diff --git a/Cider Remote/Views/Devices/DevicesView.swift b/Cider Remote/Views/Devices/DevicesView.swift index be8a523..22cb6e9 100644 --- a/Cider Remote/Views/Devices/DevicesView.swift +++ b/Cider Remote/Views/Devices/DevicesView.swift @@ -5,69 +5,159 @@ import SwiftUI struct DevicesView: View { @Environment(\.dismiss) private var dismiss: DismissAction - private var devices: [Device] { - DeviceManager.shared.devices - } - @State var isRefreshing: Bool = false - @AppStorage("refreshInterval") private var refreshInterval: Double = 10.0 + @State private var isRefreshing: Bool = false + @State private var viewingDevice: Device? = nil + @State private var scannedCode: String? @State private var isShowingScanner = false @State private var isShowingGuide = false @State private var activityCheckTimer: Timer? = nil + + // Send to Cider functionality + @StateObject private var currentMusicService = CurrentMusicService.shared + @State private var showingSendToCiderAlert = false + @State private var selectedDeviceForSending: Device? + @State private var isSendingToCider = false + @State private var sendResultAlert: SendResultAlert? = nil - var body: some View { - VStack(spacing: 0) { - header + private var devices: [Device] { + DeviceManager.shared.devices + } - List { + var body: some View { + List { + if devices.count > 0 { ForEach(devices) { device in - NavigationLink(value: device) { - DeviceRowView(device: device) + Button { + self.viewingDevice = device + } label: { + DeviceRowView(device: device, hasCurrentMusic: currentMusicService.currentTrack?.hasValidData == true) } - .swipeActions(edge: .trailing) { + .tint(Color.primary) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { DeviceManager.shared.remove(device) } label: { Label("Delete", systemImage: "trash") } + .tint(Color.red) + + Button { + selectedDeviceForSending = device + currentMusicService.updateCurrentTrack() + // Add a small delay to allow track info to update + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + showingSendToCiderAlert = true + } + } label: { + Label("Send to Cider", systemImage: "music.note.list") + } + .tint(Color.pink) // AM color? allegedly + .disabled(!(currentMusicService.currentTrack?.hasValidData ?? false) || !device.isActive) } } - - AddDeviceView(isShowingScanner: $isShowingScanner, scannedCode: $scannedCode) { json in - self.fetchDevices(from: json) + } else { + ContentUnavailableView { + Text("No devices") + .bold() + } description: { + Text("Add a Cider device by tapping the plus icon in the top right corner. Troubleshoot errors with the button next to it.") } + .listRowInsets(.all, 0.0) + .listRowSpacing(0.0) + .listRowBackground(Color.clear) + } + } + .listStyle(.insetGrouped) + .task { + await self.refreshDevices() + } + .refreshable { + await self.refreshDevices() + } - Button(action: { + .toolbar { + ToolbarItem(placement: .principal) { + header + } + + ToolbarItem(placement: .topBarTrailing) { + Button { isShowingGuide = true - }) { - Label("Connection Guide", systemImage: "questionmark.circle") + } label: { + Label("Connection Guide", systemImage: "book.and.wrench") + .foregroundStyle(Color.cider) } } - .listStyle(InsetGroupedListStyle()) - .task { - await self.refreshDevices() - } - .refreshable { - await self.refreshDevices() + + ToolbarSpacer(.fixed, placement: .topBarTrailing) + + ToolbarItem(placement: .topBarTrailing) { + AddDeviceView(isShowingScanner: $isShowingScanner, scannedCode: $scannedCode) { connection in + DeviceManager.shared.connectionInfo = connection + AppPrompt.shared.showingPrompt = .newDevice + } + .buttonStyle(.glassProminent) + .tint(Color.cider) } -#if DEBUG - Label("This is a DEBUG version.", systemImage: "gearshape.2.fill") - .foregroundStyle(.orange) - .accessibility(label: Text("Debug software")) -#endif } .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: Device.self) { device in - LazyView(MusicPlayerView(device: device)) + .navigationDestination(item: $viewingDevice) { device in + MusicPlayerView(device: device) + .tint(Color.cider) } .sheet(isPresented: $isShowingGuide) { ConnectionGuideView() } + .alert("Send to Cider", isPresented: $showingSendToCiderAlert) { + Button("Cancel", role: .cancel) { } + if !currentMusicService.hasMediaAccess { + Button("Grant Permission") { + // This will trigger permission request + currentMusicService.updateCurrentTrack() + } + } else if let track = currentMusicService.currentTrack, track.hasValidData { + Button("Send") { + if let device = selectedDeviceForSending { + sendCurrentMusicToCider(device: device) + } + } + .disabled(isSendingToCider) + } else { + Button("Refresh") { + currentMusicService.updateCurrentTrack() + // Re-show alert after refresh + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + showingSendToCiderAlert = true + } + } + } + } message: { + if !currentMusicService.hasMediaAccess { + Text("This app needs permission to access your media library to detect currently playing music. Please grant permission to continue.") + } else if let track = currentMusicService.currentTrack, track.hasValidData { + if isSendingToCider { + Text("Sending \"\(track.title)\" by \(track.artist) to \(selectedDeviceForSending?.friendlyName ?? "Cider")...") + } else { + Text("Send \"\(track.title)\" by \(track.artist) to \(selectedDeviceForSending?.friendlyName ?? "Cider")?") + } + } else { + Text("No music is currently playing on this device. Start playing music in Apple Music, Spotify, or another music app, then try 'Refresh'.") + } + } + .alert(item: $sendResultAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) + ) + } .onAppear { self.startActivityChecking() + currentMusicService.updateCurrentTrack() } .onDisappear { self.stopActivityChecking() @@ -81,13 +171,11 @@ struct DevicesView: View { .scaledToFit() .frame(height: 40) - Text("Cider Devices") + Text("Remote") .font(.title2) .fontWeight(.bold) } - .padding() .frame(maxWidth: .infinity) - .background(Material.ultraThick) } @MainActor @@ -115,25 +203,6 @@ struct DevicesView: View { isRefreshing = false } - func fetchDevices(from jsonString: String) { - print("Received JSON string: \(jsonString)") // Log the received JSON string - - guard let jsonData = jsonString.data(using: .utf8) else { - print("Error: Unable to convert JSON string to Data") - AppPrompt.shared.showingPrompt = .oldDevice - return - } - - do { - let connectionInfo = try JSONDecoder().decode(ConnectionInfo.self, from: jsonData) - DeviceManager.shared.connectionInfo = connectionInfo - AppPrompt.shared.showingPrompt = .newDevice - } catch { - print("Error decoding ConnectionInfo: \(error)") - AppPrompt.shared.showingPrompt = .oldDevice - } - } - private func finishRefreshing() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.isRefreshing = false @@ -155,4 +224,36 @@ struct DevicesView: View { activityCheckTimer?.invalidate() activityCheckTimer = nil } + + private func sendCurrentMusicToCider(device: Device) { + guard !isSendingToCider else { return } + + isSendingToCider = true + + Task { + let success = await currentMusicService.sendToCider(device: device) + + await MainActor.run { + isSendingToCider = false + + if success { + sendResultAlert = SendResultAlert( + title: "Success", + message: "Successfully sent music to \(device.friendlyName)" + ) + } else { + sendResultAlert = SendResultAlert( + title: "Failed", + message: "Could not send music to \(device.friendlyName). Make sure the device is connected and try again." + ) + } + } + } + } +} + +struct SendResultAlert: Identifiable { + let id = UUID() + let title: String + let message: String } diff --git a/Cider Remote/Views/LyricShare.swift b/Cider Remote/Views/LyricShare.swift new file mode 100644 index 0000000..f300103 --- /dev/null +++ b/Cider Remote/Views/LyricShare.swift @@ -0,0 +1,155 @@ +// Made by Lumaa + +import SwiftUI + +struct LyricShare: View { + @Environment(\.dismiss) private var dismiss: DismissAction + + @State var track: Track + + @State private var bg: [Color] = [] + @State private var albumCover: UIImage = .init() + + @State private var sharingImage: UIImage? = nil + + private static let width: CGFloat = 300.0 + + let lyric: LyricLine + let showToolbar: Bool + + init(track: Track, lyric: LyricLine, showToolbar: Bool = true) { + self.track = track + self.lyric = lyric + self.showToolbar = showToolbar + } + + var body: some View { + NavigationStack { + self.foreground() + .toolbar { + if showToolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel) { + self.dismiss() + } + .tint(Color.white) + } + + ToolbarItem(placement: .confirmationAction) { + Button { + self.imageify() + } label: { + Label("Share lyric", systemImage: "square.and.arrow.up") + } + } + } + } + } + .task { + await self.handleColors() + } + .sheet(item: $sharingImage) { image in + ActivityViewController(item: .image(images: [image])) + .presentationDetents([.medium, .large]) + } + } + + @ViewBuilder + private func foreground(scalePlate: Double = 1.0) -> some View { + ZStack { + Rectangle() + .fill(Color.black) + .ignoresSafeArea() + + if bg.count == 25 { + AnimatedMeshGradientView(colors: $bg, amplify: 0.25) + .ignoresSafeArea() + .opacity(0.3) + } + + self.songPlate + .clipShape(RoundedRectangle(cornerRadius: 15.0, style: .continuous)) + .scaleEffect(scalePlate) + } + } + + @ViewBuilder + private var songPlate: some View { + VStack(spacing: 0) { + Text(lyric.text) + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(Color.white.opacity(0.8)) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(nil) + .multilineTextAlignment(lyric.altVoice ? .trailing : .leading) + .frame(width: Self.width, alignment: lyric.altVoice ? .trailing : .leading) + .padding(15.0) + .background(Color.black.opacity(0.25)) + + HStack { + Image(uiImage: self.albumCover) + .resizable() + .scaledToFit() + .frame(width: 35, height: 35) + .clipShape(RoundedRectangle(cornerRadius: 3.0)) + + VStack(alignment: .leading) { + Text(self.track.title) + .foregroundStyle(Color.white) + .font(.callout.bold()) + .lineLimit(1) + + Text(self.track.artist) + .foregroundStyle(Color.secondary) + .lineLimit(1) + } + + Spacer() + + Image("Logo") + .resizable() + .scaledToFit() + .frame(width: 35, height: 35) + + Text("Remote") + .font(.callout.bold()) + .foregroundStyle(Color.white) + } + .frame(width: Self.width) + .padding(15.0) + .background(Color.black.opacity(0.55)) + .environment(\.colorScheme, ColorScheme.dark) + } + } + + private func imageify() { + let portrait = self.foreground(scalePlate: 2.3) + .aspectRatio(9 / 16, contentMode: .fit) + .frame(width: 1080, height: 1920, alignment: .center) + + let image = ImageRenderer(content: portrait) + self.sharingImage = image.uiImage + } + + private func handleColors() async { + if let artwork: UIImage = await self.loadArtwork() { + self.albumCover = artwork + let colors: [Color] = artwork.dominantColors(count: 25) + self.bg = colors.shuffled() + } + } + + func loadArtwork() async -> UIImage? { + let url: URL = URL(string: self.track.artwork)! + + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let image = UIImage(data: data) { + return image + } + } catch { + print("Error loading image: \(error)") + } + return nil + } +} diff --git a/Cider Remote/Views/MusicPlayer/LyricsView.swift b/Cider Remote/Views/MusicPlayer/LyricsView.swift index eddd70a..2b92618 100644 --- a/Cider Remote/Views/MusicPlayer/LyricsView.swift +++ b/Cider Remote/Views/MusicPlayer/LyricsView.swift @@ -1,29 +1,39 @@ // Made by Lumaa import SwiftUI +import LyricsStudioKit +import MusicKit struct LyricsView: View { @Environment(\.dismiss) private var dismiss: DismissAction @Environment(\.colorScheme) var colorScheme: ColorScheme - @EnvironmentObject var colorSchemeManager: ColorSchemeManager - - @ObservedObject var viewModel: MusicPlayerViewModel @ObservedObject private var userDevice: UserDevice = .shared + var device: Device + @Binding var currentTrack: Track? + @Binding var currentTime: Double + + @State private var lyrics: [LyricLine] = [] + @State private var lyricCache: [String: [LyricLine]] = [:] + @State private var lyricsProvider: Parser.LyricProvider? @State private var activeLine: LyricLine? + @State private var isLoading: Bool = false + private let lineSpacing: CGFloat = 18 // Increased spacing between lines - private let lyricAdvanceTime: Double = 0.3 // Advance lyrics 0.5 seconds early + public static let lyricAdvanceTime: Double = 0.2 // Advance lyrics 0.2 seconds early private var lyricProviderString: String? { - guard let prov = viewModel.lyricsProvider else { return nil } + guard let lyricsProvider else { return nil } - switch prov { + switch lyricsProvider { case .mxm: return "Musixmatch" case .am: return "Apple Music" + case .studio: + return "User Submitted" case .cache: return "Remote (Cache)" } @@ -33,93 +43,74 @@ struct LyricsView: View { GeometryReader { geometry in ZStack { VStack(spacing: 0) { - Divider().padding(.horizontal, 20) - - - if let lyrics = viewModel.lyrics { + if !self.isLoading { if lyrics.isEmpty { - Spacer() - - VStack { - if #available(iOS 17.0, *) { - ContentUnavailableView("No lyrics available", systemImage: "quote.bubble") - } else { - Text("No lyrics available") - .font(.system(size: 18)) - .foregroundStyle(.secondary) - .padding() - } - } - - Spacer() + ContentUnavailableView("No lyrics available", systemImage: "quote.bubble") + .frame(maxHeight: .infinity) } else { ZStack { if userDevice.horizontalOrientation == .portrait || userDevice.isPad { LyricsScrollView( lyrics: lyrics, + track: currentTrack, activeLine: $activeLine, - currentTime: $viewModel.currentTime, + currentTime: $currentTime, viewportHeight: geometry.size.height, - lineSpacing: lineSpacing + lineSpacing: lineSpacing, + changeTime: seekToTime ) + .environmentObject(device) } else { ImmersiveLyricsView( lyrics: lyrics, activeLine: $activeLine, - currentTime: $viewModel.currentTime + currentTime: $currentTime ) } } - .overlay(alignment: .bottom) { + .overlay(alignment: UserDevice.shared.horizontalOrientation.isPortrait() ? .bottom : .top) { if let lyricProviderString { - if #available(iOS 26.0, *) { - Text(lyricProviderString) - .font(.callout) - .padding(.horizontal) - .padding(.vertical, 7.5) - .glassEffect(.regular, in: .capsule) - .padding(.bottom, 22.5) - } else { - Text(lyricProviderString) - .font(.callout) - .padding(.horizontal) - .padding(.vertical, 7.5) - .background(Material.thin) - .clipShape(.capsule) - .padding(.bottom, 22.5) - } + Text(lyricProviderString) + .font(.callout) + .padding(.horizontal) + .padding(.vertical, 7.5) + .glassEffect(.regular, in: .capsule) + .padding(.bottom, 22.5) } } } } else { - Spacer() - ProgressView() .progressViewStyle(.circular) - - Spacer() + .foregroundStyle(Color.primary) + .frame(maxHeight: .infinity) } } .frame(width: geometry.size.width) } } .foregroundStyle(colorScheme == .dark ? .white : .black) - .onAppear { - if viewModel.lyrics == nil { - Task { - await viewModel.fetchAllLyrics() - } - } + .task { + await self.fetchAllLyrics() } - .onDisappear { + .onChange(of: currentTime) { _, newTime in + updateCurrentLyric(time: newTime + Self.lyricAdvanceTime) } - .onChange(of: viewModel.currentTime) { _, newTime in - updateCurrentLyric(time: newTime + lyricAdvanceTime) + } + + // MARK: - Methods + + private func seekToTime(to newTime: Double) async { + print("Seeking to time: \(newTime)") + do { + _ = try await device.sendRequest(endpoint: "playback/seek", method: "POST", body: ["position": newTime]) + } catch { + print(error) } } private func updateCurrentLyric(time: Double) { - guard let lyrics = viewModel.lyrics, let currentLine = lyrics.last(where: { $0.timestamp <= time }) else { + guard let currentLine = lyrics.last(where: { $0.timestamp <= time }) else { activeLine = nil return } @@ -128,17 +119,185 @@ struct LyricsView: View { activeLine = currentLine } } + + + private func fetchAllLyrics() async { + defer { self.isLoading = false } + self.isLoading = true + + guard await self.fetchLyricsStudio() == false else { return } // Cider Lyrics Studio (powered by LyricsStudioKit) + + guard await self.fetchLyricsAm() == false else { return } + + guard await self.fetchLyricsMxm() == false else { return } + } + + /// Returns true if the lyrics were found and fetched + private func fetchLyricsMxm() async -> Bool { + guard let currentTrack else { return false } + + print("Current track ID: \(currentTrack.id)") + + if let cachedLyrics = lyricCache[currentTrack.id] { + print("Using cached lyrics for track: \(currentTrack.id)") + self.lyricsProvider = .cache + self.lyrics = cachedLyrics + return true + } + + self.lyrics = [] + self.isLoading = true + guard let lyricsUrl = URL(string: "https://rise.cider.sh/api/v1/lyrics/mxm") else { return false } + + do { + print("Fetching lyrics ONLINE for track: \(currentTrack.id)") + + let lyricReq: Track.RequestLyrics = .init(track: currentTrack) + let encoder: JSONEncoder = .init() + let body: Data = try encoder.encode(lyricReq) + + var req = URLRequest(url: lyricsUrl, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: .infinity) + req.addValue("application/json", forHTTPHeaderField: "Content-Type") + + req.httpMethod = "POST" + req.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: req) + + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + let decoder: JSONDecoder = .init() + print(String(data: data, encoding: .utf8) ?? "wtf?") + let mxm = try decoder.decode(Track.MxmLyrics.self, from: data) + + let lines = mxm.decodeHtml() + print("Parsed \(lines.count) lyric lines") + if lines.count > 0 { + DispatchQueue.main.async { + self.lyricsProvider = .mxm + self.lyrics = lines + self.lyricCache[currentTrack.id] = self.lyrics + } + return true + } + } else { + self.lyrics = [] + throw NetworkError.serverError("Couldn't reach server") + } + } catch { + self.lyrics = [] + print(error) + } + return false + } + + /// Returns true if the lyrics were found and fetched + private func fetchLyricsAm() async -> Bool { + guard let currentTrack else { return false } + + print("Current track ID: \(currentTrack.id)") + + if let cachedLyrics = lyricCache[currentTrack.id] { + print("Using cached lyrics for track: \(currentTrack.id)") + self.lyricsProvider = .cache + self.lyrics = cachedLyrics + return true + } + + do { + guard let storefront = await self.getStorefront() else { return false } + + print("Fetching lyrics FROM CLIENT for track: \(currentTrack.id)") + let path: String = "/v1/catalog/\(storefront)/songs/\(currentTrack.catalogId)/lyrics?l=en-US&platform=web&art[url]=f" + let data = try await device.sendRequest(endpoint: "amapi/run-v3", method: "POST", body: ["path": path], version: "v1") + + if let jsonDict = (data as? [String: Any])?["data"] as? [[String: Any]], let lyricsData = jsonDict[0]["attributes"] as? [String: Any] { + guard let lyricsXml = lyricsData["ttml"] as? String, let data = lyricsXml.data(using: .utf8) else { + print("-- No TTML --") + throw NetworkError.decodingError + } + + let xmlParser = XMLParser(data: data) + let ttmlParser = Parser(provider: .am) + xmlParser.delegate = ttmlParser + xmlParser.parse() + + self.lyricsProvider = .am + self.lyrics = ttmlParser.lyrics + self.lyricCache[currentTrack.id] = self.lyrics + return true + } else { + throw NetworkError.invalidResponse + } + } catch { + print("Error fetching lyrics: \(error)") + } + return false + } + + private func fetchLyricsStudio() async -> Bool { + guard let currentTrack else { return false } + + print("Current track catalog: \(currentTrack.catalogId)") + + do { + let result: StudioLyricResponse = try await LyricsStudio.fetchLyrics(for: MusicItemID(rawValue: currentTrack.catalogId)) + + guard let data: Data = result.ttml.data(using: .utf8) else { throw NetworkError.decodingError } + + let xmlParser = XMLParser(data: data) + let ttmlParser = Parser(provider: .studio) + xmlParser.delegate = ttmlParser + xmlParser.parse() + + self.lyricsProvider = .studio + self.lyrics = ttmlParser.lyrics + self.lyricCache[currentTrack.id] = self.lyrics + + return true + } catch { + print("Error fetching studio: \(error)") + } + + return false + } + + private func getStorefront() async -> String? { + do { + guard let data: [[String: Any]] = try await device.runAppleMusicAPI(path: "/v1/me/storefront?limit=1") as? [[String: Any]] else { + return nil + } + + if let storefrontId: String = data[0]["id"] as? String { + return storefrontId + } + + return nil + } catch { + print("Error fetching storefront: \(error)") + } + + return nil + } } struct LyricsScrollView: View { + @EnvironmentObject private var device: Device + let lyrics: [LyricLine] + let track: Track? @Binding var activeLine: LyricLine? + @Binding var currentTime: Double + let viewportHeight: CGFloat let lineSpacing: CGFloat + let changeTime: (Double) async -> Void + @State private var isDragging: Bool = false + @State private var sharingLyric: LyricLine? = nil + var body: some View { GeometryReader { geometry in ScrollViewReader { scrollView in @@ -146,18 +305,71 @@ struct LyricsScrollView: View { VStack(spacing: lineSpacing) { Spacer(minLength: 180) // Space for one line above active lyric ForEach(lyrics) { line in - LyricLineView( - lyric: line, - isActive: line == activeLine, - maxWidth: geometry.size.width - 40 - ) + Button { + Task { + defer { + self.activeLine = line + self.currentTime = line.timestamp + LyricsView.lyricAdvanceTime + } + await self.changeTime(line.timestamp + LyricsView.lyricAdvanceTime) + } + } label: { + LyricLineView( + lyric: line, + isActive: line == activeLine, + maxWidth: geometry.size.width - 20 + ) + .frame(maxWidth: .infinity, alignment: line.altVoice ? .trailing : .leading) + .padding(.horizontal, 20) + .scrollTransition { content, phase in + content + .offset(y: phase.isIdentity ? 0.0 : max(min(phase.value * 17.5, 17.5), -17.5)) + .opacity(phase.isIdentity ? 1.0 : 0.85) + .blur(radius: phase.isIdentity ? 0.0 : 8.5) + } + } + .buttonStyle(LyricButton(line)) .id(line.id) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) + .contextMenu { + Button { + self.sharingLyric = line + } label: { + Label("Share lyric", systemImage: "square.and.arrow.up") + } + .disabled(self.track == nil) + } preview: { + LyricLineView( + lyric: line, + isActive: true, + maxWidth: geometry.size.width - 20 + ) + .frame(maxWidth: .infinity, alignment: line.altVoice ? .trailing : .leading) + .padding(EdgeInsets(top: 60, leading: 40, bottom: 60, trailing: 40)) + .background(Color.ciderBack) + .clipShape(RoundedRectangle(cornerRadius: 15.0)) + } } Spacer(minLength: viewportHeight - 180) // Remaining space below lyrics } } + .fullScreenCover(item: $sharingLyric) { + self.sharingLyric = nil + + Task { + try? await Task.sleep(nanoseconds: 1_000_000) // idfk why? + print(self.sharingLyric ?? "just checking yk?") + } + } content: { lyric in + if let track = self.track { + LyricShare(track: track, lyric: lyric) + } else { + ProgressView() + .onAppear { + self.sharingLyric = nil + } + } + } + .scrollClipDisabled() .onChange(of: activeLine) { _, newActiveLine in if let newActiveLine = newActiveLine, !isDragging { withAnimation(.easeInOut(duration: 0.5)) { @@ -199,21 +411,21 @@ struct LyricLineView: View { var body: some View { Text(lyric.text) - .font(.system(size: 30, weight: .bold)) + .font(.system(size: 34, weight: .bold)) .foregroundStyle(textColor) .fixedSize(horizontal: false, vertical: true) .lineLimit(nil) - .multilineTextAlignment(.leading) - .frame(maxWidth: maxWidth, alignment: .leading) - .scaleEffect(isActive ? 1.0 : 0.7, anchor: .leading) + .multilineTextAlignment(lyric.altVoice ? .trailing : .leading) + .frame(maxWidth: maxWidth, alignment: lyric.altVoice ? .trailing : .leading) + .scaleEffect(isActive ? 1.0 : 0.7, anchor: lyric.altVoice ? .trailing : .leading) .animation(.spring(duration: 0.3), value: isActive) } private var textColor: Color { if (isActive) { - return colorScheme == .dark ? .white : .black + return .white } else { - return .gray.opacity(0.6) + return .gray.opacity(0.35) } } } @@ -225,4 +437,12 @@ struct LyricLine: Identifiable, Equatable { let text: String let timestamp: Double let isMainLyric: Bool + let altVoice: Bool + + init(text: String, timestamp: Double, isMainLyric: Bool = false, altVoice: Bool = false) { + self.text = text + self.timestamp = timestamp + self.isMainLyric = isMainLyric + self.altVoice = altVoice + } } diff --git a/Cider Remote/Views/MusicPlayer/MoreActionsMenu.swift b/Cider Remote/Views/MusicPlayer/MoreActionsMenu.swift new file mode 100644 index 0000000..5af39b7 --- /dev/null +++ b/Cider Remote/Views/MusicPlayer/MoreActionsMenu.swift @@ -0,0 +1,68 @@ +// Made by Lumaa + +import SwiftUI + +struct MoreActionsMenu: View { + var currentTrack: Track + + var toggleAddToLibrary: () async -> Void + var toggleLike: () async -> Void + var dismissView: DismissAction + + @Binding var isInLibrary: Bool + @Binding var isLiked: Bool + + @State private var shareSheet: Bool = false + + var body: some View { + Menu { + Button { + Task { + await self.toggleAddToLibrary() + } + } label: { + Label(self.isInLibrary ? "Remove from library" : "Add to library", systemImage: self.isInLibrary ? "minus.circle.fill" : "plus.circle.fill") + } + + Divider() + + ControlGroup { + Button { + Task { + await self.toggleLike() + } + } label: { + Label(self.isLiked ? "Unfavorite" : "Favorite", systemImage: self.isLiked ? "star.fill" : "star") + } + + Button { + self.shareSheet.toggle() + } label: { + Label("Share", systemImage: "square.and.arrow.up.fill") + } + } + + Divider() + + Button(role: .destructive) { + self.dismissView() + } label: { + Text("Close device") + .foregroundStyle(Color.red) + } + } label: { + Image(systemName: "ellipsis") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.primary) + } + .tint(Color.primary) + .padding(10.0) + .glassEffect(.regular.interactive(), in: Circle()) + .sheet(isPresented: $shareSheet) { + ActivityViewController(item: .track(track: currentTrack)) + .presentationDetents([.medium, .large]) + } + } +} diff --git a/Cider Remote/Views/MusicPlayer/MusicPlayerView.swift b/Cider Remote/Views/MusicPlayer/MusicPlayerView.swift index 5435ef1..a3b0d9b 100644 --- a/Cider Remote/Views/MusicPlayer/MusicPlayerView.swift +++ b/Cider Remote/Views/MusicPlayer/MusicPlayerView.swift @@ -6,317 +6,627 @@ // import SwiftUI +import Combine +import SocketIO +import WidgetKit +import AVKit struct MusicPlayerView: View { - @Environment(\.colorScheme) private var systemColorScheme - @Environment(\.scenePhase) private var scenePhase - @EnvironmentObject var colorScheme: ColorSchemeManager - - @AppStorage("buttonSize") private var buttonSize: ElementSize = .medium - @AppStorage("albumArtSize") private var albumArtSize: ElementSize = .large + @Environment(\.dismiss) private var dismiss: DismissAction + @Environment(\.colorScheme) private var systemColorScheme: ColorScheme + @Environment(\.scenePhase) private var scenePhase: ScenePhase let device: Device - @StateObject private var viewModel: MusicPlayerViewModel @StateObject private var userDevice: UserDevice = .shared - @State private var currentImage: UIImage? + @State private var isLoading = true + @State private var player: AVPlayer? = nil + + // Live Activity + @State private var liveActivity: LiveActivityManager = LiveActivityManager.shared + // Queue & Playing @State private var hasPlayed = false - @State private var librarySheet = false + @State private var queueItems: [Track] = [] + @State private var sourceQueue: Queue? + @State private var currentTrack: Track? + @State private var trackUrl: URL? = nil - @State private var isLoading = true - @State private var isCompact = false + // Playback Data + @State private var isPlaying: Bool = false + @State private var repeatMode: RepeatMode = .none + @State private var shuffleMode: ShuffleMode = .none + @State private var isAutoPlaying: Bool = false + @State private var isVoluming: Bool = false + @State private var stopTimeSlider: Bool = false + @State private var currentTime: Double = 0 + @State private var duration: Double = 0 + @State private var volume: Double = 0.5 + + // AM data + @State private var isLiked: Bool = false + @State private var isInLibrary: Bool = false + @State private var videoArtwork: URL? = nil + @State private var audioFormat: Track.AudioType = .unknown + @State private var backgroundColors: [Color] = [] + + // Popups + @State private var showLibraryPopup: Bool = false + @State private var showFavoritePopup: Bool = false + + // Showing UIs + @State private var showingLyrics: Bool = false + @State private var showingQueue: Bool = false + @State private var showingLibrary: Bool = false + + // Error + @State private var errorMessage: String? + + // Lyrics +// @State var lyrics: [LyricLine]? = nil +// @State var lyricsProvider: Parser.LyricProvider? = nil + + // Socket.IO + @State private var manager: SocketManager? + @State private var socket: SocketIOClient? + @State private var cancellables = Set() + + // Cache + @State private var imageCache = NSCache() +// @State private var lyricCache: [String: [LyricLine]] = [:] + @State private var storefrontCache: String? = nil + + private var expandedView: Bool { + return !self.showingQueue && !self.showingLyrics + } + + private static let horizontalPadding: CGFloat = 20.0 init(device: Device) { self.device = device - _viewModel = StateObject(wrappedValue: MusicPlayerViewModel(device: device, colorSchemeManager: ColorSchemeManager())) + _liveActivity = State(wrappedValue: LiveActivityManager.shared) + self.liveActivity.device = device } + // MARK: - View + var body: some View { - GeometryReader { geometry in - ZStack { - Color.black - .ignoresSafeArea() + ZStack { + if userDevice.horizontalOrientation.isPortrait() { + self.portrait + } else if userDevice.horizontalOrientation.isLandscape() { + self.landscape(userDevice.horizontalOrientation) + } + } + .navigationBarBackButtonHidden() + .fullScreenCover(isPresented: $showingLibrary) { + BrowserView(device: device) + .environment(\.colorScheme, systemColorScheme) // restore user's color scheme + } + .task { + self.startListening() - if let currentImage { - BlurredImageView(image: Image(uiImage: currentImage)) - .ignoresSafeArea() - .overlay { - Color.black - .opacity(0.5) - .ignoresSafeArea() - } + await self.initializePlayer() + await MainActor.run { + withAnimation { + isLoading = false + } + } + } + .onChange(of: showingQueue) { _, newValue in + if let player { + if newValue { + player.pause() } else { - LinearGradient(colors: [Color.gray.opacity(0.7), Color.gray.opacity(0.3)], startPoint: .top, endPoint: .bottom) - .blur(radius: 60) + player.play() } + } - if isLoading { - ProgressView() - .scaleEffect(1.5) - .progressViewStyle(CircularProgressViewStyle(tint: colorScheme.primaryColor)) + if newValue { + withAnimation(.easeOut.speed(1.3)) { + self.showingLyrics = false + } + } + } + .onChange(of: showingLyrics) { _, newValue in + if let player { + if newValue { + player.pause() } else { - VStack(spacing: 20) { - if let currentTrack = viewModel.currentTrack { - if userDevice.horizontalOrientation == .portrait || userDevice.isPad { - portraitView(track: currentTrack, geometry: geometry) - } else { - landscapeView( - track: currentTrack, - geometry: geometry, - rightButtons: userDevice.horizontalOrientation == .landscapeLeft - ) - } - } else { - VStack { - Text("No Song Playing") - .font(.title) - .foregroundStyle(.secondary) - - if hasPlayed { - if #available(iOS 26.0, *) { - Button { - self.librarySheet.toggle() - } label: { - Text("View Library") - } - .buttonStyle(.glassProminent) - } else { - Button { - self.librarySheet.toggle() - } label: { - Text("View Library") - } - .buttonStyle(.borderedProminent) - } - } else { - Text("Start by playing something in Cider!") - .font(.callout) - .foregroundStyle(Color.secondary) - } - } - .fullScreenCover(isPresented: $librarySheet) { - BrowserView(device: viewModel.device) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.bottom, geometry.safeAreaInsets.bottom) - .padding(.top, userDevice.isPad ? (isCompact ? 0 : 50) : (isCompact ? 0 : 30)) + player.play() + } + } + + if newValue { + withAnimation(.easeOut.speed(1.3)) { + self.showingQueue = false } } - .tint(colorScheme.primaryColor) - .frame(width: geometry.size.width, height: geometry.size.height) } - .edgesIgnoringSafeArea(.horizontal) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isCompact) - .environmentObject(colorScheme) + .onChange(of: scenePhase) { _, newValue in + if newValue == .active, let player { + player.play() + } + } .environment(\.colorScheme, ColorScheme.dark) - .onAppear { - colorScheme.updateColorScheme(systemColorScheme) - viewModel.startListening() + } - Task { - await viewModel.initializePlayer() - await MainActor.run { - withAnimation { - isLoading = false - } + @ViewBuilder + private var portrait: some View { + VStack { + if expandedView { + artwork + .padding(.top, self.videoArtwork != nil ? 0.0 : 80.0) + .padding(.horizontal, self.videoArtwork != nil ? 0.0 : Self.horizontalPadding) + } else { + HStack { + artwork + } + .padding(.top, 80.0) + .padding(.horizontal, Self.horizontalPadding + 15.0) + } + + if self.showingQueue { + QueueView(device: device, queueItems: $queueItems, sourceQueue: $sourceQueue, currentTrack: $currentTrack) { + queueActions + .padding(.horizontal, Self.horizontalPadding) } + .minimalView() + } else if self.showingLyrics { + LyricsView(device: device, currentTrack: $currentTrack, currentTime: $currentTime) + .frame(height: 600) } + + Spacer() } - .onDisappear { - viewModel.stopListening() - if colorScheme.useAdaptiveColors { - colorScheme.resetToDefaultColors() + .ignoresSafeArea(.container) + .frame(maxHeight: .infinity) + .background { + ZStack { + Rectangle() + .fill(Color.black) + .ignoresSafeArea() + + if self.backgroundColors.count == 1 { + Rectangle() + .fill(self.backgroundColors[0]) + .ignoresSafeArea() + } else if self.backgroundColors.count == 25 { + AnimatedMeshGradientView(colors: $backgroundColors, amplify: 0.25) + .ignoresSafeArea() + .opacity(0.3) + } } - LiveActivityManager().stopActivity() } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - Task { - viewModel.refreshCurrentTrack() + .overlay(alignment: .bottom) { + VStack { + if expandedView { + trackData + .padding(.horizontal, Self.horizontalPadding) } - if colorScheme.useAdaptiveColors { - colorScheme.reapplyAdaptiveColors() + + if !self.showingLyrics { + playbackActions + .padding(.horizontal, Self.horizontalPadding) + .transition( + .move(edge: .bottom) + .combined(with: .opacity) + .animation(.spring(duration: 0.4)) + ) } + + navigationActions + .padding(.horizontal, 30.0) + .padding(.vertical, 10.0) } + .padding(.bottom, 30.0) } - .onChange(of: systemColorScheme) { _, newColorScheme in - colorScheme.updateColorScheme(newColorScheme) - } - .onChange(of: viewModel.needsColorUpdate) { _, needsUpdate in - if needsUpdate && colorScheme.useAdaptiveColors { - updateColors() + } + + @ViewBuilder + private func landscape(_ orientation: UserDevice.HorizontalOrientation) -> some View { + SidedStack(side: orientation == .landscapeLeft ? SidedStack.Side.left : SidedStack.Side.right) { + if expandedView { + artwork + .padding(.top, self.videoArtwork != nil ? 0.0 : Self.horizontalPadding) + .padding(.horizontal, self.videoArtwork != nil ? 0.0 : 80.0) + } + + if self.showingQueue { + QueueView(device: device, queueItems: $queueItems, sourceQueue: $sourceQueue, currentTrack: $currentTrack) { + queueActions + .padding(.horizontal, Self.horizontalPadding) + } + .minimalView() } + } `right`: { + VStack { + if !self.showingLyrics { + trackData + .padding(.horizontal, Self.horizontalPadding) + + playbackActions + .padding(.horizontal, Self.horizontalPadding) + .transition( + .move(edge: .bottom) + .combined(with: .opacity) + .animation(.spring(duration: 0.4)) + ) + } else { + Spacer() + } + + navigationActions + .padding(.horizontal, 30.0) + .padding(.vertical, 10.0) + } + .frame(height: 350) } - .onChange(of: viewModel.showingQueue) { _, newShow in - withAnimation(.spring) { - self.isCompact = newShow + .ignoresSafeArea(.container) + .background { + ZStack { + Rectangle() + .fill(Color.black) + .ignoresSafeArea() + + if self.backgroundColors.count == 1 { + Rectangle() + .fill(self.backgroundColors[0]) + .ignoresSafeArea() + } else if self.backgroundColors.count == 25 { + AnimatedMeshGradientView(colors: $backgroundColors, amplify: 0.25) + .ignoresSafeArea() + .opacity(0.3) + } } } - .onChange(of: viewModel.showingLyrics) { _, newShow in - withAnimation(.spring) { - self.isCompact = newShow + .overlay(alignment: .center) { + if self.showingLyrics { + LyricsView(device: device, currentTrack: $currentTrack, currentTime: $currentTime) + .frame(maxHeight: .infinity) } } - .onChange(of: userDevice.horizontalOrientation) { _, _ in - withAnimation(.spring) { - self.viewModel.showingQueue = false - self.viewModel.showingLyrics = false + .onAppear { +#if !WIDGET + self.alwaysOn(UserDefaults.standard.bool(forKey: "alwaysOn")) +#endif + } + .onDisappear { +#if !WIDGET + self.alwaysOn(false) +#endif + } + } + + @ViewBuilder + private var artwork: some View { + if let track = self.currentTrack { + if videoArtwork != nil && expandedView, let player { + UninteractableVideoPlayer(player: player) + .aspectRatio(userDevice.horizontalOrientation.isPortrait() ? LibraryAlbum.AnimatedCover.tall.ratio : LibraryAlbum.AnimatedCover.square.ratio, contentMode: .fit) + .frame(maxWidth: .infinity) + .mask(alignment: .center) { + if userDevice.horizontalOrientation.isPortrait() { + LinearGradient( + colors: [Color.white, Color.white, Color.white, Color.white.opacity(0.75), Color.white.opacity(0.65), Color.white.opacity(0.5), Color.white.opacity(0.2), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + } else { + RadialGradient( + colors: [ + Color.white, + Color.white, + Color.white, + Color.white.opacity(0.75), + Color.white.opacity(0.65), + Color.white.opacity(0.5), + Color.white.opacity(0.2), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 180 + ) + } + } + } else { + AsyncImage(url: URL(string: track.artwork)) { phase in + switch phase { + case .empty: + Rectangle() + .fill(Color.gray.opacity(0.2)) + .frame(maxWidth: expandedView ? .infinity : 40, maxHeight: expandedView ? nil : 40, alignment: .center) + .overlay { + ProgressView() + } + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + Image(systemName: "music.note") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.gray) + @unknown default: + EmptyView() + } + } + .scaledToFit() + .frame(maxWidth: expandedView ? .infinity : 40, maxHeight: expandedView ? nil : 40, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: expandedView ? 10 : 2)) + .aspectRatio(1.0, contentMode: .fit) + .shadow(radius: expandedView ? 10 : 0) } } } @ViewBuilder - private func portraitView(track: Track, geometry: GeometryProxy) -> some View { - HStack { - TrackInfoView(track: track, onImageLoaded: { image in - currentImage = image - colorScheme.updateColors(from: image) - viewModel.needsColorUpdate = false - }, albumArtSize: albumArtSize, geometry: geometry, isCompact: $isCompact) + private var trackData: some View { + if let currentTrack { + GlassEffectContainer { + HStack { + VStack(alignment: .leading) { + Text(currentTrack.title) + .font(.title2.bold()) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) - if isCompact { - Spacer() + Text(currentTrack.artist) + .font(.title2) + .foregroundStyle(Color.secondary) + .opacity(0.5) + .lineLimit(1) + } - closeBtn + Button { + Task { + await self.toggleLike() + } + } label: { + Image(systemName: self.isLiked ? "star.fill" : "star") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.primary) + } + .padding(10) + .glassEffect(.regular.interactive(), in: Circle()) + + MoreActionsMenu( + currentTrack: currentTrack, + toggleAddToLibrary: toggleAddToLibrary, + toggleLike: toggleLike, + dismissView: dismiss, + isInLibrary: $isInLibrary, + isLiked: $isLiked + ) + } } } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) + } - if isCompact { - if viewModel.showingQueue { - QueueView(viewModel: viewModel) - } else if viewModel.showingLyrics { - LyricsView(viewModel: viewModel) + @ViewBuilder + private var playbackActions: some View { + VStack(spacing: 25.0) { + CustomSlider(value: $currentTime, isDragging: $stopTimeSlider, bounds: 0...duration) { newValue in + if !newValue { + Task { + await self.seekToTime(to: self.currentTime) + } + } } - } else { - VStack(spacing: 15) { - PlayerControlsView(viewModel: viewModel, buttonSize: buttonSize, geometry: geometry) + .overlay(alignment: .bottom) { + HStack { + Text(self.formatTime(self.currentTime)) + .font(.caption.bold(self.stopTimeSlider).monospacedDigit()) + .foregroundStyle(self.stopTimeSlider ? Color.white : Color.secondary) + .opacity(self.stopTimeSlider ? 1.0 : 0.5) + .contentTransition(.identity) - VolumeControlView(viewModel: viewModel, geometry: geometry) + if self.audioFormat == .unknown { + Spacer() + } else { + Spacer() - AdditionalControlsView( - showLyrics: $viewModel.showingLyrics, - showQueue: $viewModel.showingQueue, - buttonSize: buttonSize, - geometry: geometry - ) + self.audioFormat.view + .opacity(0.5) + + Spacer() + } + + Text("-" + self.formatTime(self.duration - self.currentTime)) + .font(.caption.bold(self.stopTimeSlider).monospacedDigit()) + .foregroundStyle(self.stopTimeSlider ? Color.white : Color.secondary) + .opacity(self.stopTimeSlider ? 1.0 : 0.5) + .contentTransition(.identity) + } + .offset(y: 5.0 + (self.stopTimeSlider ? 12.0 : 0.0)) + } + + HStack(spacing: 70.0) { + Button { + Task { + await self.previousTrack() + } + } label: { + Image(systemName: "backward.fill") + .font(.title.bold()) + .foregroundStyle(Color.white) + } + .buttonStyle(.plain) + + Button { + Task { + await self.togglePlayPause() + } + } label: { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .foregroundStyle(Color.white) + .contentTransition(.symbolEffect(.replace.wholeSymbol)) + } + .buttonStyle(.plain) + + Button { + Task { + await self.nextTrack() + } + } label: { + Image(systemName: "forward.fill") + .font(.title.bold()) + .foregroundStyle(Color.white) + } + .buttonStyle(.plain) + } + + HStack { + Image(systemName: "speaker.fill") + .foregroundStyle(.secondary) + .opacity(0.5) + + CustomSlider(value: $volume, isDragging: $isVoluming, bounds: 0...1) { newValue in + if !newValue { + Task { + await self.adjustVolume(to: self.volume) + } + } + } + + Image(systemName: "speaker.wave.3.fill") + .foregroundStyle(.secondary) + .opacity(0.5) } - .padding(.horizontal, userDevice.isPad ? 40 : 20) } } @ViewBuilder - private func landscapeView(track: Track, geometry: GeometryProxy, rightButtons: Bool = false) -> some View { + private var navigationActions: some View { HStack { - if !isCompact { - if rightButtons { - TrackInfoView(track: track, onImageLoaded: { image in - currentImage = image - colorScheme.updateColors(from: image) - viewModel.needsColorUpdate = false - }, albumArtSize: albumArtSize, geometry: geometry, isCompact: $isCompact) - .frame(width: geometry.size.width / 2 - 20) - - VStack(spacing: 15) { - PlayerControlsView(viewModel: viewModel, buttonSize: buttonSize, geometry: geometry) - - VolumeControlView(viewModel: viewModel, geometry: geometry) - .padding(.horizontal) - - AdditionalControlsView( - showLyrics: $viewModel.showingLyrics, - showQueue: $viewModel.showingQueue, - buttonSize: buttonSize, - geometry: geometry - ) - } - .frame(width: geometry.size.width / 2 - 20) - } else { - VStack(spacing: 15) { - PlayerControlsView(viewModel: viewModel, buttonSize: buttonSize, geometry: geometry) + Button { + withAnimation(.easeOut.speed(1.3)) { + self.showingLyrics.toggle() + } + } label: { + Image(systemName: "quote.bubble") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(self.showingLyrics ? Color.black.opacity(0.5) : Color.white.opacity(0.5)) + } + .buttonStyle(.plain) + .padding(7.5) + .background(self.showingLyrics ? Color.white.opacity(0.5) : Color.clear) + .clipShape(Circle()) - VolumeControlView(viewModel: viewModel, geometry: geometry) - .padding(.horizontal) + Spacer() - AdditionalControlsView( - showLyrics: $viewModel.showingLyrics, - showQueue: $viewModel.showingQueue, - buttonSize: buttonSize, - geometry: geometry - ) - } - .frame(width: geometry.size.width / 2 - 20) + BrowserView.access($showingLibrary) + + Spacer() - TrackInfoView(track: track, onImageLoaded: { image in - currentImage = image - colorScheme.updateColors(from: image) - viewModel.needsColorUpdate = false - }, albumArtSize: albumArtSize, geometry: geometry, isCompact: $isCompact) - .frame(width: geometry.size.width / 2 - 20) + Button { + Task { + await self.getAutoplay() + await self.getRepeat() } - } else { - if viewModel.showingLyrics { - LyricsView(viewModel: viewModel) - .frame(width: geometry.size.width - 150) - .overlay(alignment: .topTrailing) { - closeBtn - .padding(.top, 30) - } - } else if viewModel.showingQueue { - if #available(iOS 17.0, *) { - ContentUnavailableView { - Label("Oops!", systemImage: "iphone.gen3.landscape") - } description: { - Text("Seems like you can't view your queue in landscape mode YET...") - } actions: { - Button { - withAnimation { - self.viewModel.showingQueue.toggle() - } - } label: { - Text("Close Queue") - } - .buttonStyle(.borderedProminent) - } - } else { - VStack { - Text("Oops!") - .font(.title2.bold()) - Text("Seems like you can't view your queue in landscape mode YET...") - .font(.caption) - .foregroundStyle(Color.secondary) + withAnimation(.easeOut.speed(1.3)) { + self.showingQueue.toggle() + } + } label: { + Image(systemName: "list.bullet") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(self.showingQueue ? Color.black.opacity(0.5) : Color.white.opacity(0.5)) + } + .buttonStyle(.plain) + .padding(7.5) + .background(self.showingQueue ? Color.white.opacity(0.5) : Color.clear) + .clipShape(Circle()) + } + } - Button { - withAnimation { - self.viewModel.showingQueue.toggle() - } - } label: { - Text("Close Queue") - } - .buttonStyle(.bordered) - .padding(.top) - } + @ViewBuilder + private var queueActions: some View { + GlassEffectContainer { + HStack { + Button { + Task { + await self.toggleAutoplay() } + } label: { + Image(systemName: "infinity") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) } + .buttonStyle(.plain) + .buttonBorderShape(.capsule) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .glassEffect(.regular.interactive().tint(self.isAutoPlaying ? Color.accentColor : Color.clear), in: Capsule()) + + Button { + Task { + await self.cycleRepeat() + } + } label: { + Image(systemName: self.repeatMode.symbol) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .contentTransition(.symbolEffect(.replace.magic(fallback: .downUp))) + } + .buttonStyle(.plain) + .buttonBorderShape(.capsule) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .glassEffect(.regular.interactive().tint(self.repeatMode != .none ? Color.accentColor : Color.clear), in: Capsule()) + + Button { + Task { + await self.cycleShuffle() + } + } label: { + Image(systemName: "shuffle") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .contentTransition(.symbolEffect(.replace.magic(fallback: .downUp))) + } + .buttonStyle(.plain) + .buttonBorderShape(.capsule) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .glassEffect(.regular.interactive().tint(self.shuffleMode == .shuffling ? Color.accentColor : Color.clear), in: Capsule()) } } - .onAppear { -#if !WIDGET - self.alwaysOn(UserDefaults.standard.bool(forKey: "alwaysOn")) -#endif - } - .onDisappear { -#if !WIDGET - self.alwaysOn(false) -#endif + } + + private func formatTime(_ time: Double) -> String { + let minutes = Int(time) / 60 + let seconds = Int(time) % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + private func setupAVPlayer() { + guard player == nil, let videoArtwork else { return } + + let newPlayer = AVPlayer(url: videoArtwork) + self.player = newPlayer + + NotificationCenter.default.addObserver(forName: AVPlayerItem.didPlayToEndTimeNotification, object: newPlayer.currentItem, queue: .main) { _ in + newPlayer.seek(to: CMTime.zero) + newPlayer.play() } + + newPlayer.play() } private func alwaysOn(_ bool: Bool) { @@ -326,55 +636,825 @@ struct MusicPlayerView: View { #endif } - private var closeBtn: some View { - Button { - withAnimation(.spring) { - withAnimation(.spring) { - viewModel.showingQueue = false - viewModel.showingLyrics = false + // MARK: - Model + + func startListening() { + print("Attempting to connect to socket") + let socketURL = device.connectionMethod == .tunnel ? "https://\(device.host)" : "http://\(device.host):10767" + manager = SocketManager(socketURL: URL(string: socketURL)!, config: [.log(false), .compress]) + socket = manager?.defaultSocket + + setupSocketEventHandlers() + socket?.connect() + } + + private func setupSocketEventHandlers() { + socket?.on(clientEvent: .connect) { data, ack in + print("Socket connected") + + Task { + await self.getCurrentTrack() + + if let currentTrack = self.currentTrack { + self.liveActivity.startActivity(using: currentTrack) + } + +// AppDelegate.shared.scheduleAppRefresh() + if #available(iOS 18.0, *) { + ControlCenter.shared.reloadControls(ofKind: "sh.cider.CiderRemote.PlayPauseControl") + } + } + } + + socket?.on("API:Playback") { data, ack in + guard let playbackData = data[0] as? [String: Any], let type = playbackData["type"] as? String else { + print("Invalid playback data received") + return + } + + DispatchQueue.main.async { + switch type { + case "playbackStatus.nowPlayingStatusDidChange": + if let info = playbackData["data"] as? [String: Any] { + self.setAdaptiveData(info) + } + case "playbackStatus.nowPlayingItemDidChange": + if let info = playbackData["data"] as? [String: Any] { + self.updateTrackInfo(info) + if let currentTrack = self.currentTrack { + self.liveActivity.startActivity(using: currentTrack) + } + } + case "playbackStatus.playbackStateDidChange": + if let info = playbackData["data"] as? [String: Any] { + self.setPlaybackStatus(info) + } + case "playbackStatus.playbackTimeDidChange": + if let info = playbackData["data"] as? [String: Any], + let isPlaying = info["isPlaying"] as? Int, + let currentPlaybackTime = info["currentPlaybackTime"] as? Double { + self.isPlaying = isPlaying == 1 ? true : false + if !self.stopTimeSlider { + self.currentTime = currentPlaybackTime + } + } + default: + print("Unhandled event type: \(type)") } } - } label: { - if #available(iOS 26.0, *) { - Image(systemName: "xmark") - .foregroundStyle(Color(uiColor: UIColor.label)) - .padding(12) - .glassEffect(.regular.interactive()) + } + } + + func stopListening() { + print("Disconnecting socket") + socket?.disconnect() + } + + func initializePlayer() async { + await getCurrentTrack() + await getCurrentVolume() + await fetchQueueItems() + } + + func refreshCurrentTrack() { + Task { + await getCurrentTrack() + await getCurrentVolume() + + if let currentTrack, queueItems.first?.id == currentTrack.id { + queueItems.removeFirst() } else { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Color.white.opacity(0.4)) - .font(.system(size: 28)) + await fetchQueueItems() } + + reconnectSocketIfNeeded() } } - private func updateColors() { - self.currentImage = nil + private func reconnectSocketIfNeeded() { + if socket?.status != .connected { + print("Socket not connected, reconnecting...") + socket?.connect() + } + } + + func fetchQueueItems() async { + guard let currentTrack else { print("[QUEUE] Need currentTrack to get current queue"); return } + + print("Fetching current queue") + do { + let path: String = device.useV2 ? "queue" : "playback/queue" + let data = try await sendRequest(endpoint: path) + if let jsonDict = data as? [[String: Any]] { + let attributes: [[String : Any]] = jsonDict.compactMap { $0["attributes"] as? [String : Any] } + let queue: [Track] = attributes.map { getTrack(using: $0) } - guard let artworkUrl = viewModel.currentTrack?.artwork, - let url = URL(string: artworkUrl) else { - colorScheme.resetToDefaultColors() - return + var queueItem: Queue = .init(tracks: queue) + queueItem.defineCurrent(track: currentTrack) + + self.sourceQueue = queueItem // after defining offset + self.queueItems = queueItem.tracks + } + + await self.handleColors() + } catch { + handleError(error) } + } - Task { + /// it also gets other stuff but shush who cares it works + func getAnimatedCover(size: LibraryAlbum.AnimatedCover = .tall) async -> URL? { + guard let currentTrack else { return nil } + + do { + guard let data = try await device.runAppleMusicAPI(path: "/v1/catalog/us/songs/\(currentTrack.catalogId)?include=albums&extend[albums]=editorialVideo") as? [[String: Any]], data.count > 0 else { return nil } + + if let relation: [String: Any] = data[0]["relationships"] as? [String: Any], let album: [String: Any] = relation["albums"] as? [String: Any], let subdata: [[String: Any]] = album["data"] as? [[String: Any]], let attributes = subdata[0]["attributes"] as? [String: Any] { + + if let audioTraits: [String] = attributes["audioTraits"] as? [String] { + print(audioTraits) + self.audioFormat = Track.AudioType.find(audioTraits) + } + + if let videos: [String: Any] = attributes["editorialVideo"] as? [String: Any], let squareObj: [String: Any] = videos[size.rawValue] as? [String: Any], let squareStr: String = squareObj["video"] as? String { + return URL(string: squareStr) + } + } + + return nil + } catch { + handleError(error) + return nil + } + } + + func getCurrentTrack() async { + print("Fetching current track") + do { + let data = try await sendRequest(endpoint: "playback/now-playing", method: "GET") + if let info = data as? [String: Any], device.useV2 { + updateTrackInfo(info, alt: true) + } else if let jsonDict = data as? [String: Any], let info = jsonDict["info"] as? [String: Any], !device.useV2 { + updateTrackInfo(info, alt: true) + } else { + throw NetworkError.decodingError + } + } catch { + handleError(error) + } + } + + func getStorefront() async -> String? { + do { + guard let data: [[String: Any]] = try await device.runAppleMusicAPI(path: "/v1/me/storefront?limit=1") as? [[String: Any]], !data.isEmpty else { return nil } + + if let storefrontId: String = data[0]["id"] as? String { + self.storefrontCache = storefrontId + return storefrontId + } + } catch { + print("Error fetching storefront: \(error)") + handleError(error) + } + + return nil + } + + func getTrackUrl() async -> URL? { + guard let currentTrack else { return nil } + var storefront: String? = self.storefrontCache + if self.storefrontCache == nil, let newStorefront = await self.getStorefront() { + storefront = newStorefront + } + + if let storefront { + return URL(string: "https://music.apple.com/\(storefront)/song/\(currentTrack.catalogId)") + } else { + return nil + } + } + + private func setPlaybackStatus(_ info: [String: Any]) { + print("Setting playback status: \(info)") + if let state = info["state"] as? String { + self.isPlaying = (state == "playing") + } + } + + private func setAdaptiveData(_ info: [String: Any]) { + print("Setting adaptive data: \(info)") + DispatchQueue.main.async { + if let isLiked = info["inFavorites"] as? Int, isLiked == 1 { + self.isLiked = true + } else { + self.isLiked = false + } + + if let isInLibrary = info["inLibrary"] as? Int, isInLibrary == 1 { + self.isInLibrary = true + } else { + self.isInLibrary = false + } + + if let currentPlaybackTime = info["currentPlaybackTime"] as? Double { + self.currentTime = currentPlaybackTime + } + if let durationInMillis = info["durationInMillis"] as? Double { + self.duration = durationInMillis / 1000 + } + } + } + + private func updateTrackInfo(_ info: [String: Any], alt: Bool = false) { + print("Updating track info: \(info)") + + // Extract ID from playParams + var id: String? + var amId: String? + + if let playParams = info["playParams"] as? [String: Any] { + id = playParams["id"] as? String + amId = playParams["catalogId"] as? String + } + + let title = info["name"] as? String ?? "" + let artist = info["artistName"] as? String ?? "" + let album = info["albumName"] as? String ?? "" + let duration = info["durationInMillis"] as? Double ?? 0 + + if let artwork = info["artwork"] as? [String: Any], + var artworkUrl = artwork["url"] as? String { + // Replace placeholders in artwork URL + artworkUrl = artworkUrl.replacingOccurrences(of: "{w}", with: "1024") + artworkUrl = artworkUrl.replacingOccurrences(of: "{h}", with: "1024") + + var newTrack: Track = Track(id: id ?? "", catalogId: amId ?? id ?? "", title: title, artist: artist, album: album, artwork: artworkUrl, duration: duration / 1000) + + if self.currentTrack != newTrack { + Task { + newTrack.artworkData = await newTrack.getArtwork()?.pngData() ?? Data() + let isSameAlbum: Bool = self.currentTrack?.album == newTrack.album + self.currentTrack = newTrack + + await self.updateQueue(newTrack: newTrack) + + if !isSameAlbum { + await self.resetAVPlayer() + } + } + } + } + + if alt { + self.isLiked = info["inFavorites"] as? Bool ?? false + self.isInLibrary = info["inLibrary"] as? Bool ?? false + } + self.duration = duration / 1000 + + if let currentPlaybackTime = info["currentPlaybackTime"] as? Double, !self.stopTimeSlider { + self.currentTime = currentPlaybackTime + } + + self.isPlaying = false + + print("Updated currentTrack: \(String(describing: self.currentTrack))") + print("isPlaying: \(self.isPlaying)") + } + + private func resetAVPlayer() async { + self.videoArtwork = nil + self.player = nil + + self.videoArtwork = await self.getAnimatedCover(size: .tall) + if self.videoArtwork != nil { + self.setupAVPlayer() + } + } + + private func handleColors() async { + var colors: [Color] = [Color.accentColor.opacity(0.2)] + + if let artwork: UIImage = await self.loadArtwork() { + colors = artwork.dominantColors(count: 25) + } + + withAnimation(.linear(duration: 3.5)) { + self.backgroundColors = colors.shuffled() + } + } + + private func updateQueue(newTrack: Track) async { + print("[QUEUE] smart update") + if newTrack.id == queueItems.first?.id { // if newTrack is the next playing song in the queue + queueItems = Array(queueItems.dropFirst()) + } else { + await fetchQueueItems() + } + } + + private func getTrack(using info: [String: Any]) -> Track { + // Extract ID from playParams + var id: String? + var amId: String? + + if let playParams = info["playParams"] as? [String: Any] { + id = playParams["id"] as? String + amId = playParams["catalogId"] as? String + } + + let title = info["name"] as? String ?? "" + let artist = info["artistName"] as? String ?? "" + let album = info["albumName"] as? String ?? "" + let duration = info["durationInMillis"] as? Double ?? 0 + + if let artwork = info["artwork"] as? [String: Any], + var artworkUrl = artwork["url"] as? String { + // Replace placeholders in artwork URL + artworkUrl = artworkUrl.replacingOccurrences(of: "{w}", with: "1024") + artworkUrl = artworkUrl.replacingOccurrences(of: "{h}", with: "1024") + + return Track(id: id ?? "", + catalogId: amId ?? "", + title: title, + artist: artist, + album: album, + artwork: artworkUrl, + duration: duration / 1000, + artworkData: Data() + ) + } else { + return Track(id: id ?? "", + catalogId: amId ?? "", + title: title, + artist: artist, + album: album, + artwork: "", + duration: duration / 1000, + artworkData: Data() + ) + } + } + + func getArtwork(for url: URL?) async -> Data { + guard let url else { return Data() } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + return data + } catch { + print("Error loading image: \(error)") + } + + return Data() + } + + func getCurrentVolume() async { + print("Fetching current volume") + do { + let path: String = device.useV2 ? "audio/volume" : "playback/volume" + let data = try await sendRequest(endpoint: path, method: "GET") + if let jsonDict = data as? [String: Any], + let volume = jsonDict["volume"] as? Double { + self.volume = volume + print("Current volume: \(volume)") + } else { + throw NetworkError.decodingError + } + } catch { + handleError(error) + } + } + + func nextTrack() async { + print("Skipping to next track") + do { + _ = try await sendRequest(endpoint: "playback/next", method: "POST") + await getCurrentTrack() // Refresh track info after skipping + } catch { + handleError(error) + } + } + + func previousTrack() async { + print("Going to previous track") + do { + _ = try await sendRequest(endpoint: "playback/previous", method: "POST") + await getCurrentTrack() // Refresh track info after going to previous track + } catch { + handleError(error) + } + } + + func seekToTime() async { + print("Seeking to time: \(currentTime)") + do { + _ = try await sendRequest(endpoint: "playback/seek", method: "POST", body: ["position": currentTime]) + } catch { + handleError(error) + } + } + + func getRepeat() async { + do { + let path: String = device.useV2 ? "playback/repeat" : "playback/repeat-mode" + let result = try await sendRequest(endpoint: path, method: "GET") + if let data = result as? [String: Any], device.useV2 { + let val: String = data["mode"] as? String ?? "none" + self.repeatMode = .init(rawValue: val) ?? .none + } else if let data = result as? [String: Any], !device.useV2 { + let val: Int = data["value"] as? Int ?? 0 + self.repeatMode = .from(val) + } + } catch { + handleError(error) + } + } + + func getShuffle() async { + do { + let path: String = device.useV2 ? "playback/shuffle" : "playback/shuffle-mode" + let result = try await sendRequest(endpoint: path, method: "GET") + if let data = result as? [String: Any], device.useV2 { + let enabled: Bool = data["enabled"] as? Bool ?? false + self.shuffleMode = enabled ? .shuffling : .none + } else if let data = result as? [String: Any], !device.useV2 { + let val: Int = data["value"] as? Int ?? 0 + self.shuffleMode = val == 1 ? .shuffling : .none + } + } catch { + handleError(error) + } + } + + func getAutoplay() async { + do { + let result = try await sendRequest(endpoint: "playback/autoplay", method: "GET") + if let data = result as? [String: Any] { + self.isAutoPlaying = data["value"] as? Bool ?? false + } + } catch { + handleError(error) + } + } + + func togglePlayPause() async { + print("Toggling play/pause") + withAnimation { + isPlaying.toggle() // Immediately update UI + } + do { + let path: String = device.useV2 ? "playback/toggle" : "playback/playpause" + _ = try await sendRequest(endpoint: path, method: "POST") + // Server confirmed the change, no need to update UI again + if #available(iOS 18.0, *) { + ControlCenter.shared.reloadControls(ofKind: "sh.cider.CiderRemote.PlayPauseControl") + } + } catch { + // Revert the UI change if the server request failed + isPlaying.toggle() + handleError(error) + } + } + + func cycleRepeat() async { + print("Cycling through repeat") + let lastRepeat: RepeatMode = self.repeatMode + withAnimation { + self.repeatMode = self.repeatMode.next + } + do { + let path: String = device.useV2 ? "playback/repeat/toggle" : "playback/toggle-repeat" + _ = try await sendRequest(endpoint: path, method: "POST") + } catch { + self.repeatMode = lastRepeat + handleError(error) + } + } + + func cycleShuffle() async { + print("Cycling through shuffle") + let lastShuffle: ShuffleMode = self.shuffleMode + withAnimation { + self.shuffleMode = self.shuffleMode.next + } + do { + let path: String = device.useV2 ? "playback/shuffle/toggle" : "playback/toggle-shuffle" + _ = try await sendRequest(endpoint: path, method: "POST") + } catch { + self.shuffleMode = lastShuffle + handleError(error) + } + } + + func toggleAutoplay() async { + print("Toggling autoplay") + withAnimation { + self.isAutoPlaying.toggle() // Immediately update UI + } + do { + let path: String = device.useV2 ? "playback/autoplay/toggle" : "playback/toggle-autoplay" + _ = try await sendRequest(endpoint: path, method: "POST") + } catch { + isAutoPlaying.toggle() + handleError(error) + } + } + + func toggleLike() async { + let newRating = isLiked ? 0 : 1 + print("Toggling like status to: \(newRating)") + do { + let path: String = device.useV2 ? "library/now-playing/rating" : "playback/set-rating" + _ = try await sendRequest(endpoint: path, method: "POST", body: ["rating": newRating]) + isLiked.toggle() + + withAnimation { + showFavoritePopup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.showFavoritePopup = false + } + } + } catch { + handleError(error) + } + } + + func toggleAddToLibrary() async { + if !isInLibrary { + print("Adding to library") do { - let (data, _) = try await URLSession.shared.data(from: url) - if let image = UIImage(data: data) { - self.currentImage = image + let path: String = device.useV2 ? "library/now-playing/add" : "playback/add-to-library" + _ = try await sendRequest(endpoint: path, method: "POST") + isInLibrary = true - await MainActor.run { - colorScheme.updateColors(from: image) - viewModel.needsColorUpdate = false + withAnimation { + showLibraryPopup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.showLibraryPopup = false } } } catch { - print("Error loading artwork: \(error)") - await MainActor.run { - colorScheme.resetToDefaultColors() + handleError(error) + } + } + } + + private func adjustVolume(to volume: Double) async { + print("Adjusting volume to: \(volume)") + do { + let path: String = device.useV2 ? "audio/volume" : "playback/volume" + let method: String = device.useV2 ? "PATCH" : "POST" + + let data = try await sendRequest(endpoint: path, method: method, body: ["volume": volume]) + if let jsonDict = data as? [String: Any], + let newVolume = jsonDict["volume"] as? Double { + self.volume = newVolume + print("Volume adjusted to: \(newVolume)") + } else { + throw NetworkError.decodingError + } + } catch { + handleError(error) + } + } + + func searchSong(query: String) async -> [Track] { + print("Searching for: \(query)") + do { + let data = try await sendRequest(endpoint: "amapi/run-v3", method: "POST", body: ["path": "/v1/catalog/us/search?term=\(query)&types=songs"], version: "v1") + + if let jsonDict = data as? [String: Any], let data = jsonDict["data"] as? [String: Any], let _results = data["results"] as? [String: Any] { + guard let songs = _results["songs"] as? [String: Any], let results = songs["data"] as? [[String: Any]] else { + print("Couldn't decrypt stuff") + return [] + } + + var searchResults: [Track] = [] + for result in results { + guard let attributes = result["attributes"] as? [String: Any], let artwork = attributes["artwork"] as? [String: Any] else { + print("Oopsy, couldn't add search result") + return [] + } + + searchResults + .append( + .init( + id: attributes["isrc"] as! String, + catalogId: attributes["isrc"] as! String, + title: attributes["name"] as! String, + artist: attributes["artistName"] as! String, + album: attributes["albumName"] as! String, + artwork: String((artwork["url"] as! String).replacing(/{(w|h)}/, with: "500")), + duration: (Double(attributes["durationInMillis"] as? String ?? "0") ?? 0.0) / 1000, + artworkData: Data(), + songHref: (result["href"] as! String) + ) + ) } + + print("[searchSong] RETURNING \(searchResults.count) results") + return searchResults + } else { + throw NetworkError.decodingError } + } catch { + handleError(error) } + + return [] + } + + func playHref(href: String) async { + print("Playing song using HREF") + + do { + let path: String = device.useV2 ? "playback/play-href" : "playback/play-item-href" + _ = try await sendRequest(endpoint: path, method: "POST", body: ["href": href]) + } catch { + handleError(error) + } + } + + func playTrackHref(_ track: Track) async { + guard let href = track.songHref else { fatalError("No HREF in this Track") } + print("Playing TRACK song using HREF") + + do { + let path: String = device.useV2 ? "playback/play-href" : "playback/play-item-href" + _ = try await sendRequest(endpoint: path, method: "POST", body: ["href": href]) + } catch { + handleError(error) + } + } + + private func seekToTime(to newTime: Double) async { + print("Seeking to time: \(newTime)") + do { + _ = try await sendRequest(endpoint: "playback/seek", method: "POST", body: ["position": newTime]) + } catch { + handleError(error) + } + } + + func loadImage(for url: URL) async -> UIImage? { + // Check cache first + if let cachedImage = imageCache.object(forKey: url.absoluteString as NSString) { + return cachedImage + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let image = UIImage(data: data) { + // Cache the image + imageCache.setObject(image, forKey: url.absoluteString as NSString) + return image + } + } catch { + print("Error loading image: \(error)") + } + return nil + } + + func loadArtwork() async -> UIImage? { + guard let artwork = self.currentTrack?.artwork else { return nil } + let url: URL = URL(string: artwork)! + return await self.loadImage(for: url) + } + + private func sendRequest(endpoint: String, method: String = "GET", body: [String: Any]? = nil, version: String? = nil) async throws -> Any { + let clientVersion: String = self.device.useV2 ? "v2" : "v1" + let v: String = version ?? clientVersion + + let baseURL = device.connectionMethod == .tunnel ? "https://\(device.host)" : "http://\(device.host):10767" + guard let url = URL(string: "\(baseURL)/api/\(v)/\(endpoint)") else { + throw NetworkError.invalidURL + } + + print("Sending request to: \(url.absoluteString)") + + var request = URLRequest(url: url) + request.httpMethod = method + request.addValue(device.token, forHTTPHeaderField: "apptoken") + + if let body = body { + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + print("Request body: \(body)") + } + + let (data, response) = try await URLSession.shared.data(for: request) + // print("Response raw: \(String(data: data, encoding: .utf8) ?? "[No data]")") + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + + print("Response status code: \(httpResponse.statusCode)") + + guard (200...299).contains(httpResponse.statusCode) else { + throw NetworkError.serverError("Server responded with status code \(httpResponse.statusCode)") + } + + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) + if self.device.useV2 { + let jsonData = (json as! [String: Any])["data"]! + print(jsonData) + return jsonData + } else { +// print("Received data: \(json)") + return json + } + } catch { + print(error) + throw NetworkError.decodingError + } + } + + private func handleError(_ error: Error) { + if let networkError = error as? NetworkError { + switch networkError { + case .invalidURL: + errorMessage = "Invalid URL" + case .invalidResponse: + errorMessage = "Invalid response from server" + case .decodingError: + errorMessage = "Error decoding data" + case .serverError(let message): + errorMessage = "Server error: \(message)" + } + } else { + errorMessage = error.localizedDescription + } + print("Error: \(errorMessage ?? "Unknown error")") + } + + private enum RepeatMode: String { + case none = "none" + case queue = "all" + case track = "one" + + var symbol: String { + switch self { + case .none, .queue: + "repeat" + case .track: + "repeat.1" + } + } + + var next: Self { + switch self { + case .none: + return .track + case .queue: + return .none + case .track: + return .queue + } + } + + static func from(_ int: Int) -> Self { + switch int { + case 1: + return .track + case 2: + return .queue + default: + return .none + } + } + } + + private enum ShuffleMode { + case none + case shuffling + + var next: Self { + switch self { + case .none: + return .shuffling + case .shuffling: + return .none + } + } + } +} + +// MARK: - Extensions + +private extension View { + @ViewBuilder + func minimalView(height: CGFloat? = 450) -> some View { + self + .mask(alignment: .center) { + LinearGradient( + colors: [Color.white, Color.white, Color.white, Color.white.opacity(0.9), Color.white.opacity(0.8), Color.white.opacity(0.75), Color.white.opacity(0.65), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + } + .frame(height: height) } } @@ -450,69 +1530,20 @@ extension Image { func asUIImage() -> UIImage? { let controller = UIHostingController(rootView: self) let view = controller.view - + let targetSize = controller.view.intrinsicContentSize view?.bounds = CGRect(origin: .zero, size: targetSize) view?.backgroundColor = .clear - + let renderer = UIGraphicsImageRenderer(size: targetSize) - + return renderer.image { _ in view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) } } -} - -struct BlurredBackgroundView: View { - @Environment(\.colorScheme) var colorScheme - - let colors: [Color] - - var body: some View { - ZStack { - colorScheme == .dark ? Color.black.opacity(0.2) : Color.white.opacity(0.2) - - ForEach(colors.indices, id: \.self) { index in - Circle() - .fill(colors[index].opacity(colorScheme == .dark ? 0.6 : 0.4)) - .frame(width: 150, height: 150) - .offset(x: CGFloat.random(in: -100...100), - y: CGFloat.random(in: -100...100)) - .blur(radius: 60) - } - } - .ignoresSafeArea() - } -} - -struct BlurredImageView: View { - let image: Image - var body: some View { - image - .resizable() - .scaledToFill() - .frame(maxWidth: UIScreen.main.bounds.width, maxHeight: UIScreen.main.bounds.height) - .blur(radius: 60) + func dominantColors(count: Int = 3) -> [Color] { + return self.asUIImage()?.dominantColors(count: count) ?? [] } } -class Debouncer { - private let delay: TimeInterval - private var workItem: DispatchWorkItem? - private let queue: DispatchQueue - private let action: () -> Void - - init(delay: TimeInterval, queue: DispatchQueue = .main, action: @escaping () -> Void) { - self.delay = delay - self.queue = queue - self.action = action - } - - func call() { - workItem?.cancel() - let workItem = DispatchWorkItem(block: action) - self.workItem = workItem - queue.asyncAfter(deadline: .now() + delay, execute: workItem) - } -} diff --git a/Cider Remote/Views/MusicPlayer/PlayerControlsView.swift b/Cider Remote/Views/MusicPlayer/PlayerControlsView.swift deleted file mode 100644 index 8a6cf0f..0000000 --- a/Cider Remote/Views/MusicPlayer/PlayerControlsView.swift +++ /dev/null @@ -1,255 +0,0 @@ -// Made by Lumaa - -import SwiftUI - -struct PlayerControlsView: View { - @Environment(\.colorScheme) var systemColorScheme - - @EnvironmentObject var colorScheme: ColorSchemeManager - - @ObservedObject var viewModel: MusicPlayerViewModel - - @State private var isDragging: Bool = false - - let buttonSize: ElementSize - let geometry: GeometryProxy - - var body: some View { - let isIPad = UIDevice.current.userInterfaceIdiom == .pad - let safeZone: CGFloat = UserDevice.shared.horizontalOrientation == .portrait ? geometry.size.width * 0.85 : geometry.size.width * 0.45 - - VStack(spacing: 12) { // Increased spacing between main elements - VStack(spacing: 0) { // Increased spacing between slider and timestamps - CustomSlider(value: $viewModel.currentTime, // playback - bounds: 0...viewModel.duration, - isDragging: $isDragging, - onEditingChanged: { editing in - if !editing { - Task { - await viewModel.seekToTime() - } - } - }) - .tint(Color.white) - - // Timestamps - HStack { - Text(formatTime(viewModel.currentTime)) - Spacer() - Text(formatTime(viewModel.duration)) - } - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(width: safeZone) - - HStack(spacing: 0) { - Button(action: { - Task { - await viewModel.toggleLike() - } - }) { - Image(systemName: viewModel.isLiked ? "star.fill" : "star") - .foregroundStyle(viewModel.isLiked ? Color(hex: "#fa2f48") : Color.white.opacity(0.6)) - .frame(width: buttonSize.dimension, height: buttonSize.dimension) - } - .buttonStyle(SpringyButtonStyle()) - - Spacer() - - HStack(alignment: .center, spacing: calculateButtonSpacing()) { - Button(action: { - Task { - await viewModel.previousTrack() - } - }) { - Image(systemName: "backward.fill") - .font(.system(size: buttonSize.fontSize * 1.2)) - .foregroundStyle(Color.white.opacity(0.6)) - .frame(width: buttonSize.dimension * 1.2, height: buttonSize.dimension * 1.2) - } - .buttonStyle(SpringyButtonStyle()) - - Button(action: { - Task { - await viewModel.togglePlayPause() - } - }) { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: buttonSize.fontSize * 2.5)) - .foregroundStyle(Color.white.opacity(0.6)) - .frame(width: buttonSize.dimension * 1.8, height: buttonSize.dimension * 1.8) - } - .buttonStyle(SpringyButtonStyle()) - - Button(action: { - Task { - await viewModel.nextTrack() - } - }) { - Image(systemName: "forward.fill") - .font(.system(size: buttonSize.fontSize * 1.2)) - .foregroundStyle(Color.white.opacity(0.6)) - .frame(width: buttonSize.dimension * 1.2, height: buttonSize.dimension * 1.2) - } - .buttonStyle(SpringyButtonStyle()) - } - - Spacer() - - AdditionalControls(viewModel: viewModel, lightDarkColor: lightDarkColor, buttonSize: buttonSize) - } - .font(.system(size: isIPad ? 22 : 20)) // Slightly reduced font size for iPad - } - .padding(.top, isIPad ? 20 : 0) // Add padding at the top - } - - private func calculateButtonSpacing() -> CGFloat { - let isIPad = UIDevice.current.userInterfaceIdiom == .pad - let totalWidth = min(geometry.size.width * (isIPad ? 0.5 : 0.6), 300) - let buttonWidths = buttonSize.dimension * 1.2 * 2 + buttonSize.dimension * 1.8 - let remainingSpace = totalWidth - buttonWidths - - return remainingSpace / 4 // Divide by 4 to create 3 equal spaces between buttons - } - - private var lightDarkColor: Color { - systemColorScheme == .dark ? .white : .black - } - - private func formatTime(_ time: Double) -> String { - let minutes = Int(time) / 60 - let seconds = Int(time) % 60 - return String(format: "%d:%02d", minutes, seconds) - } -} - -struct AdditionalControls: View { - let viewModel: MusicPlayerViewModel - let lightDarkColor: Color - let buttonSize: ElementSize - - @State private var isSharing: Bool = false - - init(viewModel: MusicPlayerViewModel, lightDarkColor: Color, buttonSize: ElementSize) { - self.viewModel = viewModel - self.lightDarkColor = lightDarkColor - self.buttonSize = buttonSize - } - - var body: some View { - Menu { - Button { - Task { - await viewModel.toggleAddToLibrary() - } - } label: { - Label(viewModel.isInLibrary ? "Remove from Library" : "Add to Library", systemImage: viewModel.isInLibrary ? "minus" : "plus") - } - - Button { - Task { - await viewModel.toggleLike() - } - } label: { - Label(viewModel.isLiked ? "Unfavorite" : "Favorite", systemImage: viewModel.isLiked ? "star.fill" : "star") - } - - Button { - self.isSharing.toggle() - } label: { - Label("Share", systemImage: "square.and.arrow.up") - } - } label: { - Image(systemName: "ellipsis") - .foregroundStyle(Color.white.opacity(0.6)) - .frame(width: buttonSize.dimension * (UIDevice.current.userInterfaceIdiom == .pad ? 1.1 : 1.0), height: buttonSize.dimension * (UIDevice.current.userInterfaceIdiom == .pad ? 1.1 : 1.0)) - } - .buttonStyle(SpringyButtonStyle()) - .tint(Color.white) - .sheet(isPresented: $isSharing) { - if let currentTrack = viewModel.currentTrack { - ActivityViewController(item: .track(track: currentTrack)) - .presentationDetents([.medium, .large]) - } else { - Text("Nothing to share") - .font(.title2) - .foregroundStyle(Color.secondary) - .onAppear { - isSharing = false - } - } - } - } -} - -struct VolumeControlView: View { - @ObservedObject var viewModel: MusicPlayerViewModel - @State private var isDragging = false - - let geometry: GeometryProxy - - var body: some View { - let safeZone: CGFloat = UserDevice.shared.horizontalOrientation == .portrait ? geometry.size.width * 0.85 : geometry.size.width * 0.45 - HStack(spacing: 12) { - Image(systemName: "speaker.fill") - .foregroundStyle(.secondary) - CustomSlider(value: $viewModel.volume, - bounds: 0...1, - isDragging: $isDragging, - onEditingChanged: { editing in - if !editing { - Task { - viewModel.adjustVolume() - } - } - }) - .accentColor(.red) - Image(systemName: "speaker.wave.3.fill") - .foregroundStyle(.secondary) - } - .frame(width: safeZone, height: 30) // Set a fixed height for the volume control - } -} - -struct AdditionalControlsView: View { - @Environment(\.colorScheme) var colorScheme - - @Binding var showLyrics: Bool - @Binding var showQueue: Bool - - let buttonSize: ElementSize - let geometry: GeometryProxy - - var body: some View { - HStack(spacing: 30) { - Button(action: { - withAnimation(.easeInOut(duration: 0.5)) { - showLyrics.toggle() - } - }) { - Image(systemName: "quote.bubble") - .font(.system(size: 20)) - .foregroundStyle(Color.white.opacity(0.6)) - } - .buttonStyle(ScaleButtonStyle()) - .padding(.horizontal, 40) - - Spacer() - - Button(action: { - withAnimation(.easeInOut(duration: 0.5)) { - showQueue.toggle() - } - }) { - Image(systemName: "list.bullet") - .font(.system(size: 20)) - .foregroundStyle(Color.white.opacity(0.6)) - } - .buttonStyle(ScaleButtonStyle()) - .padding(.horizontal, 40) - } - .frame(maxWidth: .infinity) - .padding(10) - } -} diff --git a/Cider Remote/Views/MusicPlayer/QueueView.swift b/Cider Remote/Views/MusicPlayer/QueueView.swift index d6e1d7d..b9fd444 100644 --- a/Cider Remote/Views/MusicPlayer/QueueView.swift +++ b/Cider Remote/Views/MusicPlayer/QueueView.swift @@ -2,13 +2,15 @@ import SwiftUI -struct QueueView: View { +struct QueueView: View { @Environment(\.dismiss) private var dismiss: DismissAction @Environment(\.colorScheme) var colorScheme: ColorScheme - @EnvironmentObject var colorPalette: ColorSchemeManager + let device: Device - @ObservedObject var viewModel: MusicPlayerViewModel + @Binding var queueItems: [Track] + @Binding var sourceQueue: Queue? + @Binding var currentTrack: Track? @State private var tappedTrack: Track? = nil @State private var fetchingResults: Bool = false @@ -17,66 +19,36 @@ struct QueueView: View { @FocusState private var isSearching: Bool + var header: () -> Content + var body: some View { ZStack { List { - if #available(iOS 26.0, *) {} else { - BrowserView.access($librarySheet, background: colorPalette.primaryColor) - .padding(.horizontal) - .ciderRowOptimized() - - Divider() - .overlay { Color.white } - .padding(.horizontal) - .ciderRowOptimized() - } + self.header() + .ciderRowOptimized() - Section { - queueView - .ciderRowOptimized() - } - .ciderOptimized() + queueView + .ciderRowOptimized() } + .contentMargins(.bottom, 20, for: .scrollContent) + .contentMargins(.top, 10, for: .scrollContent) .ciderOptimized() - .fullScreenCover(isPresented: $librarySheet) { - BrowserView(device: viewModel.device) - } - .contentMargins(.vertical, UserDevice.shared.isBeta ? 60 : 0, for: .scrollContent) - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - BrowserView.access($librarySheet, background: colorPalette.primaryColor) - .padding(.horizontal) - } - } } .foregroundStyle(.primary) + .task { + await fetchQueueItems() + } } @ViewBuilder private var queueView: some View { - if viewModel.queueItems.count < 1 || (viewModel.queueItems.count == 1 && viewModel.queueItems.first == viewModel.currentTrack) { - if #available(iOS 17.0, *) { - ContentUnavailableView("Queue empty", systemImage: "list.number", description: Text("Your Cider queue is empty")) - } else { - VStack { - Image(systemName: "list.number") - .imageScale(.large) - .font(.title2) - .padding(.bottom) - - Text("Queue empty") - .font(.title3) - - Text("Your Cider queue is empty") - .font(.caption) - .foregroundStyle(Color.gray) - } - } + if queueItems.count < 1 || (queueItems.count == 1 && queueItems.first?.id == currentTrack?.id) { + ContentUnavailableView("Queue empty", systemImage: "list.number", description: Text("Your Cider queue is empty")) } else { - ForEach(viewModel.queueItems, id: \.id) { track in + ForEach(queueItems, id: \.id) { track in Button { Task { - await viewModel.playFromQueue(track) + await playFromQueue(track) } } label: { trackRow(track, showDuration: true) @@ -84,27 +56,29 @@ struct QueueView: View { } } .onDelete { set in - guard var sourceQueue = viewModel.sourceQueue else { return } - viewModel.queueItems.remove(atOffsets: set) + guard var sourceQueue = sourceQueue else { return } + + self.queueItems.remove(atOffsets: set) sourceQueue.remove(set: set) - viewModel.sourceQueue = sourceQueue + self.sourceQueue = sourceQueue Task { for i in set { - await viewModel.removeQueue(index: i) + await self.removeQueue(index: i) } } } .onMove { from, to in - guard var sourceQueue = viewModel.sourceQueue, let firstIndex = from.first else { return } - viewModel.queueItems.move(fromOffsets: from, toOffset: to) + guard var sourceQueue = sourceQueue, let firstIndex = from.first else { return } + + self.queueItems.move(fromOffsets: from, toOffset: to) sourceQueue.move(from: from, to: to) - viewModel.sourceQueue = sourceQueue + self.sourceQueue = sourceQueue Task { - await viewModel.moveQueue(from: firstIndex, to: to) + await self.moveQueue(from: firstIndex, to: to) } } } @@ -145,7 +119,7 @@ struct QueueView: View { Spacer() #if DEBUG - if let trackIndex = self.viewModel.sourceQueue?.firstIndex(of: track), trackIndex >= 0 { + if let trackIndex = sourceQueue?.firstIndex(of: track), trackIndex >= 0 { Text("\(trackIndex)") .font(.caption.bold()) } @@ -165,5 +139,127 @@ struct QueueView: View { let seconds = Int(duration) % 60 return String(format: "%d:%02d", minutes, seconds) } -} + // MARK: - Device functions + + func moveQueue(from startIndex: Int, to destinationIndex: Int) async { + guard let sourceQueue, startIndex != destinationIndex else { return } + do { + let path: String = device.useV2 ? "queue/move" : "playback/queue/move-to-position" + _ = try await device.sendRequest(endpoint: path, method: "POST", body: ["startIndex" : startIndex + sourceQueue.offset, "destinationIndex": destinationIndex + sourceQueue.offset]) + try? await Task.sleep(nanoseconds: 500_000_000) // we don't wait, then the *fetchQueueItems* will error + await self.fetchQueueItems() + } catch { + print(error) + } + } + + func removeQueue(index: Int) async { + guard let sourceQueue else { return } + do { + let path: String = device.useV2 ? "queue/items/\(index + sourceQueue.offset)" : "playback/queue/remove-by-index" + let method: String = device.useV2 ? "DELETE" : "POST" + _ = try await device.sendRequest(endpoint: path, method: method, body: ["index": index + sourceQueue.offset]) // body unused in v2 + } catch { + print(error) + } + } + + func playFromQueue(_ track: Track) async { + guard let sourceQueue, let index = sourceQueue.tracks.firstIndex(where: { $0.id == track.id }) else { return } + print("[QUEUE] play from queue") + + do { + let path: String = device.useV2 ? "queue/jump" : "playback/queue/change-to-index" + _ = try await device.sendRequest(endpoint: path, method: "POST", body: ["index" : index + sourceQueue.offset]) + await self.updateQueue(newTrack: track) + } catch { + print(error) + } + } + + private func updateQueue(newTrack: Track) async { + print("[QUEUE] smart update") + if newTrack.id == queueItems.first?.id { // newTrack is the next playing song in the queue + queueItems = Array(queueItems.dropFirst()) + } else { + await self.fetchQueueItems() + } + } + + func fetchQueueItems() async { + guard let currentTrack else { print("[QUEUE] Need currentTrack to get current queue"); return } + + print("Fetching current queue") + do { + if device.useV2 { + // v2 + var queueItem = Queue(tracks: []) + try await queueItem.fetchCurrent(device: device) + + self.sourceQueue = queueItem + self.queueItems = queueItem.tracks + } else { + let data = try await device.sendRequest(endpoint: "playback/queue") + if let jsonDict = data as? [[String: Any]] { + // v1 + let attributes: [[String : Any]] = jsonDict.compactMap { $0["attributes"] as? [String : Any] } + let queue: [Track] = attributes.map { getTrack(using: $0) } + + var queueItem: Queue = .init(tracks: queue) + queueItem.defineCurrent(track: currentTrack) + + self.sourceQueue = queueItem // after defining offset + self.queueItems = queueItem.tracks + } + } + } catch { + print(error) + } + } + + private func getTrack(using info: [String: Any]) -> Track { + // Extract ID from playParams + var id: String? + var amId: String? + + if let playParams = info["playParams"] as? [String: Any] { + id = playParams["id"] as? String + amId = playParams["catalogId"] as? String + } + + let title = info["name"] as? String ?? "" + let artist = info["artistName"] as? String ?? "" + let album = info["albumName"] as? String ?? "" + let duration = info["durationInMillis"] as? Double ?? 0 + + if let artwork = info["artwork"] as? [String: Any], + var artworkUrl = artwork["url"] as? String { + // Replace placeholders in artwork URL + artworkUrl = artworkUrl.replacingOccurrences(of: "{w}", with: "1024") + artworkUrl = artworkUrl.replacingOccurrences(of: "{h}", with: "1024") + + let data: Data? = nil + + return Track(id: id ?? "", + catalogId: amId ?? "", + title: title, + artist: artist, + album: album, + artwork: artworkUrl, + duration: duration / 1000, + artworkData: data ?? Data() + ) + } else { + return Track(id: id ?? "", + catalogId: amId ?? "", + title: title, + artist: artist, + album: album, + artwork: "", + duration: duration / 1000, + artworkData: Data() + ) + } + } +} diff --git a/Cider Remote/Views/MusicPlayer/TrackInfoView.swift b/Cider Remote/Views/MusicPlayer/TrackInfoView.swift deleted file mode 100644 index 9b1ca2e..0000000 --- a/Cider Remote/Views/MusicPlayer/TrackInfoView.swift +++ /dev/null @@ -1,98 +0,0 @@ -// Made by Lumaa - -import SwiftUI - -struct TrackInfoView: View { - let track: Track - let onImageLoaded: (UIImage) -> Void - let albumArtSize: ElementSize - let geometry: GeometryProxy - - @Binding var isCompact: Bool - - var body: some View { - if isCompact { - compact - } else { - large - } - } - - @ViewBuilder - var compact: some View { - HStack(spacing: 16.0) { - artwork - - VStack(alignment: .leading) { - Text(track.title) - .font(.body.bold()) - .lineLimit(1) - - Text(track.artist) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .transition(.opacity) - } - - @ViewBuilder - var large: some View { - let isIPad = UIDevice.current.userInterfaceIdiom == .pad - - let scale: CGFloat = isIPad ? 1.1 : 1.0 // Slightly reduced scale - let titleFontSize: CGFloat = CGFloat.getFontSize(UIFont.preferredFont(forTextStyle: .title2)) + 8.0 - let artistFontSize: CGFloat = CGFloat.getFontSize(UIFont.preferredFont(forTextStyle: .caption1)) + 8.0 - - VStack(spacing: 10 * scale) { // Reduced spacing - artwork - - VStack(spacing: 5 * scale) { - Text(track.title) - .font(.system(size: titleFontSize * scale).bold()) - .lineLimit(1) - - Text(track.artist) - .font(.system(size: artistFontSize * scale)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .padding(.bottom, isIPad ? 20 : 0) // use full display of iPad - .frame(maxWidth: .infinity, alignment: .center) - .transition(.opacity) - } - - @ViewBuilder - private var artwork: some View { - let deviceFactor: CGFloat = UserDevice.shared.isPad ? 0.8 : 0.9 - let artworkSize: CGFloat = isCompact ? 65 : (UserDevice.shared.horizontalOrientation == .portrait || UserDevice.shared.isPad ? geometry.size.width * deviceFactor : 250) - - AsyncImage(url: URL(string: track.artwork)) { phase in - switch phase { - case .empty: - ProgressView() - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fit) - .onAppear { - if let uiImage = image.asUIImage() { - onImageLoaded(uiImage) - } - } - case .failure: - Image(systemName: "music.note") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundStyle(.gray) - @unknown default: - EmptyView() - } - } - .frame(width: artworkSize, height: artworkSize) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(radius: 10) - } -} diff --git a/Cider Remote/Views/OnboardingView.swift b/Cider Remote/Views/OnboardingView.swift new file mode 100644 index 0000000..1852fc2 --- /dev/null +++ b/Cider Remote/Views/OnboardingView.swift @@ -0,0 +1,155 @@ +// Made by Lumaa + +import SwiftUI + +struct OnboardingView: View { + @Environment(\.colorScheme) private var originalScheme: ColorScheme + + @State private var appCover: Bool = false + private var onOK: (() -> Void)? + + init(onOK: (() -> Void)? = nil) { + self.onOK = onOK + } + + var body: some View { + ZStack { + Color.ciderBack + .ignoresSafeArea() + + VStack(spacing: 10.0) { + Image(.glassIcon) + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .environment(\.colorScheme, self.originalScheme) + .colorScheme(self.originalScheme) + + self.features + .padding() + + Spacer() + + Button { + if let onOK { + onOK() + } else { + self.appCover.toggle() + UserDefaults.standard.set(true, forKey: "onboarded") + } + } label: { + Text("OK") + .frame(maxWidth: .infinity, minHeight: 30.0, maxHeight: 30.0) + } + .padding(.horizontal, 30.0) + .tint(Color.cider) + .buttonStyle(.glassProminent) + .buttonBorderShape(.capsule) + .buttonSizing(.fitted) + } + } + .environment(\.colorScheme, ColorScheme.dark) + .colorScheme(ColorScheme.dark) + .fullScreenCover(isPresented: $appCover) { + ContentView() + .environment(\.colorScheme, self.originalScheme) + .colorScheme(self.originalScheme) + } + } + + var features: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 20) { + Text("Welcome to Cider Remote") + .font(.title2.bold()) + .scrollTransition { content, phase in + content + .opacity(phase.isIdentity ? 1 : 0) + .blur(radius: phase.isIdentity ? 0 : 5) + .offset(y: phase.isIdentity ? 0 : -15) + } + + ForEach(Self.Features.allCases, id: \.self) { f in + self.feature(f.appFeature) + } + + } + .frame(maxWidth: .infinity) + } + .scrollClipDisabled() + } + + @ViewBuilder + private func feature(_ feature: Self.AppFeature) -> some View { + HStack(alignment: .center) { + Image(systemName: feature.systemImage) + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + + VStack(alignment: .leading) { + Text(feature.title) + .bold() + .lineLimit(1) + .multilineTextAlignment(.leading) + Text(feature.description) + .font(.callout) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(.leading) + + Spacer() + } + .padding(.leading, 20) + .frame(maxWidth: .infinity) + .padding(.vertical) + .background(Color.gray.opacity(0.2)) + .clipShape(.capsule) + .scrollTransition { content, phase in + content + .opacity(phase.isIdentity ? 1 : 0) + .scaleEffect(x: phase.isIdentity ? 1 : 0.5, y: phase.isIdentity ? 1 : 0.75, anchor: .center) + .blur(radius: phase.isIdentity ? 0 : 10) + .offset(y: phase.isIdentity ? 0 : 10) + } + } + + private struct AppFeature { + let title: LocalizedStringKey + let description: LocalizedStringKey + let systemImage: String + + init(_ title: LocalizedStringKey, description: LocalizedStringKey, systemImage: String) { + self.title = title + self.description = description + self.systemImage = systemImage + } + } + + private enum Features: CaseIterable { + case liveActivity + case libraryBrowser + case horizontalLayout + case lyrics + case studio + case controlCenter + + var appFeature: AppFeature { + switch self { + case .liveActivity: + return .init("Live Activity", description: "The lock screen has it all, song info, background updates, play/pause...", systemImage: "clock.badge") + case .libraryBrowser: + return .init("Library Browser", description: "Browse your Apple Music library, play songs, count down future albums", systemImage: "book") + case .horizontalLayout: + return .init("Horizontal Layout", description: "Landscape or portrait, Remote will always have the perfect layout", systemImage: "iphone.landscape") + case .lyrics: + return .init("Sing along!", description: "Sing your favorite songs' lyrics at all times, and share them online!", systemImage: "music.microphone") + case .studio: + return .init("Sing together!", description: "Remote supports user submitted lyrics through Taproom!", systemImage: "person.3.fill") + case .controlCenter: + return .init("Control Center actions", description: "Not in Remote? Control Cider through the Control Center", systemImage: "switch.2") + } + } + } +} diff --git a/Cider Remote/Views/SettingsView.swift b/Cider Remote/Views/SettingsView.swift index 71ff9a8..689e654 100644 --- a/Cider Remote/Views/SettingsView.swift +++ b/Cider Remote/Views/SettingsView.swift @@ -6,13 +6,6 @@ struct SettingsView: View { @Environment(\.openURL) private var openURL: OpenURLAction @Environment(\.dismiss) private var dismiss: DismissAction - @EnvironmentObject var colorScheme: ColorSchemeManager - - // appearence - @AppStorage("useAdaptiveColors") private var useAdaptiveColors: Bool = true - @AppStorage("buttonSize") private var buttonSize: ElementSize = .medium - @AppStorage("albumArtSize") private var albumArtSize: ElementSize = .large - // advanced @AppStorage("alwaysOn") private var alwaysOn: Bool = false @AppStorage("alertLiveActivity") private var alertLiveActivity: Bool = false @@ -21,8 +14,11 @@ struct SettingsView: View { @AppStorage("deviceDetails") private var deviceDetails: Bool = false @AppStorage("refreshInterval") private var refreshInterval: Double = 10.0 + // other + @State private var onboardingScreen: Bool = false + var body: some View { - NavigationView { + NavigationStack { List { Section(header: Text("Feedback")) { Button { @@ -42,26 +38,9 @@ struct SettingsView: View { } } - Section(header: Text("Appearance")) { - Toggle(isOn: $useAdaptiveColors) { - Label("Use Dynamic Colors", systemImage: "paintpalette.fill") - } - .foregroundStyle(Color(uiColor: UIColor.label)) - - Picker(selection: $buttonSize) { - ForEach(ElementSize.allCases) { size in - Text(size.rawValue.capitalized) - .id(size) - } - } label: { - Label("Button Size", systemImage: "button.horizontal.top.press.fill") - } - .foregroundStyle(Color(uiColor: UIColor.label)) - .pickerStyle(.menu) - } - Section(header: Text("Advanced")) { Toggle("Always-on Immersive", isOn: $alwaysOn) + Toggle(isOn: $alertLiveActivity) { HStack(spacing: 8.0) { unstablePill @@ -73,7 +52,6 @@ struct SettingsView: View { Section(header: Text("Devices")) { Toggle("Device Information", isOn: $deviceDetails) -// Button("Reset All Devices", role: .destructive, action: resetAllDevices) VStack(alignment: .leading) { HStack(alignment: .center) { @@ -88,21 +66,29 @@ struct SettingsView: View { Slider(value: $refreshInterval, in: 5...60, step: 5) { Text("Refresh Interval: \(Int(refreshInterval)) seconds") } - .onChange(of: refreshInterval) { _, _ in - let impact = UIImpactFeedbackGenerator(style: .light) //MARK: API is deprecated - impact.impactOccurred() - } } } + Section { + Button { + self.onboardingScreen = true + } label: { + Text("Show Onboarding") + } + } + .fullScreenCover(isPresented: $onboardingScreen) { + OnboardingView { + self.onboardingScreen = false + } + } + Section(header: Text("About")) { - HStack { - Text("Version") - Spacer() - Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") - .foregroundStyle(.secondary) - } - + LabeledContent { + Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") + } label: { + Text("Version") + } + NavigationLink { ChangelogsView() } label: { @@ -111,14 +97,10 @@ struct SettingsView: View { } Section { - Text("© Cider Collective 2024-2025") - .font(.footnote) - .foregroundStyle(.secondary) - NavigationLink { ContributorsView() } label: { - Text("Made with ❤️ by contributors") + Text("© Cider Collective 2024-2026") .font(.footnote) .foregroundStyle(.secondary) } @@ -128,7 +110,6 @@ struct SettingsView: View { } } } - .listStyle(InsetGroupedListStyle()) .navigationTitle(Text("Settings")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -153,32 +134,3 @@ struct SettingsView: View { .clipShape(Capsule()) } } - -enum ElementSize: String, Hashable, CaseIterable, Identifiable { - case small, medium, large - var id: Self { self } - - var dimension: CGFloat { - switch self { - case .small: return 40 - case .medium: return 60 // This was 50 before, now it's 60 to match the original size - case .large: return 80 // Increased to take up more space - } - } - - var fontSize: CGFloat { - switch self { - case .small: return 16 - case .medium: return 24 // Increased from 20 to 24 - case .large: return 32 // Increased from 24 to 32 - } - } - - var padding: CGFloat { - switch self { - case .small: return 8 - case .medium: return 12 - case .large: return 20 // Increased from 16 to 20 - } - } -} diff --git a/NowPlaying/Data/DeviceEntity.swift b/NowPlaying/Data/DeviceEntity.swift index 4c94596..4033602 100644 --- a/NowPlaying/Data/DeviceEntity.swift +++ b/NowPlaying/Data/DeviceEntity.swift @@ -35,7 +35,7 @@ struct DeviceEntity: Identifiable, Codable, AppEntity { self.name = device.friendlyName self.token = device.token self.host = device.host - self.connectionMethod = device.connectionMethod + self.connectionMethod = device.connectionMethod.rawValue self.isActive = device.isActive self.isPlaying = false } @@ -61,6 +61,7 @@ struct DeviceEntity: Identifiable, Codable, AppEntity { var request = URLRequest(url: url) request.httpMethod = method request.addValue(self.token, forHTTPHeaderField: "apptoken") + request.timeoutInterval = 3.0 if let body = body { request.httpBody = try? JSONSerialization.data(withJSONObject: body) diff --git a/NowPlaying/Intents/TimeTrackIntent.swift b/NowPlaying/Intents/TimeTrackIntent.swift index af5e798..9317533 100644 --- a/NowPlaying/Intents/TimeTrackIntent.swift +++ b/NowPlaying/Intents/TimeTrackIntent.swift @@ -28,7 +28,9 @@ struct TimeTrackIntent: AppIntent { } func perform() async throws -> some IntentResult { - let (statusCode, _) = await device.sendRequest(endpoint: "playback/active") +// let path: String = device.useV2 ? "playback/state" : "playback/active" +// let (statusCode, _) = await device.sendRequest(endpoint: path) + let (statusCode, _) = await device.sendRequest(endpoint: "playback/active") if statusCode == 200 { var req: String = "playback/unknown" diff --git a/NowPlaying/Intents/TogglePlayIntent.swift b/NowPlaying/Intents/TogglePlayIntent.swift index f146700..de55a21 100644 --- a/NowPlaying/Intents/TogglePlayIntent.swift +++ b/NowPlaying/Intents/TogglePlayIntent.swift @@ -61,17 +61,21 @@ struct TogglePlayButtonIntent: AppIntent { Summary("Toggle play/pause on Cider") } + var device: DeviceEntity? + func perform() async throws -> some IntentResult { - let devices: [DeviceEntity] = try await DeviceQuery().suggestedEntities() + guard let devices = await self.getDevices() else { return .result() } for device in devices { let (statusCode, _) = await device.sendRequest(endpoint: "playback/active") if statusCode == 200 { (_, _) = await device.sendRequest(endpoint: "playback/playpause", method: "POST") + if #available(iOS 18.0, *) { ControlCenter.shared.reloadControls(ofKind: "sh.cider.CiderRemote.PlayPauseControl") } + return .result() } else { print("[AppIntent] - No toggle \(statusCode)") @@ -80,4 +84,12 @@ struct TogglePlayButtonIntent: AppIntent { return .result() } + + private func getDevices() async -> [DeviceEntity]? { + if let device { + return [device] + } else { + return try? await DeviceQuery().suggestedEntities() + } + } } diff --git a/NowPlaying/NowPlayingLiveActivity.swift b/NowPlaying/NowPlayingLiveActivity.swift index b315436..6a49b00 100644 --- a/NowPlaying/NowPlayingLiveActivity.swift +++ b/NowPlaying/NowPlayingLiveActivity.swift @@ -18,9 +18,7 @@ struct NowPlayingLiveActivity: Widget { } DynamicIslandExpandedRegion(.leading) { - Image("Logo") - .resizable() - .scaledToFit() + self.artwork(using: context) .frame(width: 65, height: 65, alignment: .center) .clipShape(RoundedRectangle(cornerRadius: 3.0)) } @@ -42,7 +40,7 @@ struct NowPlayingLiveActivity: Widget { .resizable() .scaledToFit() } - .keylineTint(Color.pink) + .keylineTint(Color.cider) } } @@ -50,13 +48,9 @@ struct NowPlayingLiveActivity: Widget { private func expandView(using context: ActivityViewContext, dynamicIsland: Bool = false) -> some View { HStack { if !dynamicIsland { - ZStack { - Image(uiImage: UIImage.logo) // TEMPORARY SOLUTION - .resizable() - .scaledToFit() - .frame(width: 40, height: 40, alignment: .center) - .clipShape(RoundedRectangle(cornerRadius: 3.0)) - } + self.artwork(using: context) + .frame(width: 40, height: 40, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 3.0)) } VStack(alignment: .leading) { @@ -85,18 +79,25 @@ struct NowPlayingLiveActivity: Widget { @ViewBuilder private func playBtn(using context: ActivityViewContext) -> some View { - if #available(iOS 17.0, *) { - Button(intent: TogglePlayButtonIntent()) { - Image(systemName: "playpause.fill") - .font(.title) - .foregroundStyle(Color.white) - } - .buttonStyle(.plain) - } else { - Image(systemName: "waveform") - .font(.title2) + Button(intent: TogglePlayButtonIntent(device: .init(from: context.attributes.device))) { + Image(systemName: "playpause.fill") + .font(.title) .foregroundStyle(Color.white) } + .buttonStyle(.plain) + } + + @ViewBuilder + private func artwork(using context: ActivityViewContext) -> some View { +// if let data: Data = context.state.artwork, let ui: UIImage = UIImage(data: data) { +// Image(uiImage: ui) +// .resizable() +// .scaledToFit() +// } else { + Image("Logo") + .resizable() + .scaledToFit() +// } } struct NowPlayingAttributes: ActivityAttributes { @@ -107,7 +108,7 @@ struct NowPlayingLiveActivity: Widget { hasher.combine(trackInfo.id) } - var trackInfo: Track + var trackInfo: LiveActivityManager.DisplayingTrack } } } diff --git a/README.md b/README.md index 708fa52..31bea18 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Cider Remote Banner + Cider Remote Banner Cider Remote on the App Store @@ -11,17 +11,18 @@ [Cider Remote](https://cider.sh/remote) is a native iOS app, built with [SwiftUI](https://developer.apple.com/swiftui/) and [Socket.io](https://socket.io/), that gives remote controls to [Cider](https://cider.sh/). -> [!NOTE] -> Cider Remote is also available on Android: [`ciderapp/Cider-Remote-RN`](https://github.com/ciderapp/Cider-Remote-RN) - [Cider Remote](https://cider.sh/remote) is the official [Cider](https://cider.sh/) remote control app on iPhone and iPad using iOS 17 or later, with these features: +> [!NOTE] +> Remote 4.0.0 should work with previous version of Cider + - Seamless communications between [Remote](https://cider.sh/remote) and [Cider](https://cider.sh/) -- Live Activity with the playing track and quick actions ([ActivityKit](https://developer.apple.com/documentation/ActivityKit)) +- Live Activity with the playing track and quick actions ([ActivityKit](https://developer.apple.com/documentation/ActivityKit), iOS 16.1+) - Horizontal Layout (Landscape) - Queue Management +- [Taproom](https://taproom.cider.sh/)'s Lyrics Studio ([LyricsStudioKit](https://github.com/The-Amber-Team/LyricsStudioKit), iOS 16+) - Apple Music & MusixMatch Lyrics (+ Immersive Lyrics in Horizontal Layout) -- Siri Shortcuts actions ([App Intents](https://developer.apple.com/documentation/appintents)) +- Siri Shortcuts actions ([App Intents](https://developer.apple.com/documentation/appintents), iOS 16+) - Control Center actions ([WidgetKit](https://developer.apple.com/documentation/widgetkit/creating-controls-to-perform-actions-across-the-system#Add-a-control-toggle-to-your-app), iOS 18+) - Liquid Glass design (iOS 26+) @@ -116,4 +117,8 @@ Join the [TestFlight beta](https://testflight.apple.com/join/qTeV2T2w) here. This project is licensed under the Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) license. See the [LICENSE](./LICENSE) file for details. -© Cider Collective 2024-2025 +© Cider Collective 2024-2026 + +# Android + +Cider Remote is also available on Android: [`ciderapp/Cider-Remote-RN`](https://github.com/ciderapp/Cider-Remote-RN). There should also be a separate Cider client on Android coming soon. \ No newline at end of file diff --git a/README_data/AppBanner.png b/README_data/AppBanner.png new file mode 100644 index 0000000..3d11eda Binary files /dev/null and b/README_data/AppBanner.png differ