diff --git a/Cider Remote.xcodeproj/project.pbxproj b/Cider Remote.xcodeproj/project.pbxproj index df3eaa7..3ec09d9 100644 --- a/Cider Remote.xcodeproj/project.pbxproj +++ b/Cider Remote.xcodeproj/project.pbxproj @@ -7,16 +7,11 @@ 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 */; }; 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 +49,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 +91,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 +108,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 */ @@ -153,27 +141,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,10 +177,8 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + B998C12B2EA2B54500FF1517 /* Cider Remote */, B9E7D9FC2D0125E800840996 /* NowPlaying */, - B9E7DA0A2D0125F100840996 /* Views */, - B9E7DA182D0125F500840996 /* Components */, - B9E7DA2F2D01260000840996 /* Data */, ); name = NowPlayingExtension; packageProductDependencies = ( @@ -234,10 +204,7 @@ B9CDA84B2CC686AC00FBF580 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - B99015492D46931300D4CE93 /* Preview Content */, - B9E7DA0A2D0125F100840996 /* Views */, - B9E7DA182D0125F500840996 /* Components */, - B9E7DA2F2D01260000840996 /* Data */, + B998C12B2EA2B54500FF1517 /* Cider Remote */, ); name = "Cider Remote"; packageProductDependencies = ( @@ -294,7 +261,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B99C7C292DBD96E400B6CD36 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -302,8 +268,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B913FE682E17108A005A4680 /* AppIcon.icon in Resources */, - B9D289322CC51497008543A7 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -321,8 +285,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B9D2892E2CC51497008543A7 /* Cider_RemoteApp.swift in Sources */, - B9CDA87D2CC69A7300FBF580 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -352,7 +314,7 @@ 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", @@ -388,7 +350,7 @@ 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", @@ -549,13 +511,14 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiresFullScreen = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + 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"; @@ -589,13 +552,14 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiresFullScreen = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + 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"; diff --git a/Cider Remote.xcodeproj/project.xcworkspace/xcuserdata/lumaa.xcuserdatad/UserInterfaceState.xcuserstate b/Cider Remote.xcodeproj/project.xcworkspace/xcuserdata/lumaa.xcuserdatad/UserInterfaceState.xcuserstate index e79b503..b141560 100644 Binary files a/Cider Remote.xcodeproj/project.xcworkspace/xcuserdata/lumaa.xcuserdatad/UserInterfaceState.xcuserstate and b/Cider Remote.xcodeproj/project.xcworkspace/xcuserdata/lumaa.xcuserdatad/UserInterfaceState.xcuserstate 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/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/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/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/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 index de91689..1f578db 100644 --- a/Cider Remote/Data/Browser/LibraryAlbum.swift +++ b/Cider Remote/Data/Browser/LibraryAlbum.swift @@ -7,6 +7,7 @@ struct LibraryAlbum: Identifiable, Hashable { let title: String let artist: String let artwork: String + var audioType: Track.AudioType = .unknown var tracks: [LibraryTrack]? = nil @@ -31,7 +32,54 @@ struct LibraryAlbum: Identifiable, Hashable { self.artwork = (artwork["url"] as! String).replacing(/\{(w|h)\}/, with: "\(700)") } } else { - self.artwork = "" + 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]] 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/LibraryTrack.swift b/Cider Remote/Data/Browser/LibraryTrack.swift index 722b2c6..f5de117 100644 --- a/Cider Remote/Data/Browser/LibraryTrack.swift +++ b/Cider Remote/Data/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/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/Device.swift b/Cider Remote/Data/Device.swift index 1c98eca..4522f6f 100644 --- a/Cider Remote/Data/Device.swift +++ b/Cider Remote/Data/Device.swift @@ -92,10 +92,12 @@ class Device: Identifiable, Codable, ObservableObject, Hashable { } 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]) 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 diff --git a/Cider Remote/Data/DeviceManager.swift b/Cider Remote/Data/DeviceManager.swift index 3057061..6d1c8d6 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 { 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..10ec98a 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 = "" } } 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/Track.swift b/Cider Remote/Data/Track.swift index 4d7efe8..46d93b8 100644 --- a/Cider Remote/Data/Track.swift +++ b/Cider Remote/Data/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/Views/Browser/BrowserTabView.swift b/Cider Remote/Views/Browser/BrowserTabView.swift index 72d9b41..f5561fa 100644 --- a/Cider Remote/Views/Browser/BrowserTabView.swift +++ b/Cider Remote/Views/Browser/BrowserTabView.swift @@ -86,7 +86,6 @@ struct BrowserTabView: View { .clipShape(RoundedRectangle(cornerRadius: 7.0)) } } - .plainGlassButton() .disabled(self.isLoadingMore) .padding(.top, 15.0) .padding(.bottom, 5.0) diff --git a/Cider Remote/Views/Browser/BrowserView.swift b/Cider Remote/Views/Browser/BrowserView.swift index 171f975..a058171 100644 --- a/Cider Remote/Views/Browser/BrowserView.swift +++ b/Cider Remote/Views/Browser/BrowserView.swift @@ -87,7 +87,6 @@ struct BrowserView: View { .clipShape(RoundedRectangle(cornerRadius: 7.0)) } } - .plainGlassButton() .disabled(self.isLoadingMore) .padding(.top, 15.0) .padding(.bottom, 5.0) @@ -142,47 +141,20 @@ struct BrowserView: View { Button { sheetVisible.wrappedValue.toggle() } label: { - if #available(iOS 26.0, *) { - HStack(alignment: .center, spacing: 14) { - Image(systemName: "play.square.stack.fill") - .imageScale(.large) - .foregroundStyle(Color.white) - - Text("View Library") - .font(.body.bold()) - .foregroundStyle(Color.white) - .lineLimit(1) - } - .frame(maxWidth: .infinity, minHeight: 50) - .glassEffect(.regular.interactive()) - } else { - HStack(alignment: .center, spacing: 14) { - Image(systemName: "play.square.stack.fill") - .imageScale(.large) - .foregroundStyle(Color.white) - - Text("View Library") - .font(.body.bold()) - .foregroundStyle(Color.white) - .lineLimit(1) - } - .frame(maxWidth: .infinity, minHeight: 50) - .background { - ZStack { - Rectangle() - .fill(Material.ultraThin) - .zIndex(10) - - Rectangle() - .fill(background.gradient) - .opacity(0.6) - .zIndex(1) - } - } - .clipShape(RoundedRectangle(cornerRadius: 12)) + HStack(alignment: .center, spacing: 14) { + Image(systemName: "play.square.stack.fill") + .imageScale(.medium) + .foregroundStyle(Color.white) + + Text("View Library") + .font(.callout.bold()) + .foregroundStyle(Color.white) + .lineLimit(1) } + .padding(.horizontal) + .padding(.vertical, 7.5) } - .plainGlassButton() + .buttonStyle(.glass) } } diff --git a/Cider Remote/Views/Browser/LibraryAlbumView.swift b/Cider Remote/Views/Browser/LibraryAlbumView.swift index 7eec0ad..8917996 100644 --- a/Cider Remote/Views/Browser/LibraryAlbumView.swift +++ b/Cider Remote/Views/Browser/LibraryAlbumView.swift @@ -1,5 +1,6 @@ // Made by Lumaa +import AVKit import SwiftUI struct LibraryAlbumView: View { @@ -8,9 +9,14 @@ struct LibraryAlbumView: View { @State var album: LibraryAlbum @State private var isLoading: Bool = true + @State private var multiDisc: Bool = false - @State private var sharingTrack: LibraryTrack? = nil @State private var releaseDate: Date? = nil + + @State private var player: AVPlayer? = nil + @State private var videoURL: URL? = nil + + @State private var sharingTrack: LibraryTrack? = nil @State private var sharingImage: UIImage? = nil init(_ album: LibraryAlbum) { @@ -54,14 +60,14 @@ struct LibraryAlbumView: View { Button { Task { await self.playHref(href: track.href) - // await self.clearQueue() - // self.playNext(from: track) - // - // DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - // Task { - // await self.skipTrack() - // } - // } +// await self.clearQueue() +// self.playNext(from: track) +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2) { +// Task { +// await self.skipTrack() +// } +// } } } label: { LibraryTrackRow(track, number: track.trackNumber, showCover: false) @@ -105,14 +111,21 @@ struct LibraryAlbumView: View { } Divider() } - .padding(.top) + .padding(.top, videoURL != nil ? 0 : 15.0) } } + .padding(.top, videoURL != nil ? -120 : 0) .navigationTitle(Text(album.title)) .navigationBarTitleDisplayMode(.inline) .task { defer { self.isLoading = false } + + let amData: (URL?, Track.AudioType) = await self.album.getAnimatedCover(using: device, size: .tall) + self.videoURL = amData.0 + self.album.audioType = amData.1 + self.album.tracks = await self.getTracks(from: self.album) + self.setupPlayer() if let last = self.album.tracks?.map({ $0.discNumber }).last { self.multiDisc = last > 1 @@ -126,57 +139,88 @@ 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 + } + 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 +241,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 { @@ -278,6 +336,7 @@ 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 } + 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..f4ded51 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,6 +76,16 @@ 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") } @@ -89,6 +102,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) @@ -213,4 +230,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..43bde55 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,156 +133,147 @@ 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() } } } @@ -290,9 +281,59 @@ struct Changelog: Hashable, Identifiable { // MARK: Changelogs are HERE extension Changelog { + /// Remote 4.0.0 + static var v400: Changelog { + var temp = Changelog(version: "4.0.0", authors: ["Lumaa"], commits: "main...v4") + temp = temp + .setChanges(additions: [ + "Cider Collective team in the contributors screen", + "Animated album covers", + "Shuffle, repeat and autoplay buttons at the top of the queue (thanks gabrielzv1233!)", + "Share lyrics by tap-and-holding a lyric", + "Tap a lyric to go and listen to it", + "Left-to-right and right-to-left 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", + "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 & Remote v4.0.0 will now re-appear with new text", + "Changed macOS icon for default macOS icon", + "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: Lyrics now correctly load", + ], removals: [ + "TEMPORARY: Horizontal Layout", + "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/ContentView.swift b/Cider Remote/Views/ContentView.swift index d7f6ac1..5d72b28 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,9 @@ struct ContentView: View { } } .onAppear { - if !UserDefaults.standard.bool(forKey: "updatePopup") { + UserDefaults.standard.removeObject(forKey: "updatePopup") + + if !UserDefaults.standard.bool(forKey: "updatePopup_v4") { AppPrompt.shared.showingPrompt = .update } } @@ -90,12 +88,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 +106,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) } } @@ -237,18 +235,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 @@ -282,32 +268,33 @@ struct StatusIndicator: View { } struct ConnectionGuideView: View { - @Environment(\.presentationMode) var presentationMode - + @Environment(\.dismiss) var dismiss: DismissAction + var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Prerequisites:") - .font(.headline) + .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)", + "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) + .font(.title2.bold()) 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.") + GuideStep(number: 1, text: "Launch the Cider Remote app on your iPhone.") + GuideStep(number: 2, text: "Open Cider on your Computer, tap the '...' menu, and visit 'Help > Connect a Remote app' and create a device. (Some devices may prefer WAN over LAN.)") + GuideStep(number: 3, text: "In Cider Remote, tap the plus icon in the top right corner, and use the camera to scan the QR code displayed in Cider.") + 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(.headline) + .font(.title2.bold()) Text("If you can't connect:") .font(.subheadline) BulletedList(items: [ @@ -318,7 +305,7 @@ struct ConnectionGuideView: View { ]) Text("Firewall Settings:") - .font(.subheadline) + .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", @@ -326,24 +313,31 @@ struct ConnectionGuideView: View { ]) Text("For QR code scanning issues:") - .font(.subheadline) + .font(.title2.bold()) BulletedList(items: [ - "Ensure the code is clearly visible and well-lit", + "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 support forum or GitHub issues page.") + 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() } - .navigationBarTitle("Connection Guide") + .navigationTitle("Connection Guide") .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: Button("Close") { - presentationMode.wrappedValue.dismiss() - }) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } } } } @@ -352,7 +346,7 @@ 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 +355,18 @@ struct DeviceIconView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } - var deviceImage: UIImage { + var deviceImage: Image { let osType = device.os ?? device.platform + switch osType.lowercased() { case "win32": - return UIImage(named: "Windows") ?? UIImage(systemName: "desktopcomputer")! + return Image("Windows") case "darwin": - return UIImage(named: "macOS") ?? UIImage(systemName: "desktopcomputer")! + return Image("macOS") case "linux": - return UIImage(named: "Linux") ?? UIImage(systemName: "desktopcomputer")! + return Image("Linux") default: - return UIImage(systemName: "desktopcomputer")! + return Image(systemName: "desktopcomputer") } } } diff --git a/Cider Remote/Views/ContributorsView.swift b/Cider Remote/Views/ContributorsView.swift index 226b3cf..9652391 100644 --- a/Cider Remote/Views/ContributorsView.swift +++ b/Cider Remote/Views/ContributorsView.swift @@ -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,23 @@ struct ContributorsView: View { let pfp: URL? let commitCount: Int - init(id: String, name: String, ghLink: URL, commits: Int = 0, pfp: URL? = nil) { + /// Cider Collective (10/25) + 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: "DeadFrost", ghLink: URL(string: "https://github.com/DeadFrostt")!, pfp: URL(string: "https://avatars.githubusercontent.com/u/71704732?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..b9e6f6e 100644 --- a/Cider Remote/Views/Devices/AddDeviceView.swift +++ b/Cider Remote/Views/Devices/AddDeviceView.swift @@ -32,13 +32,14 @@ struct AddDeviceView: View { } } } label: { - Label("Add New Cider Device", systemImage: "plus.circle") + Label("Add New Cider Device", systemImage: "plus") + .foregroundStyle(Color.cider) } .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\":\"4.0.0\",\"platform\":\"genten\",\"os\":\"darwin\"}}"), text: $jsonTxt) .padding() .textFieldStyle(.roundedBorder) @@ -53,24 +54,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()) @@ -162,7 +145,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 +197,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..f0f0c67 100644 --- a/Cider Remote/Views/Devices/DeviceRowView.swift +++ b/Cider Remote/Views/Devices/DeviceRowView.swift @@ -24,11 +24,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)") diff --git a/Cider Remote/Views/Devices/DevicesView.swift b/Cider Remote/Views/Devices/DevicesView.swift index be8a523..2b69f54 100644 --- a/Cider Remote/Views/Devices/DevicesView.swift +++ b/Cider Remote/Views/Devices/DevicesView.swift @@ -5,28 +5,33 @@ 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 + private var devices: [Device] { + DeviceManager.shared.devices + } + var body: some View { VStack(spacing: 0) { - header - List { ForEach(devices) { device in - NavigationLink(value: device) { + Button { + guard device.isActive else { return } + + self.viewingDevice = device + } label: { DeviceRowView(device: device) } + .tint(Color.primary) .swipeActions(edge: .trailing) { Button(role: .destructive) { DeviceManager.shared.remove(device) @@ -35,33 +40,43 @@ struct DevicesView: View { } } } - - AddDeviceView(isShowingScanner: $isShowingScanner, scannedCode: $scannedCode) { json in - self.fetchDevices(from: json) - } - - Button(action: { - isShowingGuide = true - }) { - Label("Connection Guide", systemImage: "questionmark.circle") - } } - .listStyle(InsetGroupedListStyle()) + .listStyle(.insetGrouped) .task { await self.refreshDevices() } .refreshable { await self.refreshDevices() } -#if DEBUG - Label("This is a DEBUG version.", systemImage: "gearshape.2.fill") - .foregroundStyle(.orange) - .accessibility(label: Text("Debug software")) -#endif + } + .toolbar { + ToolbarItem(placement: .principal) { + header + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + isShowingGuide = true + } label: { + Label("Connection Guide", systemImage: "questionmark.circle") + .foregroundStyle(Color.cider) + } + } + + ToolbarSpacer(.fixed, placement: .topBarTrailing) + + ToolbarItem(placement: .topBarTrailing) { + AddDeviceView(isShowingScanner: $isShowingScanner, scannedCode: $scannedCode) { json in + self.fetchDevices(from: json) + } + .buttonStyle(.glassProminent) + .tint(Color.cider) + } } .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() @@ -81,13 +96,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 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..52379bd 100644 --- a/Cider Remote/Views/MusicPlayer/LyricsView.swift +++ b/Cider Remote/Views/MusicPlayer/LyricsView.swift @@ -1,25 +1,32 @@ // Made by Lumaa + import SwiftUI 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: @@ -33,93 +40,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) { 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 +116,154 @@ struct LyricsView: View { activeLine = currentLine } } + + + private func fetchAllLyrics() async { + defer { self.isLoading = false } + self.isLoading = true + + let success: Bool = await self.fetchLyricsAm() // apple music + if !success { + _ = await self.fetchLyricsMxm() // musixmatch + } + } + + /// 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]) + + 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)") + } + 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]], !data.isEmpty else { return nil } + + if let storefrontId: String = data[0]["id"] as? String { + return storefrontId + } + } 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 +271,61 @@ 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) + } } 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 +367,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 +393,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..9f18162 --- /dev/null +++ b/Cider Remote/Views/MusicPlayer/MoreActionsMenu.swift @@ -0,0 +1,58 @@ +// Made by Lumaa + +import SwiftUI + +struct MoreActionsMenu: View { + var currentTrack: Track + + var toggleAddToLibrary: () async -> Void + var toggleLike: () async -> Void + + @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") + } + } + } 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..9191fa4 100644 --- a/Cider Remote/Views/MusicPlayer/MusicPlayerView.swift +++ b/Cider Remote/Views/MusicPlayer/MusicPlayerView.swift @@ -6,375 +6,1284 @@ // 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(\.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 + 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() + } + .ignoresSafeArea(.container) + .frame(maxHeight: .infinity) + .background { ZStack { - Color.black + Rectangle() + .fill(Color.black) .ignoresSafeArea() - if let currentImage { - BlurredImageView(image: Image(uiImage: currentImage)) + if self.backgroundColors.count == 1 { + Rectangle() + .fill(self.backgroundColors[0]) .ignoresSafeArea() - .overlay { - Color.black - .opacity(0.5) - .ignoresSafeArea() - } + } else if self.backgroundColors.count == 25 { + AnimatedMeshGradientView(colors: $backgroundColors, amplify: 0.25) + .ignoresSafeArea() + .opacity(0.3) + } + } + } + .overlay(alignment: .bottom) { + VStack { + if expandedView { + trackData + .padding(.horizontal, Self.horizontalPadding) + } + + 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) + } + .fullScreenCover(isPresented: $showingLibrary) { + BrowserView(device: device) + .environment(\.colorScheme, systemColorScheme) // restore user's color scheme + } + .task { + self.startListening() + + 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) + player.play() + } + } + + if newValue { + withAnimation(.easeOut.speed(1.3)) { + self.showingQueue = false + } + } + } + .onChange(of: scenePhase) { _, newValue in + if newValue == .active, let player { + player.play() + } + } + .environment(\.colorScheme, ColorScheme.dark) + } + + @ViewBuilder + private var artwork: some View { + if let track = self.currentTrack { + if videoArtwork != nil && expandedView, let player { + UninteractableVideoPlayer(player: player) + .aspectRatio(LibraryAlbum.AnimatedCover.tall.ratio, contentMode: .fit) + .frame(maxWidth: .infinity) + .mask(alignment: .center) { + 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 { + 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() } - } - .fullScreenCover(isPresented: $librarySheet) { - BrowserView(device: viewModel.device) - } - } + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + Image(systemName: "music.note") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.gray) + @unknown default: + EmptyView() } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.bottom, geometry.safeAreaInsets.bottom) - .padding(.top, userDevice.isPad ? (isCompact ? 0 : 50) : (isCompact ? 0 : 30)) } + .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) } - .tint(colorScheme.primaryColor) - .frame(width: geometry.size.width, height: geometry.size.height) } - .edgesIgnoringSafeArea(.horizontal) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isCompact) - .environmentObject(colorScheme) - .environment(\.colorScheme, ColorScheme.dark) - .onAppear { - colorScheme.updateColorScheme(systemColorScheme) - viewModel.startListening() + } - Task { - await viewModel.initializePlayer() - await MainActor.run { - withAnimation { - isLoading = false + @ViewBuilder + 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) + + Text(currentTrack.artist) + .font(.title2) + .foregroundStyle(Color.secondary) + .opacity(0.5) + .lineLimit(1) + } + + 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, + isInLibrary: $isInLibrary, + isLiked: $isLiked + ) } } } - .onDisappear { - viewModel.stopListening() - if colorScheme.useAdaptiveColors { - colorScheme.resetToDefaultColors() + } + + @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) + } + } + } + .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) + + if self.audioFormat == .unknown { + Spacer() + } else { + Spacer() + + 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) } - LiveActivityManager().stopActivity() } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { + } + + @ViewBuilder + private var navigationActions: some View { + HStack { + 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()) + + Spacer() + + BrowserView.access($showingLibrary) + + Spacer() + + Button { Task { - viewModel.refreshCurrentTrack() + await self.getAutoplay() + await self.getRepeat() } - if colorScheme.useAdaptiveColors { - colorScheme.reapplyAdaptiveColors() + + 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()) + } + } + + @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()) } } - .onChange(of: systemColorScheme) { _, newColorScheme in - colorScheme.updateColorScheme(newColorScheme) + } + + 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() } - .onChange(of: viewModel.needsColorUpdate) { _, needsUpdate in - if needsUpdate && colorScheme.useAdaptiveColors { - updateColors() + + newPlayer.play() + } + + // 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") + } } } - .onChange(of: viewModel.showingQueue) { _, newShow in - withAnimation(.spring) { - self.isCompact = newShow + + 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)") + } } } - .onChange(of: viewModel.showingLyrics) { _, newShow in - withAnimation(.spring) { - self.isCompact = newShow + } + + 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() } - .onChange(of: userDevice.horizontalOrientation) { _, _ in - withAnimation(.spring) { - self.viewModel.showingQueue = false - self.viewModel.showingLyrics = false + } + + 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 } + + await self.handleColors() + } catch { + handleError(error) } } - @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) + /// 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]] 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 isCompact { - Spacer() + if let audioTraits: [String] = attributes["audioTraits"] as? [String] { + print(audioTraits) + self.audioFormat = Track.AudioType.find(audioTraits) + } - closeBtn + 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 } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) + } - if isCompact { - if viewModel.showingQueue { - QueueView(viewModel: viewModel) - } else if viewModel.showingLyrics { - LyricsView(viewModel: viewModel) + 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) + } else { + throw NetworkError.decodingError } - } else { - VStack(spacing: 15) { - PlayerControlsView(viewModel: viewModel, buttonSize: buttonSize, geometry: geometry) + } catch { + handleError(error) + } + } - VolumeControlView(viewModel: viewModel, geometry: geometry) + 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 } - AdditionalControlsView( - showLyrics: $viewModel.showingLyrics, - showQueue: $viewModel.showingQueue, - buttonSize: buttonSize, - geometry: geometry - ) + if let storefrontId: String = data[0]["id"] as? String { + self.storefrontCache = storefrontId + return storefrontId } - .padding(.horizontal, userDevice.isPad ? 40 : 20) + } catch { + print("Error fetching storefront: \(error)") + handleError(error) } + + return nil } - @ViewBuilder - private func landscapeView(track: Track, geometry: GeometryProxy, rightButtons: Bool = false) -> 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) + 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 + } - VolumeControlView(viewModel: viewModel, geometry: geometry) - .padding(.horizontal) + if let storefront { + return URL(string: "https://music.apple.com/\(storefront)/song/\(currentTrack.catalogId)") + } else { + return nil + } + } - AdditionalControlsView( - showLyrics: $viewModel.showingLyrics, - showQueue: $viewModel.showingQueue, - buttonSize: buttonSize, - geometry: geometry - ) - } - .frame(width: geometry.size.width / 2 - 20) + private func setPlaybackStatus(_ info: [String: Any]) { + print("Setting playback status: \(info)") + if let state = info["state"] as? String { + self.isPlaying = (state == "playing") + } + } - 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) - } + 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 { - 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()) + self.isLiked = false + } - Text("Seems like you can't view your queue in landscape mode YET...") - .font(.caption) - .foregroundStyle(Color.secondary) + if let isInLibrary = info["inLibrary"] as? Int, isInLibrary == 1 { + self.isInLibrary = true + } else { + self.isInLibrary = false + } - Button { - withAnimation { - self.viewModel.showingQueue.toggle() - } - } label: { - Text("Close Queue") - } - .buttonStyle(.bordered) - .padding(.top) - } + 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 ?? "", 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() } } } } - .onAppear { -#if !WIDGET - self.alwaysOn(UserDefaults.standard.bool(forKey: "alwaysOn")) -#endif + + if alt { + self.isLiked = info["inFavorites"] as? Bool ?? false + self.isInLibrary = info["inLibrary"] as? Bool ?? false } - .onDisappear { -#if !WIDGET - self.alwaysOn(false) -#endif + 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 alwaysOn(_ bool: Bool) { -#if !WIDGET - UIApplication.shared.isIdleTimerDisabled = bool - print("always-\(bool ? "on" : "off")") -#endif + private func resetAVPlayer() async { + self.videoArtwork = nil + self.player = nil + + self.videoArtwork = await self.getAnimatedCover(size: .tall) + if self.videoArtwork != nil { + self.setupAVPlayer() + } } - private var closeBtn: some View { - Button { - withAnimation(.spring) { - withAnimation(.spring) { - viewModel.showingQueue = false - viewModel.showingLyrics = false - } - } - } label: { - if #available(iOS 26.0, *) { - Image(systemName: "xmark") - .foregroundStyle(Color(uiColor: UIColor.label)) - .padding(12) - .glassEffect(.regular.interactive()) + 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 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 { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Color.white.opacity(0.4)) - .font(.system(size: 28)) + throw NetworkError.decodingError } + } catch { + handleError(error) } } - private func updateColors() { - self.currentImage = nil + 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) + } + } - guard let artworkUrl = viewModel.currentTrack?.artwork, - let url = URL(string: artworkUrl) else { - colorScheme.resetToDefaultColors() - return + 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) } + } - Task { + 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 result = try await sendRequest(endpoint: "playback/repeat-mode", method: "GET") + if let data = result as? [String: Any] { + let val: Int = data["value"] as? Int ?? 0 + self.repeatMode = .init(rawValue: val) ?? .none + } + } catch { + handleError(error) + } + } + + func getShuffle() async { + do { + let result = try await sendRequest(endpoint: "playback/shuffle-mode", method: "GET") + if let data = result as? [String: Any] { + let val: Int = data["value"] as? Int ?? 0 + self.shuffleMode = .init(rawValue: val) ?? .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 { + _ = 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 cycleRepeat() async { + print("Cycling through repeat") + let lastRepeat: RepeatMode = self.repeatMode + withAnimation { + self.repeatMode = .init(rawValue: self.repeatMode.rawValue + 1) ?? .none + } + do { + _ = try await sendRequest(endpoint: "playback/toggle-repeat", method: "POST") + } catch { + self.repeatMode = lastRepeat + handleError(error) + } + } + + func cycleShuffle() async { + print("Cycling through shuffle") + let lastShuffle: ShuffleMode = self.shuffleMode + withAnimation { + self.shuffleMode = .init(rawValue: self.shuffleMode.rawValue + 1) ?? .none + } + do { + _ = try await sendRequest(endpoint: "playback/toggle-shuffle", method: "POST") + } catch { + self.shuffleMode = lastShuffle + handleError(error) + } + } + + func toggleAutoplay() async { + print("Toggling autoplay") + withAnimation { + self.isAutoPlaying.toggle() // Immediately update UI + } + do { + _ = try await sendRequest(endpoint: "playback/toggle-autoplay", method: "POST") + } catch { + isAutoPlaying.toggle() + 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() + + 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 + _ = try await sendRequest(endpoint: "playback/add-to-library", 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 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) + } + } + + 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) 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")") + } + + private enum RepeatMode: Int { + case none = 0 + case queue = 2 + case track = 1 + + var symbol: String { + switch self { + case .none, .queue: + "repeat" + case .track: + "repeat.1" + } + } + } + + private enum ShuffleMode: Int { + case none = 0 + case shuffling = 1 + } +} + +// 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 +1359,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..ee367bd 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,33 @@ 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) } @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 +53,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 +116,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 +136,113 @@ 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 { + _ = try await device.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 self.fetchQueueItems() + } catch { + print(error) + } + } + + func removeQueue(index: Int) async { + guard let sourceQueue else { return } + do { + _ = try await device.sendRequest(endpoint: "playback/queue/remove-by-index", method: "POST", body: ["index": index + sourceQueue.offset]) + } 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 { + _ = try await device.sendRequest(endpoint: "playback/queue/change-to-index", 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 { + let data = try await device.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 { + 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/SettingsView.swift b/Cider Remote/Views/SettingsView.swift index 71ff9a8..adb01fd 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 @@ -22,7 +15,7 @@ struct SettingsView: View { @AppStorage("refreshInterval") private var refreshInterval: Double = 10.0 var body: some View { - NavigationView { + NavigationStack { List { Section(header: Text("Feedback")) { Button { @@ -42,26 +35,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 +49,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,10 +63,6 @@ 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() - } } } @@ -111,14 +82,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-2025") .font(.footnote) .foregroundStyle(.secondary) } @@ -128,7 +95,6 @@ struct SettingsView: View { } } } - .listStyle(InsetGroupedListStyle()) .navigationTitle(Text("Settings")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -153,32 +119,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..1cac436 100644 --- a/NowPlaying/Data/DeviceEntity.swift +++ b/NowPlaying/Data/DeviceEntity.swift @@ -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/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 60534ba..2bfcd2a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ +# Remote v4.0.0 +You are currently viewing the Cider Remote source code for the v4.0.0 version, which changes the user interface for a much more Apple Music-like experience, with the new logistic of Cider 4. + +This version is currently work in progress, which means that everything you see and do may cause unexpected behaviors. + +Please be warned that all previously existing features from v3.1.0 (and less) may not end up in v4.0.0. + +# Remote v3.1.0

Cider Remote Banner @@ -103,4 +111,4 @@ Join the [TestFlight beta](https://testflight.apple.com/join/qTeV2T2w) here. # License & Copyright 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 \ No newline at end of file +© Cider Collective 2024-2025