├── .gitattributes ├── PlayerWidget ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── WidgetBackground.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── WidgetIntents.swift ├── Info.plist ├── PlayerWidget.entitlements ├── PlayerWidgetBundle.swift ├── AppIntent.swift ├── PlayerWidget.swift └── PlaylistWidget.swift ├── Cosmos Music Player ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── output-onlinepngtools(2).png │ │ └── Contents.json │ ├── SpotifyBlack.imageset │ │ ├── Spotify_Primary_Logo_RGB_Black.png │ │ └── Contents.json │ └── SpotifyWhite.imageset │ │ ├── Spotify_Primary_Logo_RGB_White.png │ │ └── Contents.json ├── Cosmos-Music-Player-Bridging-Header.h ├── Helpers │ ├── ObjCExceptionCatcher.h │ ├── Cosmos Music Player 2025-10-04 10-22-35 │ │ ├── ExportOptions.plist │ │ └── DistributionSummary.plist │ ├── ObjCExceptionCatcher.m │ └── EnvironmentLoader.swift ├── Cosmos Music Player.entitlements ├── Models │ ├── StateModels.swift │ ├── SettingsModels.swift │ ├── SFBTrack.swift │ ├── DatabaseModels.swift │ └── WidgetData.swift ├── Services │ ├── PlaybackRouter.swift │ ├── DiscogsAPI.swift │ └── SpotifyAPI.swift ├── Info.plist ├── Views │ ├── Utility │ │ ├── SettingsView.swift │ │ └── UtilityViews.swift │ └── Player │ │ └── QueueManagementView.swift ├── ContentView.swift ├── ViewModels │ └── TutorialViewModel.swift ├── Resources │ ├── en.lproj │ │ └── Localizable.strings │ └── fr.lproj │ │ └── Localizable.strings └── Cosmos_Music_PlayerApp.swift ├── Cosmos Music Player.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── Cosmos Music Player.xcscheme │ ├── Share.xcscheme │ └── SiriIntentsExtension.xcscheme ├── Share ├── Share.entitlements ├── Info.plist └── Base.lproj │ └── MainInterface.storyboard ├── PlayerWidgetExtension.entitlements ├── SiriIntentsExtension ├── SiriIntentsExtension.entitlements └── Info.plist ├── .env.template └── .gitignore /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /PlayerWidget/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Cosmos Music Player/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Cosmos Music Player/Assets.xcassets/AppIcon.appiconset/output-onlinepngtools(2).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clquwu/Cosmos-Music-Player/HEAD/Cosmos Music Player/Assets.xcassets/AppIcon.appiconset/output-onlinepngtools(2).png -------------------------------------------------------------------------------- /Cosmos Music Player.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /PlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Cosmos Music Player/Assets.xcassets/SpotifyBlack.imageset/Spotify_Primary_Logo_RGB_Black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clquwu/Cosmos-Music-Player/HEAD/Cosmos Music Player/Assets.xcassets/SpotifyBlack.imageset/Spotify_Primary_Logo_RGB_Black.png -------------------------------------------------------------------------------- /Cosmos Music Player/Assets.xcassets/SpotifyWhite.imageset/Spotify_Primary_Logo_RGB_White.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clquwu/Cosmos-Music-Player/HEAD/Cosmos Music Player/Assets.xcassets/SpotifyWhite.imageset/Spotify_Primary_Logo_RGB_White.png -------------------------------------------------------------------------------- /Cosmos Music Player/Cosmos-Music-Player-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Cosmos-Music-Player-Bridging-Header.h 3 | // Cosmos Music Player 4 | // 5 | // Objective-C to Swift bridging header 6 | // 7 | 8 | #import "Helpers/ObjCExceptionCatcher.h" 9 | -------------------------------------------------------------------------------- /PlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /PlayerWidget/WidgetIntents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetIntents.swift 3 | // PlayerWidget 4 | // 5 | // Reserved for future widget intents 6 | // 7 | 8 | import AppIntents 9 | import Foundation 10 | import WidgetKit 11 | 12 | // No active intents - widgets use URL schemes for navigation 13 | -------------------------------------------------------------------------------- /Cosmos Music Player/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "output-onlinepngtools(2).png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Share/Share.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.dev.clq.Cosmos-Music-Player 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /PlayerWidget/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.widgetkit-extension 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /PlayerWidgetExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.dev.clq.Cosmos-Music-Player 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /PlayerWidget/PlayerWidget.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.dev.clq.Cosmos-Music-Player 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /SiriIntentsExtension/SiriIntentsExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.dev.clq.Cosmos-Music-Player 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Cosmos Music Player/Assets.xcassets/SpotifyBlack.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Spotify_Primary_Logo_RGB_Black.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Cosmos Music Player/Assets.xcassets/SpotifyWhite.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Spotify_Primary_Logo_RGB_White.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /PlayerWidget/PlayerWidgetBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerWidgetBundle.swift 3 | // PlayerWidget 4 | // 5 | // Created by CLQ on 07/12/2025. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | @main 12 | struct PlayerWidgetBundle: WidgetBundle { 13 | var body: some Widget { 14 | PlayerWidget() 15 | PlaylistWidget() 16 | // PlayerWidgetControl() - Control Center widget (iOS 18+) 17 | // PlayerWidgetLiveActivity() - Live Activity / Dynamic Island 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # API Keys Template for Cosmos Music Player 2 | # Copy this file to .env and fill in your actual API keys 3 | 4 | # Spotify API Keys 5 | # Get them from: https://developer.spotify.com/dashboard/applications 6 | SPOTIFY_CLIENT_ID=your_spotify_client_id_here 7 | SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here 8 | 9 | # Discogs API Keys 10 | # Get them from: https://www.discogs.com/settings/developers 11 | DISCOGS_CONSUMER_KEY=your_discogs_consumer_key_here 12 | DISCOGS_CONSUMER_SECRET=your_discogs_consumer_secret_here -------------------------------------------------------------------------------- /PlayerWidget/AppIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppIntent.swift 3 | // PlayerWidget 4 | // 5 | // Created by CLQ on 07/12/2025. 6 | // 7 | 8 | import WidgetKit 9 | import AppIntents 10 | 11 | struct ConfigurationAppIntent: WidgetConfigurationIntent { 12 | static var title: LocalizedStringResource { "Configuration" } 13 | static var description: IntentDescription { "This is an example widget." } 14 | 15 | // An example configurable parameter. 16 | @Parameter(title: "Favorite Emoji", default: "😃") 17 | var favoriteEmoji: String 18 | } 19 | -------------------------------------------------------------------------------- /Cosmos Music Player/Helpers/ObjCExceptionCatcher.h: -------------------------------------------------------------------------------- 1 | // 2 | // ObjCExceptionCatcher.h 3 | // Cosmos Music Player 4 | // 5 | // Catches Objective-C exceptions that Swift can't handle 6 | // 7 | 8 | #import 9 | 10 | NS_ASSUME_NONNULL_BEGIN 11 | 12 | @interface ObjCExceptionCatcher : NSObject 13 | 14 | /// Executes a block and catches any Objective-C exceptions 15 | /// Returns YES if successful, NO if an exception was caught 16 | + (BOOL)tryCatch:(void(^)(void))tryBlock error:(NSError *_Nullable __autoreleasing *_Nullable)error; 17 | 18 | @end 19 | 20 | NS_ASSUME_NONNULL_END 21 | -------------------------------------------------------------------------------- /Cosmos Music Player/Helpers/Cosmos Music Player 2025-10-04 10-22-35/ExportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | destination 6 | export 7 | iCloudContainerEnvironment 8 | Production 9 | method 10 | release-testing 11 | signingStyle 12 | automatic 13 | stripSwiftSymbols 14 | 15 | teamID 16 | PC237X7NGJ 17 | thinning 18 | <none> 19 | 20 | 21 | -------------------------------------------------------------------------------- /PlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /SiriIntentsExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | IntentsRestrictedWhileLocked 10 | 11 | IntentsSupported 12 | 13 | INPlayMediaIntent 14 | 15 | SupportedMediaCategories 16 | 17 | INMediaCategoryMusic 18 | 19 | 20 | NSExtensionPointIdentifier 21 | com.apple.intents-service 22 | NSExtensionPrincipalClass 23 | $(PRODUCT_MODULE_NAME).IntentHandler 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Cosmos Music Player/Cosmos Music Player.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.icloud-container-identifiers 6 | 7 | iCloud.$(PRODUCT_BUNDLE_IDENTIFIER) 8 | 9 | com.apple.developer.icloud-services 10 | 11 | CloudDocuments 12 | CloudKit 13 | 14 | com.apple.developer.siri 15 | 16 | com.apple.developer.ubiquity-container-identifiers 17 | 18 | iCloud.$(PRODUCT_BUNDLE_IDENTIFIER) 19 | 20 | com.apple.security.application-groups 21 | 22 | group.dev.clq.Cosmos-Music-Player 23 | 24 | com.apple.developer.carplay-audio 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Share/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleShortVersionString 6 | 1.1.2 7 | CFBundleVersion 8 | 43 9 | NSExtension 10 | 11 | NSExtensionAttributes 12 | 13 | NSExtensionActivationRule 14 | 15 | NSExtensionActivationSupportsFileWithMaxCount 16 | 100 17 | NSExtensionActivationSupportsText 18 | 19 | NSExtensionActivationSupportsWebURLWithMaxCount 20 | 0 21 | NSExtensionActivationSupportsImageWithMaxCount 22 | 0 23 | 24 | 25 | NSExtensionMainStoryboard 26 | MainInterface 27 | NSExtensionPointIdentifier 28 | com.apple.share-services 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Cosmos Music Player/Models/StateModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateModels.swift 3 | // Cosmos Music Player 4 | // 5 | // JSON state models for favorites and playlists sync 6 | // 7 | 8 | import Foundation 9 | 10 | struct FavoritesState: Codable { 11 | let version: Int 12 | let updatedAt: Date 13 | let favorites: [String] 14 | 15 | init(favorites: [String]) { 16 | self.version = 1 17 | self.updatedAt = Date() 18 | self.favorites = favorites 19 | } 20 | } 21 | 22 | struct PlaylistState: Codable { 23 | let version: Int 24 | let slug: String 25 | let title: String 26 | let createdAt: Date 27 | let updatedAt: Date 28 | let items: [PlaylistItem] 29 | 30 | struct PlaylistItem: Codable { 31 | let trackId: String 32 | let addedAt: Date 33 | } 34 | 35 | init(slug: String, title: String, createdAt: Date, items: [(String, Date)]) { 36 | self.version = 1 37 | self.slug = slug 38 | self.title = title 39 | self.createdAt = createdAt 40 | self.updatedAt = Date() 41 | self.items = items.map { PlaylistItem(trackId: $0.0, addedAt: $0.1) } 42 | } 43 | } -------------------------------------------------------------------------------- /Cosmos Music Player/Helpers/ObjCExceptionCatcher.m: -------------------------------------------------------------------------------- 1 | // 2 | // ObjCExceptionCatcher.m 3 | // Cosmos Music Player 4 | // 5 | // Catches Objective-C exceptions that Swift can't handle 6 | // 7 | 8 | #import "ObjCExceptionCatcher.h" 9 | 10 | @implementation ObjCExceptionCatcher 11 | 12 | + (BOOL)tryCatch:(void(^)(void))tryBlock error:(NSError **)error { 13 | @try { 14 | tryBlock(); 15 | return YES; 16 | } 17 | @catch (NSException *exception) { 18 | NSLog(@"⚠️ Caught Objective-C exception: %@ - %@", exception.name, exception.reason); 19 | if (error) { 20 | *error = [NSError errorWithDomain:@"ObjCException" 21 | code:-1 22 | userInfo:@{ 23 | NSLocalizedDescriptionKey: exception.reason ?: @"Objective-C exception occurred", 24 | NSLocalizedFailureReasonErrorKey: exception.name, 25 | @"ExceptionName": exception.name, 26 | @"ExceptionReason": exception.reason ?: @"Unknown" 27 | }]; 28 | } 29 | return NO; 30 | } 31 | } 32 | 33 | @end 34 | -------------------------------------------------------------------------------- /Share/Base.lproj/MainInterface.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # Environment Variables 93 | .env 94 | .env.local 95 | .env.production 96 | .env.staging 97 | 98 | # API Keys and Secrets 99 | api-keys.json 100 | secrets.plist 101 | Config.plist 102 | 103 | # macOS 104 | .DS_Store -------------------------------------------------------------------------------- /Cosmos Music Player/Models/SettingsModels.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import UIKit 4 | 5 | enum BackgroundColor: String, CaseIterable, Codable { 6 | case violet = "b11491" 7 | case red = "e74c3c" 8 | case blue = "3498db" 9 | case green = "27ae60" 10 | case orange = "f39c12" 11 | case pink = "e91e63" 12 | case teal = "1abc9c" 13 | case purple = "9b59b6" 14 | 15 | var name: String { 16 | switch self { 17 | case .violet: return "Violet (Default)" 18 | case .red: return "Red" 19 | case .blue: return "Blue" 20 | case .green: return "Green" 21 | case .orange: return "Orange" 22 | case .pink: return "Pink" 23 | case .teal: return "Teal" 24 | case .purple: return "Purple" 25 | } 26 | } 27 | 28 | var color: Color { 29 | return Color(hex: self.rawValue) 30 | } 31 | } 32 | 33 | enum DSDPlaybackMode: String, CaseIterable, Codable { 34 | case auto = "auto" 35 | case pcm = "pcm" 36 | case dop = "dop" 37 | 38 | var displayName: String { 39 | switch self { 40 | case .auto: return Localized.dsdModeAuto 41 | case .pcm: return Localized.dsdModePCM 42 | case .dop: return Localized.dsdModeDoP 43 | } 44 | } 45 | 46 | var description: String { 47 | switch self { 48 | case .auto: return Localized.dsdModeAutoDescription 49 | case .pcm: return Localized.dsdModePCMDescription 50 | case .dop: return Localized.dsdModeDoDescription 51 | } 52 | } 53 | } 54 | 55 | struct DeleteSettings: Codable { 56 | var hasShownDeletePopup: Bool = false 57 | var minimalistIcons: Bool = false 58 | var backgroundColorChoice: BackgroundColor = .violet 59 | var forceDarkMode: Bool = false 60 | var dsdPlaybackMode: DSDPlaybackMode = .pcm 61 | var lastLibraryScanDate: Date? = nil 62 | 63 | static func load() -> DeleteSettings { 64 | guard let data = UserDefaults.standard.data(forKey: "DeleteSettings"), 65 | let settings = try? JSONDecoder().decode(DeleteSettings.self, from: data) else { 66 | return DeleteSettings() 67 | } 68 | return settings 69 | } 70 | 71 | func save() { 72 | if let data = try? JSONEncoder().encode(self) { 73 | UserDefaults.standard.set(data, forKey: "DeleteSettings") 74 | } 75 | } 76 | } 77 | 78 | // MARK: - Color Extension for Widget 79 | extension Color { 80 | func toHex() -> String { 81 | #if canImport(UIKit) 82 | let components = UIColor(self).cgColor.components 83 | let r = Float(components?[0] ?? 0) 84 | let g = Float(components?[1] ?? 0) 85 | let b = Float(components?[2] ?? 0) 86 | 87 | return String(format: "%02lX%02lX%02lX", 88 | lroundf(r * 255), 89 | lroundf(g * 255), 90 | lroundf(b * 255)) 91 | #else 92 | return "b11491" // Default violet 93 | #endif 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Cosmos Music Player.xcodeproj/xcshareddata/xcschemes/Cosmos Music Player.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Cosmos Music Player/Services/PlaybackRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaybackRouter.swift 3 | // Cosmos Music Player 4 | // 5 | // Smart audio playback routing for different formats 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | import SFBAudioEngine 11 | 12 | enum PlaybackError: Error { 13 | case unsupportedFormat 14 | case fileNotFound 15 | case decodingFailed 16 | case dacNotConnected 17 | } 18 | 19 | /// Playback strategy pattern for different audio formats 20 | class PlaybackRouter { 21 | 22 | enum PlaybackStrategy { 23 | case native(AVAudioFile) 24 | case sfbAudio(AudioDecoder) // SFBAudioEngine for Opus, Vorbis, DSD 25 | } 26 | 27 | /// Determine the optimal playback strategy for a given audio file 28 | static func determineStrategy(for url: URL) async throws -> PlaybackStrategy { 29 | let ext = url.pathExtension.lowercased() 30 | 31 | switch ext { 32 | case "flac", "mp3", "wav", "aac": 33 | // Native AVAudioFile formats 34 | let file = try AVAudioFile(forReading: url) 35 | return .native(file) 36 | 37 | case "m4a": 38 | if isOpusInM4A(url) { 39 | // Opus in M4A → SFBAudioEngine 40 | let decoder = try AudioDecoder(url: url) 41 | return .sfbAudio(decoder) 42 | } else { 43 | // AAC in M4A → Native 44 | let file = try AVAudioFile(forReading: url) 45 | return .native(file) 46 | } 47 | 48 | case "opus", "ogg": 49 | // All Opus/Vorbis containers → SFBAudioEngine 50 | let decoder = try AudioDecoder(url: url) 51 | return .sfbAudio(decoder) 52 | 53 | case "dsf", "dff": 54 | // DSD formats → SFBAudioEngine with DSD to PCM conversion 55 | let decoder = try AudioDecoder(url: url) 56 | return .sfbAudio(decoder) 57 | 58 | default: 59 | throw PlaybackError.unsupportedFormat 60 | } 61 | } 62 | 63 | 64 | /// Check if M4A file contains Opus codec 65 | static func isOpusInM4A(_ url: URL) -> Bool { 66 | // Check MP4 atoms for Opus codec 67 | guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else { 68 | return false 69 | } 70 | 71 | // Look for 'Opus' atom in MP4 structure 72 | // MP4 structure: ftyp → moov → trak → mdia → minf → stbl → stsd → Opus 73 | let opusSignature = "Opus".data(using: .ascii)! 74 | return data.range(of: opusSignature, in: 0.. (format: String, badge: String?) { 79 | let ext = url.pathExtension.lowercased() 80 | 81 | switch ext { 82 | case "flac": 83 | return ("FLAC", nil) 84 | case "mp3": 85 | return ("MP3", nil) 86 | case "wav": 87 | return ("WAV", nil) 88 | case "aac": 89 | return ("AAC", nil) 90 | case "m4a": 91 | if isOpusInM4A(url) { 92 | return ("Opus", "OPUS") 93 | } else { 94 | return ("AAC", nil) 95 | } 96 | case "opus": 97 | return ("Opus", "OPUS") 98 | case "ogg": 99 | // Could be Opus or Vorbis - would need deeper inspection 100 | return ("OGG", "OGG") 101 | case "dsf": 102 | return ("DSD", "DSD") 103 | case "dff": 104 | return ("DSDIFF", "DSD") 105 | default: 106 | return ("Unknown", nil) 107 | } 108 | } 109 | } 110 | 111 | -------------------------------------------------------------------------------- /Cosmos Music Player/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Cosmos 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.1.2 21 | CFBundleVersion 22 | 43 23 | INAlternativeAppNames 24 | 25 | 26 | INAlternativeAppName 27 | Cosmos Music Player 28 | INAlternativeAppNamePronunciationHint 29 | cosmos music player 30 | 31 | 32 | INAlternativeAppName 33 | Cosmos 34 | INAlternativeAppNamePronunciationHint 35 | cosmos 36 | 37 | 38 | INAlternativeAppName 39 | Cosmos Musique 40 | INAlternativeAppNamePronunciationHint 41 | cosmos musique 42 | 43 | 44 | LSRequiresIPhoneOS 45 | 46 | LSSupportsOpeningDocumentsInPlace 47 | 48 | NSUbiquitousContainers 49 | 50 | iCloud.dev.clq.Cosmos-Music-Player 51 | 52 | NSUbiquitousContainerIsDocumentScopePublic 53 | 54 | NSUbiquitousContainerName 55 | Cosmos Music Player 56 | NSUbiquitousContainerSupportedFolderLevels 57 | Any 58 | 59 | 60 | UIApplicationSceneManifest 61 | 62 | UIApplicationSupportsMultipleScenes 63 | 64 | UISceneConfigurations 65 | 66 | CPTemplateApplicationSceneSessionRoleApplication 67 | 68 | 69 | UISceneConfigurationName 70 | CarPlay 71 | UISceneClassName 72 | CPTemplateApplicationScene 73 | UISceneDelegateClassName 74 | $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate 75 | 76 | 77 | 78 | 79 | UIApplicationSupportsIndirectInputEvents 80 | 81 | UIBackgroundModes 82 | 83 | audio 84 | 85 | UIFileSharingEnabled 86 | 87 | UILaunchScreen 88 | 89 | UIRequiredDeviceCapabilities 90 | 91 | arm64 92 | 93 | UIStatusBarStyle 94 | 95 | UISupportedInterfaceOrientations 96 | 97 | UIInterfaceOrientationPortrait 98 | 99 | UISupportedInterfaceOrientations~ipad 100 | 101 | UIInterfaceOrientationLandscapeLeft 102 | UIInterfaceOrientationLandscapeRight 103 | UIInterfaceOrientationPortrait 104 | UIInterfaceOrientationPortraitUpsideDown 105 | 106 | CFBundleURLTypes 107 | 108 | 109 | CFBundleURLName 110 | dev.clq.cosmos-music-player 111 | CFBundleURLSchemes 112 | 113 | cosmos-music 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /Cosmos Music Player.xcodeproj/xcshareddata/xcschemes/Share.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 59 | 61 | 67 | 68 | 69 | 70 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /Cosmos Music Player.xcodeproj/xcshareddata/xcschemes/SiriIntentsExtension.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 11 | 17 | 23 | 24 | 25 | 31 | 37 | 38 | 39 | 40 | 41 | 47 | 48 | 60 | 64 | 65 | 66 | 72 | 73 | 74 | 75 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /Cosmos Music Player/Helpers/EnvironmentLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentLoader.swift 3 | // Cosmos Music Player 4 | // 5 | // Environment variable loader for API keys and configuration 6 | // 7 | 8 | import Foundation 9 | 10 | class EnvironmentLoader: @unchecked Sendable { 11 | static let shared = EnvironmentLoader() 12 | 13 | private var environmentVariables: [String: String] = [:] 14 | 15 | private init() { 16 | loadEnvironmentVariables() 17 | } 18 | 19 | private func loadEnvironmentVariables() { 20 | // First, try to load from .env file in the app bundle 21 | if let envPath = Bundle.main.path(forResource: ".env", ofType: nil) { 22 | loadFromFile(path: envPath) 23 | } 24 | 25 | // Also try loading from .env in the project root (for development) 26 | let projectRoot = Bundle.main.bundlePath 27 | let rootEnvPath = URL(fileURLWithPath: projectRoot) 28 | .appendingPathComponent("../../../.env") 29 | .standardized 30 | .path 31 | 32 | if FileManager.default.fileExists(atPath: rootEnvPath) { 33 | loadFromFile(path: rootEnvPath) 34 | } 35 | 36 | // Finally, load from actual environment variables (overrides file values) 37 | loadFromSystemEnvironment() 38 | } 39 | 40 | private func loadFromFile(path: String) { 41 | guard let content = try? String(contentsOfFile: path) else { 42 | print("📄 EnvironmentLoader: Could not read .env file at \(path)") 43 | return 44 | } 45 | 46 | let lines = content.components(separatedBy: .newlines) 47 | 48 | for line in lines { 49 | let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) 50 | 51 | // Skip empty lines and comments 52 | if trimmed.isEmpty || trimmed.hasPrefix("#") { 53 | continue 54 | } 55 | 56 | // Parse KEY=VALUE format 57 | let components = trimmed.components(separatedBy: "=") 58 | if components.count >= 2 { 59 | let key = components[0].trimmingCharacters(in: .whitespacesAndNewlines) 60 | let value = components.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespacesAndNewlines) 61 | 62 | // Remove quotes if present 63 | let cleanValue = value.hasPrefix("\"") && value.hasSuffix("\"") ? 64 | String(value.dropFirst().dropLast()) : value 65 | 66 | environmentVariables[key] = cleanValue 67 | print("🔑 EnvironmentLoader: Loaded \(key) from .env file") 68 | } 69 | } 70 | } 71 | 72 | private func loadFromSystemEnvironment() { 73 | // Load system environment variables (these override .env file values) 74 | for (key, value) in ProcessInfo.processInfo.environment { 75 | if key.hasPrefix("SPOTIFY_") || key.hasPrefix("DISCOGS_") { 76 | environmentVariables[key] = value 77 | print("🌍 EnvironmentLoader: Loaded \(key) from system environment") 78 | } 79 | } 80 | } 81 | 82 | /// Get an environment variable value 83 | func getValue(for key: String) -> String? { 84 | return environmentVariables[key] 85 | } 86 | 87 | /// Get an environment variable value with a fallback 88 | func getValue(for key: String, fallback: String) -> String { 89 | return environmentVariables[key] ?? fallback 90 | } 91 | 92 | /// Check if a key exists 93 | func hasKey(_ key: String) -> Bool { 94 | return environmentVariables[key] != nil 95 | } 96 | 97 | /// Get all loaded keys (for debugging) 98 | func getAllKeys() -> [String] { 99 | return Array(environmentVariables.keys).sorted() 100 | } 101 | } 102 | 103 | // MARK: - API Key Helpers 104 | 105 | extension EnvironmentLoader { 106 | // Spotify API Keys 107 | var spotifyClientId: String { 108 | guard let clientId = getValue(for: "SPOTIFY_CLIENT_ID"), !clientId.isEmpty else { 109 | fatalError("❌ SPOTIFY_CLIENT_ID not found in environment variables. Please add it to your .env file.") 110 | } 111 | return clientId 112 | } 113 | 114 | var spotifyClientSecret: String { 115 | guard let clientSecret = getValue(for: "SPOTIFY_CLIENT_SECRET"), !clientSecret.isEmpty else { 116 | fatalError("❌ SPOTIFY_CLIENT_SECRET not found in environment variables. Please add it to your .env file.") 117 | } 118 | return clientSecret 119 | } 120 | 121 | // Discogs API Keys 122 | var discogsConsumerKey: String { 123 | guard let consumerKey = getValue(for: "DISCOGS_CONSUMER_KEY"), !consumerKey.isEmpty else { 124 | fatalError("❌ DISCOGS_CONSUMER_KEY not found in environment variables. Please add it to your .env file.") 125 | } 126 | return consumerKey 127 | } 128 | 129 | var discogsConsumerSecret: String { 130 | guard let consumerSecret = getValue(for: "DISCOGS_CONSUMER_SECRET"), !consumerSecret.isEmpty else { 131 | fatalError("❌ DISCOGS_CONSUMER_SECRET not found in environment variables. Please add it to your .env file.") 132 | } 133 | return consumerSecret 134 | } 135 | } -------------------------------------------------------------------------------- /Cosmos Music Player/Models/SFBTrack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SFBTrack.swift 3 | // Cosmos Music Player 4 | // 5 | // Audio track model for SFBAudioEngine integration 6 | // 7 | 8 | import Foundation 9 | import SFBAudioEngine 10 | import UIKit 11 | 12 | /// A simplified audio track for SFBAudioEngine 13 | struct SFBTrack: Identifiable { 14 | /// The unique identifier of this track 15 | let id = UUID() 16 | /// The URL holding the audio data 17 | let url: URL 18 | 19 | /// Duration in seconds (calculated from SFBAudioEngine) 20 | let duration: TimeInterval 21 | /// Sample rate from SFBAudioEngine 22 | let sampleRate: Double 23 | /// Frame length from SFBAudioEngine 24 | let frameLength: Int64 25 | 26 | /// Reads audio properties and initializes a track 27 | init(url: URL) { 28 | self.url = url 29 | 30 | // Try to get properties from SFBAudioEngine 31 | if let audioFile = try? SFBAudioEngine.AudioFile(readingPropertiesAndMetadataFrom: url) { 32 | let frameLength = audioFile.properties.frameLength ?? 0 33 | let sampleRate = audioFile.properties.sampleRate ?? 0 34 | let durationProperty = audioFile.properties.duration ?? 0 35 | 36 | self.frameLength = frameLength 37 | self.sampleRate = sampleRate 38 | 39 | print("🔍 SFBTrack AudioFile properties: frameLength=\(frameLength), sampleRate=\(sampleRate), duration=\(durationProperty)") 40 | 41 | // For duration, prefer the direct duration property if available 42 | if durationProperty > 0 { 43 | self.duration = durationProperty 44 | print("🔍 SFBTrack using direct duration: \(self.duration) seconds") 45 | } else if frameLength > 0 && sampleRate > 0 { 46 | self.duration = Double(frameLength) / sampleRate 47 | print("🔍 SFBTrack calculated duration: \(self.duration) seconds") 48 | } else { 49 | self.duration = 0 50 | print("⚠️ SFBTrack: Invalid frame length or sample rate") 51 | } 52 | } else { 53 | // Fallback values 54 | print("⚠️ SFBTrack: Could not read AudioFile properties") 55 | self.frameLength = 0 56 | self.sampleRate = 0 57 | self.duration = 0 58 | } 59 | } 60 | 61 | /// Returns a decoder for this track or `nil` if the audio type is unknown 62 | /// Let SFBAudioEngine choose the best decoder automatically 63 | func decoder(enableDoP: Bool = false) throws -> PCMDecoding? { 64 | let pathExtension = url.pathExtension.lowercased() 65 | if AudioDecoder.handlesPaths(withExtension: pathExtension) { 66 | return try AudioDecoder(url: url) 67 | } else if DSDDecoder.handlesPaths(withExtension: pathExtension) { 68 | let dsdDecoder: DSDDecoder 69 | do { 70 | dsdDecoder = try DSDDecoder(url: url) 71 | } catch { 72 | print("❌ DSDDecoder creation failed for \(url.lastPathComponent): \(error)") 73 | print("💡 This may be due to unsupported DSD sample rate - returning nil to fallback to native playback") 74 | return nil // This will cause SFBAudioEngine to return false from canHandle, falling back to native 75 | } 76 | 77 | if enableDoP { 78 | // For external DACs, use DoP with proper error handling 79 | print("🎵 Attempting DoP decoder for external DAC") 80 | do { 81 | let dopDecoder = try DoPDecoder(decoder: dsdDecoder) 82 | print("✅ DoP decoder created successfully for DAC") 83 | return dopDecoder 84 | } catch { 85 | print("❌ DoP failed for DAC, this may cause noise issues: \(error)") 86 | // For DACs that support DoP, failing back to PCM may cause noise 87 | // Try to create PCM decoder but warn about potential issues 88 | do { 89 | let pcmDecoder = try DSDPCMDecoder(decoder: dsdDecoder) 90 | print("⚠️ Using PCM fallback - may cause noise on DoP-capable DAC") 91 | return pcmDecoder 92 | } catch { 93 | print("❌ Both DoP and PCM failed: \(error)") 94 | throw error 95 | } 96 | } 97 | } else { 98 | // For internal audio or non-DoP capable devices, prefer PCM 99 | do { 100 | let pcmDecoder = try DSDPCMDecoder(decoder: dsdDecoder) 101 | print("✅ DSD PCM decoder created for internal audio") 102 | return pcmDecoder 103 | } catch { 104 | print("⚠️ DSD PCM conversion failed, trying DoP as fallback: \(error)") 105 | // Fallback to DoP if PCM fails (e.g., high DSD rates) 106 | do { 107 | let dopDecoder = try DoPDecoder(decoder: dsdDecoder) 108 | print("✅ DoP decoder created as PCM fallback") 109 | return dopDecoder 110 | } catch { 111 | print("❌ Both PCM and DoP failed: \(error)") 112 | throw error 113 | } 114 | } 115 | } 116 | } 117 | return nil 118 | } 119 | } 120 | 121 | extension SFBTrack: Equatable { 122 | /// Returns true if the two tracks have the same `id` 123 | static func ==(lhs: SFBTrack, rhs: SFBTrack) -> Bool { 124 | return lhs.id == rhs.id 125 | } 126 | } -------------------------------------------------------------------------------- /Cosmos Music Player.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "12363f177d92992c8b1e8cb69d4eac1edff8a9487101536dcb51ade7a843458f", 3 | "pins" : [ 4 | { 5 | "identity" : "avfaudioextensions", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sbooth/AVFAudioExtensions", 8 | "state" : { 9 | "revision" : "a9616556eb3ed9751fc6e036c2197540036567ee", 10 | "version" : "0.4.1" 11 | } 12 | }, 13 | { 14 | "identity" : "cdumb", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/sbooth/CDUMB", 17 | "state" : { 18 | "revision" : "0cceb710cad35b9aaf5bb6b65faf71436b6aa7c3", 19 | "version" : "2.0.3" 20 | } 21 | }, 22 | { 23 | "identity" : "cspeex", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/sbooth/CSpeex", 26 | "state" : { 27 | "revision" : "ba33fd93d54d8e7fc93b8a1d27991e756bac7345", 28 | "version" : "1.2.1" 29 | } 30 | }, 31 | { 32 | "identity" : "cxxaudioutilities", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/sbooth/CXXAudioUtilities", 35 | "state" : { 36 | "revision" : "b381f7dfcece39b28b91913e1f763d2ecf196bef", 37 | "version" : "0.3.2" 38 | } 39 | }, 40 | { 41 | "identity" : "cxxmonkeysaudio", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/sbooth/CXXMonkeysAudio", 44 | "state" : { 45 | "revision" : "f576cd43884179960733a7ff6b44e3aa920e9768", 46 | "version" : "11.22.0" 47 | } 48 | }, 49 | { 50 | "identity" : "cxxtaglib", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/sbooth/CXXTagLib", 53 | "state" : { 54 | "revision" : "a243fefb04d56eeade7669b4ec90dc2997089fbb", 55 | "version" : "2.1.1" 56 | } 57 | }, 58 | { 59 | "identity" : "flac-binary-xcframework", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/sbooth/flac-binary-xcframework", 62 | "state" : { 63 | "revision" : "9005dc2cd455765fb6824eb215c9703429bbe8ff", 64 | "version" : "0.2.0" 65 | } 66 | }, 67 | { 68 | "identity" : "grdb.swift", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/groue/GRDB.swift", 71 | "state" : { 72 | "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", 73 | "version" : "6.29.3" 74 | } 75 | }, 76 | { 77 | "identity" : "lame-binary-xcframework", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/sbooth/lame-binary-xcframework", 80 | "state" : { 81 | "revision" : "07703e040231d50f2e7f160c670f356f129a00e4", 82 | "version" : "0.1.2" 83 | } 84 | }, 85 | { 86 | "identity" : "mpc-binary-xcframework", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/sbooth/mpc-binary-xcframework", 89 | "state" : { 90 | "revision" : "de4b45fa64ed88467806c50217e9da7032a99d80", 91 | "version" : "0.1.2" 92 | } 93 | }, 94 | { 95 | "identity" : "mpg123-binary-xcframework", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/sbooth/mpg123-binary-xcframework", 98 | "state" : { 99 | "revision" : "a678f1f21ed6f0350fc1ded94259ebe1f8e802b9", 100 | "version" : "0.2.3" 101 | } 102 | }, 103 | { 104 | "identity" : "ogg-binary-xcframework", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/sbooth/ogg-binary-xcframework", 107 | "state" : { 108 | "revision" : "c0e822e18738ad913864e98d9614927ac1e9337c", 109 | "version" : "0.1.2" 110 | } 111 | }, 112 | { 113 | "identity" : "opus-binary-xcframework", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/sbooth/opus-binary-xcframework", 116 | "state" : { 117 | "revision" : "74201a6af424e7e3a007fd5e401e9d2ce6896628", 118 | "version" : "0.2.2" 119 | } 120 | }, 121 | { 122 | "identity" : "sfbaudioengine", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/sbooth/SFBAudioEngine", 125 | "state" : { 126 | "revision" : "e5b8fdad1b8e551cd780ba4e4e9663c545d32a88", 127 | "version" : "0.7.2" 128 | } 129 | }, 130 | { 131 | "identity" : "sndfile-binary-xcframework", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/sbooth/sndfile-binary-xcframework", 134 | "state" : { 135 | "revision" : "52f73460dc04ba789ad3007ad004faa328b732dd", 136 | "version" : "0.1.2" 137 | } 138 | }, 139 | { 140 | "identity" : "tta-cpp-binary-xcframework", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/sbooth/tta-cpp-binary-xcframework", 143 | "state" : { 144 | "revision" : "b68cf8a127936434cae93aa2c613d4b47eb34de4", 145 | "version" : "0.1.2" 146 | } 147 | }, 148 | { 149 | "identity" : "vorbis-binary-xcframework", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/sbooth/vorbis-binary-xcframework", 152 | "state" : { 153 | "revision" : "842020eabcebe410e698c68545d6597b2d232e51", 154 | "version" : "0.1.2" 155 | } 156 | }, 157 | { 158 | "identity" : "wavpack-binary-xcframework", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/sbooth/wavpack-binary-xcframework", 161 | "state" : { 162 | "revision" : "4463d800294a573ac8902b5d100e4a8676df6086", 163 | "version" : "0.1.2" 164 | } 165 | } 166 | ], 167 | "version" : 3 168 | } 169 | -------------------------------------------------------------------------------- /Cosmos Music Player/Models/DatabaseModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatabaseModels.swift 3 | // Cosmos Music Player 4 | // 5 | // Database models for the music library 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import GRDB 10 | 11 | struct Artist: Codable, FetchableRecord, PersistableRecord { 12 | var id: Int64? 13 | var name: String 14 | 15 | static let databaseTableName = "artist" 16 | 17 | nonisolated(unsafe) static let tracks = hasMany(Track.self) 18 | nonisolated(unsafe) static let albums = hasMany(Album.self) 19 | } 20 | 21 | struct Album: Codable, FetchableRecord, PersistableRecord { 22 | var id: Int64? 23 | var artistId: Int64? 24 | var title: String 25 | var year: Int? 26 | var albumArtist: String? 27 | 28 | static let databaseTableName = "album" 29 | 30 | nonisolated(unsafe) static let artist = belongsTo(Artist.self) 31 | nonisolated(unsafe) static let tracks = hasMany(Track.self) 32 | 33 | enum CodingKeys: String, CodingKey { 34 | case id, title, year 35 | case artistId = "artist_id" 36 | case albumArtist = "album_artist" 37 | } 38 | } 39 | 40 | struct Track: Codable, FetchableRecord, PersistableRecord, Equatable { 41 | var id: Int64? 42 | var stableId: String 43 | var albumId: Int64? 44 | var artistId: Int64? 45 | var title: String 46 | var trackNo: Int? 47 | var discNo: Int? 48 | var durationMs: Int? 49 | var sampleRate: Int? 50 | var bitDepth: Int? 51 | var channels: Int? 52 | var path: String 53 | var fileSize: Int64? 54 | var replaygainTrackGain: Double? 55 | var replaygainAlbumGain: Double? 56 | var replaygainTrackPeak: Double? 57 | var replaygainAlbumPeak: Double? 58 | var hasEmbeddedArt: Bool = false 59 | 60 | static let databaseTableName = "track" 61 | 62 | nonisolated(unsafe) static let artist = belongsTo(Artist.self) 63 | nonisolated(unsafe) static let album = belongsTo(Album.self) 64 | 65 | enum CodingKeys: String, CodingKey { 66 | case id, title, path 67 | case stableId = "stable_id" 68 | case albumId = "album_id" 69 | case artistId = "artist_id" 70 | case trackNo = "track_no" 71 | case discNo = "disc_no" 72 | case durationMs = "duration_ms" 73 | case sampleRate = "sample_rate" 74 | case bitDepth = "bit_depth" 75 | case channels, fileSize = "file_size" 76 | case replaygainTrackGain = "replaygain_track_gain" 77 | case replaygainAlbumGain = "replaygain_album_gain" 78 | case replaygainTrackPeak = "replaygain_track_peak" 79 | case replaygainAlbumPeak = "replaygain_album_peak" 80 | case hasEmbeddedArt = "has_embedded_art" 81 | } 82 | } 83 | 84 | struct Favorite: Codable, FetchableRecord, PersistableRecord { 85 | var trackStableId: String 86 | 87 | static let databaseTableName = "favorite" 88 | 89 | enum CodingKeys: String, CodingKey { 90 | case trackStableId = "track_stable_id" 91 | } 92 | } 93 | 94 | struct Playlist: Codable, FetchableRecord, PersistableRecord { 95 | var id: Int64? 96 | var slug: String 97 | var title: String 98 | var createdAt: Int64 99 | var updatedAt: Int64 100 | var lastPlayedAt: Int64 101 | var folderPath: String? // Path to the folder this playlist syncs with 102 | var isFolderSynced: Bool // Whether this playlist is synced with a folder 103 | var lastFolderSync: Int64? // Last time folder sync was performed 104 | var customCoverImagePath: String? // Custom user-selected cover image 105 | 106 | static let databaseTableName = "playlist" 107 | 108 | nonisolated(unsafe) static let items = hasMany(PlaylistItem.self) 109 | 110 | enum CodingKeys: String, CodingKey { 111 | case id, slug, title 112 | case createdAt = "created_at" 113 | case updatedAt = "updated_at" 114 | case lastPlayedAt = "last_played_at" 115 | case folderPath = "folder_path" 116 | case isFolderSynced = "is_folder_synced" 117 | case lastFolderSync = "last_folder_sync" 118 | case customCoverImagePath = "custom_cover_image_path" 119 | } 120 | } 121 | 122 | struct PlaylistItem: Codable, FetchableRecord, PersistableRecord { 123 | var playlistId: Int64 124 | var position: Int 125 | var trackStableId: String 126 | 127 | static let databaseTableName = "playlist_item" 128 | 129 | nonisolated(unsafe) static let playlist = belongsTo(Playlist.self) 130 | 131 | enum CodingKeys: String, CodingKey { 132 | case position 133 | case playlistId = "playlist_id" 134 | case trackStableId = "track_stable_id" 135 | } 136 | } 137 | 138 | // MARK: - Graphic EQ Models 139 | 140 | enum EQPresetType: String, Codable { 141 | case imported = "imported" // Imported GraphicEQ with variable bands 142 | case manual = "manual" // Manual 16-band EQ 143 | } 144 | 145 | struct EQPreset: Codable, FetchableRecord, PersistableRecord, Identifiable { 146 | var id: Int64? 147 | var name: String 148 | var isBuiltIn: Bool 149 | var isActive: Bool 150 | var presetType: EQPresetType 151 | var createdAt: Int64 152 | var updatedAt: Int64 153 | 154 | static let databaseTableName = "eq_preset" 155 | 156 | nonisolated(unsafe) static let bands = hasMany(EQBand.self) 157 | 158 | enum CodingKeys: String, CodingKey { 159 | case id, name 160 | case isBuiltIn = "is_built_in" 161 | case isActive = "is_active" 162 | case presetType = "preset_type" 163 | case createdAt = "created_at" 164 | case updatedAt = "updated_at" 165 | } 166 | } 167 | 168 | struct EQBand: Codable, FetchableRecord, PersistableRecord { 169 | var id: Int64? 170 | var presetId: Int64 171 | var frequency: Double 172 | var gain: Double 173 | var bandwidth: Double 174 | var bandIndex: Int 175 | 176 | static let databaseTableName = "eq_band" 177 | 178 | nonisolated(unsafe) static let preset = belongsTo(EQPreset.self) 179 | 180 | enum CodingKeys: String, CodingKey { 181 | case id, frequency, gain, bandwidth 182 | case presetId = "preset_id" 183 | case bandIndex = "band_index" 184 | } 185 | } 186 | 187 | struct EQSettings: Codable, FetchableRecord, PersistableRecord { 188 | var id: Int64? 189 | var isEnabled: Bool 190 | var activePresetId: Int64? 191 | var globalGain: Double 192 | var updatedAt: Int64 193 | 194 | static let databaseTableName = "eq_settings" 195 | 196 | nonisolated(unsafe) static let activePreset = belongsTo(EQPreset.self, key: "activePresetId") 197 | 198 | enum CodingKeys: String, CodingKey { 199 | case id 200 | case isEnabled = "is_enabled" 201 | case activePresetId = "active_preset_id" 202 | case globalGain = "global_gain" 203 | case updatedAt = "updated_at" 204 | } 205 | } -------------------------------------------------------------------------------- /Cosmos Music Player/Views/Utility/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsView: View { 4 | @Environment(\.dismiss) private var dismiss 5 | @State private var deleteSettings = DeleteSettings.load() 6 | 7 | var body: some View { 8 | NavigationView { 9 | Form { 10 | Section(Localized.appearance) { 11 | Toggle(Localized.minimalistLibraryIcons, isOn: $deleteSettings.minimalistIcons) 12 | .onChange(of: deleteSettings.minimalistIcons) { _, _ in 13 | deleteSettings.save() 14 | } 15 | 16 | Text(Localized.useSimpleIcons) 17 | .font(.caption) 18 | .foregroundColor(.secondary) 19 | 20 | Toggle(Localized.forceDarkMode, isOn: $deleteSettings.forceDarkMode) 21 | .onChange(of: deleteSettings.forceDarkMode) { _, _ in 22 | deleteSettings.save() 23 | } 24 | 25 | Text(Localized.overrideSystemAppearance) 26 | .font(.caption) 27 | .foregroundColor(.secondary) 28 | 29 | VStack(alignment: .leading, spacing: 12) { 30 | Text(Localized.backgroundColor) 31 | .font(.headline) 32 | 33 | LazyVGrid(columns: [ 34 | GridItem(.flexible()), 35 | GridItem(.flexible()), 36 | GridItem(.flexible()), 37 | GridItem(.flexible()) 38 | ], spacing: 16) { 39 | ForEach(BackgroundColor.allCases, id: \.self) { color in 40 | Button(action: { 41 | deleteSettings.backgroundColorChoice = color 42 | deleteSettings.save() 43 | NotificationCenter.default.post(name: NSNotification.Name("BackgroundColorChanged"), object: nil) 44 | }) { 45 | ZStack { 46 | Circle() 47 | .fill(color.color) 48 | .frame(width: 44, height: 44) 49 | .overlay( 50 | Circle() 51 | .stroke(deleteSettings.backgroundColorChoice == color ? Color.primary : Color.clear, lineWidth: 3) 52 | ) 53 | 54 | if deleteSettings.backgroundColorChoice == color { 55 | Image(systemName: "checkmark") 56 | .font(.system(size: 16, weight: .bold)) 57 | .foregroundColor(.white) 58 | } 59 | } 60 | } 61 | .buttonStyle(PlainButtonStyle()) 62 | } 63 | } 64 | } 65 | 66 | Text(Localized.chooseColorTheme) 67 | .font(.caption) 68 | .foregroundColor(.secondary) 69 | } 70 | 71 | 72 | Section(Localized.audioSettings) { 73 | NavigationLink(destination: EQSettingsView()) { 74 | HStack { 75 | Image(systemName: "slider.horizontal.3") 76 | .foregroundColor(.blue) 77 | .font(.system(size: 20)) 78 | Text(Localized.graphicEqualizer) 79 | } 80 | } 81 | 82 | VStack(alignment: .leading, spacing: 8) { 83 | Text(Localized.dsdPlaybackMode) 84 | .font(.headline) 85 | 86 | Text(Localized.dsdPlaybackModeDescription) 87 | .font(.caption) 88 | .foregroundColor(.secondary) 89 | 90 | Picker("", selection: $deleteSettings.dsdPlaybackMode) { 91 | ForEach(DSDPlaybackMode.allCases, id: \.self) { mode in 92 | VStack(alignment: .leading) { 93 | Text(mode.displayName) 94 | .font(.body) 95 | } 96 | .tag(mode) 97 | } 98 | } 99 | .pickerStyle(.segmented) 100 | .onChange(of: deleteSettings.dsdPlaybackMode) { _, _ in 101 | deleteSettings.save() 102 | } 103 | 104 | Text(deleteSettings.dsdPlaybackMode.description) 105 | .font(.caption) 106 | .foregroundColor(.secondary) 107 | .padding(.top, 4) 108 | } 109 | .padding(.vertical, 4) 110 | } 111 | 112 | Section(Localized.information) { 113 | HStack { 114 | Text(Localized.version) 115 | Spacer() 116 | Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") 117 | .foregroundColor(.secondary) 118 | } 119 | 120 | HStack { 121 | Text(Localized.appName) 122 | Spacer() 123 | Text(Localized.cosmosMusicPlayer) 124 | .foregroundColor(.secondary) 125 | } 126 | 127 | Button(action: { 128 | print("🔗 GitHub repository button tapped") 129 | if let url = URL(string: "https://github.com/clquwu/Cosmos-Music-Player") { 130 | print("🔗 Opening URL: \(url)") 131 | UIApplication.shared.open(url) 132 | } else { 133 | print("❌ Invalid GitHub URL") 134 | } 135 | }) { 136 | HStack { 137 | Text(Localized.githubRepository) 138 | .foregroundColor(.primary) 139 | Spacer() 140 | Image(systemName: "arrow.up.right.square") 141 | .foregroundColor(.secondary) 142 | .font(.system(size: 16)) 143 | } 144 | .contentShape(Rectangle()) // Make entire area tappable 145 | } 146 | .buttonStyle(PlainButtonStyle()) // Remove default button styling that might interfere 147 | } 148 | } 149 | .safeAreaInset(edge: .bottom) { 150 | Color.clear.frame(height: 100) 151 | } 152 | .navigationTitle(Localized.settings) 153 | .navigationBarTitleDisplayMode(.inline) 154 | .toolbar { 155 | ToolbarItem(placement: .navigationBarTrailing) { 156 | Button(Localized.done) { 157 | dismiss() 158 | } 159 | } 160 | } 161 | } 162 | } 163 | } 164 | 165 | #Preview { 166 | SettingsView() 167 | } 168 | -------------------------------------------------------------------------------- /Cosmos Music Player/Views/Utility/UtilityViews.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct InitializationView: View { 4 | @StateObject private var libraryIndexer = LibraryIndexer.shared 5 | @EnvironmentObject private var appCoordinator: AppCoordinator 6 | @State private var settings = DeleteSettings.load() 7 | 8 | var body: some View { 9 | VStack(spacing: 20) { 10 | ProgressView() 11 | .scaleEffect(1.5) 12 | 13 | VStack(spacing: 8) { 14 | Text(statusMessage) 15 | .font(.headline) 16 | 17 | if libraryIndexer.isIndexing { 18 | VStack(spacing: 8) { 19 | Text(Localized.foundTracks(libraryIndexer.tracksFound)) 20 | .font(.subheadline) 21 | .foregroundColor(.secondary) 22 | 23 | // Current file being processed 24 | if !libraryIndexer.currentlyProcessing.isEmpty { 25 | VStack(spacing: 4) { 26 | Text(Localized.processingColon) 27 | .font(.caption) 28 | .foregroundColor(.secondary) 29 | 30 | Text(libraryIndexer.currentlyProcessing) 31 | .font(.caption) 32 | .fontWeight(.medium) 33 | .foregroundColor(settings.backgroundColorChoice.color) 34 | .lineLimit(1) 35 | .frame(maxWidth: 250) 36 | } 37 | } 38 | 39 | // Progress bar and percentage 40 | HStack { 41 | Text(Localized.percentComplete(Int(libraryIndexer.indexingProgress * 100))) 42 | .font(.caption) 43 | .foregroundColor(settings.backgroundColorChoice.color) 44 | .frame(width: 35, alignment: .leading) 45 | 46 | ProgressView(value: libraryIndexer.indexingProgress) 47 | .frame(maxWidth: 200) 48 | } 49 | 50 | // Queued files 51 | if !libraryIndexer.queuedFiles.isEmpty { 52 | VStack(spacing: 2) { 53 | Text(Localized.waitingColon) 54 | .font(.caption2) 55 | .foregroundColor(.secondary) 56 | 57 | ForEach(libraryIndexer.queuedFiles.prefix(3), id: \.self) { fileName in 58 | Text(fileName) 59 | .font(.caption2) 60 | .foregroundColor(.secondary) 61 | .lineLimit(1) 62 | .frame(maxWidth: 250) 63 | } 64 | 65 | if libraryIndexer.queuedFiles.count > 3 { 66 | Text(Localized.andMore(libraryIndexer.queuedFiles.count - 3)) 67 | .font(.caption2) 68 | .foregroundColor(.secondary) 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | .padding() 77 | .background(Color(.systemBackground)) 78 | .cornerRadius(12) 79 | .shadow(radius: 10) 80 | } 81 | 82 | private var statusMessage: String { 83 | switch appCoordinator.iCloudStatus { 84 | case .available: 85 | return "Setting up your iCloud music library..." 86 | case .offline: 87 | return "Setting up your offline music library..." 88 | default: 89 | return "Setting up your local music library..." 90 | } 91 | } 92 | } 93 | 94 | struct OfflineStatusView: View { 95 | @Environment(\.openURL) private var openURL 96 | @State private var isExpanded = false 97 | 98 | var body: some View { 99 | HStack { 100 | Image(systemName: "wifi.slash") 101 | .foregroundColor(.orange) 102 | 103 | VStack(alignment: .leading, spacing: 2) { 104 | Text(Localized.offlineMode) 105 | .font(.caption) 106 | .fontWeight(.medium) 107 | 108 | if isExpanded { 109 | Text(Localized.offlineModeMessage) 110 | .font(.caption2) 111 | .foregroundColor(.secondary) 112 | } 113 | } 114 | 115 | Spacer() 116 | 117 | Button(Localized.settings) { 118 | openSettings() 119 | } 120 | .font(.caption) 121 | .buttonStyle(.bordered) 122 | .controlSize(.mini) 123 | } 124 | .padding(.horizontal, 12) 125 | .padding(.vertical, 8) 126 | .background(Color.orange.opacity(0.1)) 127 | .overlay( 128 | RoundedRectangle(cornerRadius: 8) 129 | .stroke(Color.orange.opacity(0.3), lineWidth: 1) 130 | ) 131 | .onTapGesture { 132 | withAnimation(.easeInOut(duration: 0.2)) { 133 | isExpanded.toggle() 134 | } 135 | } 136 | .padding(.horizontal) 137 | } 138 | 139 | private func openSettings() { 140 | if let settingsURL = URL(string: UIApplication.openSettingsURLString) { 141 | openURL(settingsURL) 142 | } 143 | } 144 | } 145 | 146 | struct ErrorView: View { 147 | let error: Error 148 | @Environment(\.openURL) private var openURL 149 | 150 | var body: some View { 151 | VStack(spacing: 20) { 152 | Image(systemName: "icloud.slash") 153 | .font(.system(size: 60)) 154 | .foregroundColor(.red) 155 | 156 | VStack(spacing: 12) { 157 | Text(Localized.icloudConnectionRequired) 158 | .font(.title2) 159 | .fontWeight(.semibold) 160 | 161 | Text(errorMessage) 162 | .font(.body) 163 | .foregroundColor(.secondary) 164 | .multilineTextAlignment(.center) 165 | .padding(.horizontal) 166 | } 167 | 168 | VStack(spacing: 12) { 169 | Button(Localized.openSettings) { 170 | openSettings() 171 | } 172 | .buttonStyle(.borderedProminent) 173 | 174 | Button(Localized.retry) { 175 | retryInitialization() 176 | } 177 | .buttonStyle(.bordered) 178 | } 179 | } 180 | .padding() 181 | .background(Color(.systemBackground)) 182 | .cornerRadius(12) 183 | .shadow(radius: 10) 184 | .frame(maxWidth: 300) 185 | } 186 | 187 | private var errorMessage: String { 188 | if let appError = error as? AppCoordinatorError { 189 | return appError.localizedDescription 190 | } 191 | return error.localizedDescription 192 | } 193 | 194 | private func openSettings() { 195 | if let settingsURL = URL(string: UIApplication.openSettingsURLString) { 196 | openURL(settingsURL) 197 | } 198 | } 199 | 200 | private func retryInitialization() { 201 | Task { 202 | await AppCoordinator.shared.initialize() 203 | } 204 | } 205 | } -------------------------------------------------------------------------------- /Cosmos Music Player/Views/Player/QueueManagementView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import GRDB 3 | 4 | struct QueueManagementView: View { 5 | @StateObject private var playerEngine = PlayerEngine.shared 6 | @StateObject private var artworkManager = ArtworkManager.shared 7 | @Environment(\.dismiss) private var dismiss 8 | @State private var draggedTrack: Track? 9 | @State private var settings = DeleteSettings.load() 10 | 11 | var body: some View { 12 | NavigationView { 13 | ZStack { 14 | ScreenSpecificBackgroundView(screen: .player) 15 | 16 | VStack(spacing: 20) { 17 | // Header 18 | HStack { 19 | Button(Localized.done) { 20 | dismiss() 21 | } 22 | .font(.headline) 23 | 24 | Spacer() 25 | 26 | Text(Localized.playingQueue) 27 | .font(.title2) 28 | .fontWeight(.semibold) 29 | 30 | Spacer() 31 | 32 | // Invisible button for balance 33 | Button(Localized.done) { 34 | dismiss() 35 | } 36 | .font(.headline) 37 | .opacity(0) 38 | .disabled(true) 39 | } 40 | .padding(.horizontal, 20) 41 | .padding(.top, 10) 42 | 43 | if playerEngine.playbackQueue.isEmpty { 44 | VStack(spacing: 16) { 45 | Image(systemName: "music.note.list") 46 | .font(.system(size: 60)) 47 | .foregroundColor(.secondary) 48 | 49 | Text(Localized.noSongsInQueue) 50 | .font(.headline) 51 | .foregroundColor(.secondary) 52 | } 53 | .frame(maxWidth: .infinity, maxHeight: .infinity) 54 | } else { 55 | List { 56 | ForEach(Array(playerEngine.playbackQueue.enumerated()), id: \.offset) { index, track in 57 | QueueTrackRow( 58 | track: track, 59 | index: index, 60 | isCurrentTrack: index == playerEngine.currentIndex, 61 | isDragging: draggedTrack?.stableId == track.stableId 62 | ) 63 | .listRowBackground(Color.clear) 64 | .listRowSeparator(.hidden) 65 | .listRowInsets(EdgeInsets()) 66 | } 67 | .onMove(perform: moveItems) 68 | } 69 | .listStyle(PlainListStyle()) 70 | .scrollContentBackground(.hidden) 71 | .padding(.horizontal, 16) 72 | } 73 | } 74 | } 75 | } 76 | .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("BackgroundColorChanged"))) { _ in 77 | settings = DeleteSettings.load() 78 | } 79 | .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in 80 | settings = DeleteSettings.load() 81 | } 82 | } 83 | 84 | private func moveItems(from source: IndexSet, to destination: Int) { 85 | var newQueue = playerEngine.playbackQueue 86 | var newCurrentIndex = playerEngine.currentIndex 87 | 88 | // Get the source index (should be only one item) 89 | guard let sourceIndex = source.first else { return } 90 | 91 | // Calculate the actual destination index 92 | let actualDestination = sourceIndex < destination ? destination - 1 : destination 93 | 94 | // Move the item 95 | let movedTrack = newQueue.remove(at: sourceIndex) 96 | newQueue.insert(movedTrack, at: actualDestination) 97 | 98 | // Update current playing index 99 | if sourceIndex == playerEngine.currentIndex { 100 | // The currently playing track was moved 101 | newCurrentIndex = actualDestination 102 | } else if sourceIndex < playerEngine.currentIndex && actualDestination >= playerEngine.currentIndex { 103 | // Track moved from before current to after current 104 | newCurrentIndex -= 1 105 | } else if sourceIndex > playerEngine.currentIndex && actualDestination <= playerEngine.currentIndex { 106 | // Track moved from after current to before current 107 | newCurrentIndex += 1 108 | } 109 | 110 | // Apply changes 111 | playerEngine.playbackQueue = newQueue 112 | playerEngine.currentIndex = newCurrentIndex 113 | } 114 | } 115 | 116 | struct QueueTrackRow: View { 117 | let track: Track 118 | let index: Int 119 | let isCurrentTrack: Bool 120 | let isDragging: Bool 121 | 122 | @State private var artworkImage: UIImage? 123 | @State private var settings = DeleteSettings.load() 124 | 125 | var body: some View { 126 | HStack(spacing: 12) { 127 | // Album artwork 128 | ZStack { 129 | RoundedRectangle(cornerRadius: 6) 130 | .fill(Color.gray.opacity(0.2)) 131 | .frame(width: 50, height: 50) 132 | 133 | if let image = artworkImage { 134 | Image(uiImage: image) 135 | .resizable() 136 | .aspectRatio(contentMode: .fill) 137 | .frame(width: 50, height: 50) 138 | .clipShape(RoundedRectangle(cornerRadius: 6)) 139 | } else { 140 | Image(systemName: "music.note") 141 | .font(.title3) 142 | .foregroundColor(.secondary) 143 | } 144 | } 145 | 146 | // Track info 147 | VStack(alignment: .leading, spacing: 2) { 148 | HStack { 149 | Text(track.title) 150 | .font(.headline) 151 | .fontWeight(isCurrentTrack ? .bold : .medium) 152 | .foregroundColor(isCurrentTrack ? settings.backgroundColorChoice.color : .primary) 153 | .lineLimit(1) 154 | 155 | if isCurrentTrack { 156 | Image(systemName: "speaker.wave.2.fill") 157 | .font(.caption) 158 | .foregroundColor(settings.backgroundColorChoice.color) 159 | } 160 | } 161 | 162 | if let artistId = track.artistId, 163 | let artist = try? DatabaseManager.shared.read({ db in 164 | try Artist.fetchOne(db, key: artistId) 165 | }) { 166 | Text(artist.name) 167 | .font(.subheadline) 168 | .foregroundColor(.secondary) 169 | .lineLimit(1) 170 | } 171 | } 172 | 173 | Spacer() 174 | 175 | // Drag indicator only 176 | Image(systemName: "line.3.horizontal") 177 | .font(.caption) 178 | .foregroundColor(.secondary.opacity(0.6)) 179 | } 180 | .padding(.vertical, 8) 181 | .padding(.horizontal, 12) 182 | .background( 183 | RoundedRectangle(cornerRadius: 10) 184 | .fill(isCurrentTrack ? settings.backgroundColorChoice.color.opacity(0.15) : Color.clear) 185 | ) 186 | .opacity(isDragging ? 0.8 : 1.0) 187 | .scaleEffect(isDragging ? 1.05 : 1.0) 188 | .animation(.easeInOut(duration: 0.2), value: isDragging) 189 | .onAppear { 190 | loadArtwork() 191 | } 192 | .task { 193 | if artworkImage == nil { 194 | loadArtwork() 195 | } 196 | } 197 | .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("BackgroundColorChanged"))) { _ in 198 | settings = DeleteSettings.load() 199 | } 200 | } 201 | 202 | private func loadArtwork() { 203 | Task { 204 | artworkImage = await ArtworkManager.shared.getArtwork(for: track) 205 | } 206 | } 207 | } 208 | 209 | -------------------------------------------------------------------------------- /Cosmos Music Player/Models/WidgetData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetData.swift 3 | // Cosmos Music Player 4 | // 5 | // Shared data models for widget communication 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | // MARK: - Widget Track Data 12 | struct WidgetTrackData: Codable { 13 | let trackId: String 14 | let title: String 15 | let artist: String 16 | let isPlaying: Bool 17 | let lastUpdated: Date 18 | let backgroundColorHex: String 19 | 20 | init(trackId: String, title: String, artist: String, isPlaying: Bool, backgroundColorHex: String) { 21 | self.trackId = trackId 22 | self.title = title 23 | self.artist = artist 24 | self.isPlaying = isPlaying 25 | self.lastUpdated = Date() 26 | self.backgroundColorHex = backgroundColorHex 27 | } 28 | } 29 | 30 | // MARK: - Widget Data Manager 31 | final class WidgetDataManager: @unchecked Sendable { 32 | static let shared = WidgetDataManager() 33 | 34 | private let userDefaults: UserDefaults? 35 | private let currentTrackKey = "widget.currentTrack" 36 | private let artworkFileName = "widget_artwork.jpg" 37 | 38 | private init() { 39 | // Use App Group to share data between app and widget 40 | userDefaults = UserDefaults(suiteName: "group.dev.clq.Cosmos-Music-Player") 41 | } 42 | 43 | // MARK: - Track Data (without artwork to avoid 4MB limit) 44 | 45 | func saveCurrentTrack(_ data: WidgetTrackData, artworkData: Data? = nil) { 46 | guard let userDefaults = userDefaults else { 47 | print("⚠️ Widget: Failed to access shared UserDefaults") 48 | return 49 | } 50 | 51 | do { 52 | // Save track data to UserDefaults (small, < 1KB) 53 | let encoded = try JSONEncoder().encode(data) 54 | userDefaults.set(encoded, forKey: currentTrackKey) 55 | userDefaults.synchronize() 56 | print("✅ Widget: Saved track data - \(data.title) (\(encoded.count) bytes)") 57 | 58 | // Save artwork to shared file (can be > 4MB) 59 | if let artworkData = artworkData { 60 | saveArtwork(artworkData) 61 | } else { 62 | clearArtwork() 63 | } 64 | } catch { 65 | print("❌ Widget: Failed to encode track data - \(error)") 66 | } 67 | } 68 | 69 | func getCurrentTrack() -> WidgetTrackData? { 70 | print("📱 Widget: Attempting to retrieve track data...") 71 | print("📱 Widget: Using suite: group.dev.clq.Cosmos-Music-Player") 72 | 73 | guard let userDefaults = userDefaults else { 74 | print("⚠️ Widget: Failed to access shared UserDefaults - userDefaults is nil") 75 | return nil 76 | } 77 | 78 | guard let data = userDefaults.data(forKey: currentTrackKey) else { 79 | print("ℹ️ Widget: No track data found in UserDefaults for key: \(currentTrackKey)") 80 | print("ℹ️ Widget: Available keys: \(userDefaults.dictionaryRepresentation().keys)") 81 | return nil 82 | } 83 | 84 | print("📦 Widget: Found data, size: \(data.count) bytes") 85 | 86 | do { 87 | let decoded = try JSONDecoder().decode(WidgetTrackData.self, from: data) 88 | print("✅ Widget: Retrieved track data - \(decoded.title) by \(decoded.artist)") 89 | print("✅ Widget: Playing: \(decoded.isPlaying), Color: \(decoded.backgroundColorHex)") 90 | return decoded 91 | } catch { 92 | print("❌ Widget: Failed to decode track data - \(error)") 93 | print("❌ Widget: Data: \(String(data: data, encoding: .utf8) ?? "unable to decode")") 94 | return nil 95 | } 96 | } 97 | 98 | func clearCurrentTrack() { 99 | userDefaults?.removeObject(forKey: currentTrackKey) 100 | userDefaults?.synchronize() 101 | clearArtwork() 102 | print("🗑️ Widget: Cleared track data") 103 | } 104 | 105 | // MARK: - Artwork File Storage (avoids 4MB UserDefaults limit) 106 | 107 | private func getSharedContainerURL() -> URL? { 108 | return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.dev.clq.Cosmos-Music-Player") 109 | } 110 | 111 | private func saveArtwork(_ data: Data) { 112 | guard let containerURL = getSharedContainerURL() else { 113 | print("⚠️ Widget: Failed to get shared container URL") 114 | return 115 | } 116 | 117 | let fileURL = containerURL.appendingPathComponent(artworkFileName) 118 | 119 | do { 120 | try data.write(to: fileURL, options: .atomic) 121 | print("✅ Widget: Saved artwork to file (\(data.count) bytes)") 122 | } catch { 123 | print("❌ Widget: Failed to save artwork - \(error)") 124 | } 125 | } 126 | 127 | public func getArtwork() -> Data? { 128 | guard let containerURL = getSharedContainerURL() else { 129 | print("⚠️ Widget: Failed to get shared container URL") 130 | return nil 131 | } 132 | 133 | let fileURL = containerURL.appendingPathComponent(artworkFileName) 134 | 135 | guard FileManager.default.fileExists(atPath: fileURL.path) else { 136 | print("ℹ️ Widget: No artwork file found") 137 | return nil 138 | } 139 | 140 | do { 141 | let data = try Data(contentsOf: fileURL) 142 | print("✅ Widget: Loaded artwork from file (\(data.count) bytes)") 143 | return data 144 | } catch { 145 | print("❌ Widget: Failed to load artwork - \(error)") 146 | return nil 147 | } 148 | } 149 | 150 | private func clearArtwork() { 151 | guard let containerURL = getSharedContainerURL() else { return } 152 | 153 | let fileURL = containerURL.appendingPathComponent(artworkFileName) 154 | 155 | if FileManager.default.fileExists(atPath: fileURL.path) { 156 | try? FileManager.default.removeItem(at: fileURL) 157 | print("🗑️ Widget: Cleared artwork file") 158 | } 159 | } 160 | } 161 | 162 | // MARK: - Widget Playlist Data 163 | public struct WidgetPlaylistData: Codable { 164 | public let id: String 165 | public let name: String 166 | public let trackCount: Int 167 | public let colorHex: String 168 | public let artworkPaths: [String] // Filenames of artwork files in shared container 169 | public let customCoverImagePath: String? // Custom user-selected cover image 170 | 171 | public init(id: String, name: String, trackCount: Int, colorHex: String, artworkPaths: [String], customCoverImagePath: String? = nil) { 172 | self.id = id 173 | self.name = name 174 | self.trackCount = trackCount 175 | self.colorHex = colorHex 176 | self.artworkPaths = artworkPaths 177 | self.customCoverImagePath = customCoverImagePath 178 | } 179 | } 180 | 181 | // MARK: - Playlist Data Manager 182 | public final class PlaylistDataManager: @unchecked Sendable { 183 | public static let shared = PlaylistDataManager() 184 | 185 | private let userDefaults: UserDefaults? 186 | private let playlistsKey = "widget.playlists" 187 | 188 | private init() { 189 | userDefaults = UserDefaults(suiteName: "group.dev.clq.Cosmos-Music-Player") 190 | } 191 | 192 | public func savePlaylists(_ playlists: [WidgetPlaylistData]) { 193 | guard let userDefaults = userDefaults else { 194 | print("⚠️ Widget: Failed to access shared UserDefaults for playlists") 195 | return 196 | } 197 | 198 | do { 199 | let encoded = try JSONEncoder().encode(playlists) 200 | userDefaults.set(encoded, forKey: playlistsKey) 201 | userDefaults.synchronize() 202 | print("✅ Widget: Saved \(playlists.count) playlists") 203 | } catch { 204 | print("❌ Widget: Failed to encode playlists - \(error)") 205 | } 206 | } 207 | 208 | public func getPlaylists() -> [WidgetPlaylistData] { 209 | guard let userDefaults = userDefaults else { 210 | print("⚠️ Widget: Failed to access shared UserDefaults for playlists") 211 | return [] 212 | } 213 | 214 | guard let data = userDefaults.data(forKey: playlistsKey) else { 215 | print("ℹ️ Widget: No playlist data found") 216 | return [] 217 | } 218 | 219 | do { 220 | let decoded = try JSONDecoder().decode([WidgetPlaylistData].self, from: data) 221 | print("✅ Widget: Retrieved \(decoded.count) playlists") 222 | return decoded 223 | } catch { 224 | print("❌ Widget: Failed to decode playlists - \(error)") 225 | return [] 226 | } 227 | } 228 | 229 | public func clearPlaylists() { 230 | userDefaults?.removeObject(forKey: playlistsKey) 231 | userDefaults?.synchronize() 232 | print("🗑️ Widget: Cleared playlists") 233 | } 234 | } 235 | 236 | -------------------------------------------------------------------------------- /Cosmos Music Player/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | @EnvironmentObject private var appCoordinator: AppCoordinator 5 | @StateObject private var playerEngine = PlayerEngine.shared 6 | @StateObject private var libraryIndexer = LibraryIndexer.shared 7 | 8 | @State private var tracks: [Track] = [] 9 | @State private var selectedTab = 0 10 | @State private var refreshTimer: Timer? 11 | @State private var showTutorial = false 12 | @State private var showPlaylistManagement = false 13 | @State private var showSettings = false 14 | @State private var settings = DeleteSettings.load() 15 | 16 | var body: some View { 17 | mainContent 18 | .background(.clear) 19 | .preferredColorScheme(settings.forceDarkMode ? .dark : nil) 20 | .accentColor(settings.backgroundColorChoice.color) 21 | .modifier(LifecycleModifier( 22 | appCoordinator: appCoordinator, 23 | libraryIndexer: libraryIndexer, 24 | refreshTimer: $refreshTimer, 25 | showTutorial: $showTutorial, 26 | onRefresh: refreshLibrary 27 | )) 28 | .modifier(OverlayModifier( 29 | appCoordinator: appCoordinator 30 | )) 31 | .modifier(SheetModifier( 32 | appCoordinator: appCoordinator, 33 | showTutorial: $showTutorial, 34 | showPlaylistManagement: $showPlaylistManagement, 35 | showSettings: $showSettings 36 | )) 37 | } 38 | 39 | private var mainContent: some View { 40 | LibraryView( 41 | tracks: tracks, 42 | showTutorial: $showTutorial, 43 | showPlaylistManagement: $showPlaylistManagement, 44 | showSettings: $showSettings, 45 | onRefresh: performRefresh, 46 | onManualSync: performManualSync 47 | ) 48 | .safeAreaInset(edge: .bottom) { 49 | MiniPlayerView() 50 | .background(.clear) 51 | } 52 | .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LibraryNeedsRefresh"))) { _ in 53 | Task { 54 | await refreshLibrary() 55 | } 56 | } 57 | .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in 58 | settings = DeleteSettings.load() 59 | } 60 | } 61 | 62 | @Sendable private func refreshLibrary() async { 63 | do { 64 | let allTracks = try appCoordinator.getAllTracks() 65 | 66 | // Filter out incompatible formats when connected to CarPlay 67 | if SFBAudioEngineManager.shared.isCarPlayEnvironment { 68 | tracks = allTracks.filter { track in 69 | let ext = URL(fileURLWithPath: track.path).pathExtension.lowercased() 70 | let incompatibleFormats = ["ogg", "opus", "dsf", "dff"] 71 | return !incompatibleFormats.contains(ext) 72 | } 73 | print("🚗 CarPlay: Filtered \(allTracks.count - tracks.count) incompatible tracks") 74 | } else { 75 | tracks = allTracks 76 | } 77 | } catch { 78 | print("Failed to refresh library: \(error)") 79 | } 80 | } 81 | 82 | @Sendable private func performManualSync() async -> (before: Int, after: Int) { 83 | let trackCountBefore = tracks.count 84 | await appCoordinator.manualSync() 85 | 86 | // Wait for indexer to finish processing if it's currently running 87 | while libraryIndexer.isIndexing { 88 | try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds 89 | } 90 | 91 | await refreshLibrary() 92 | let trackCountAfter = tracks.count 93 | return (before: trackCountBefore, after: trackCountAfter) 94 | } 95 | 96 | @Sendable private func performRefresh() async -> (before: Int, after: Int) { 97 | let trackCountBefore = tracks.count 98 | 99 | // Wait for indexer to finish processing if it's currently running 100 | while libraryIndexer.isIndexing { 101 | try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds 102 | } 103 | 104 | await refreshLibrary() 105 | let trackCountAfter = tracks.count 106 | return (before: trackCountBefore, after: trackCountAfter) 107 | } 108 | 109 | } 110 | 111 | struct LifecycleModifier: ViewModifier { 112 | let appCoordinator: AppCoordinator 113 | let libraryIndexer: LibraryIndexer 114 | @Binding var refreshTimer: Timer? 115 | @Binding var showTutorial: Bool 116 | let onRefresh: @Sendable () async -> Void 117 | 118 | func body(content: Content) -> some View { 119 | content 120 | .task { 121 | if appCoordinator.isInitialized { 122 | await onRefresh() 123 | if TutorialViewModel.shouldShowTutorial() { 124 | showTutorial = true 125 | } 126 | } 127 | } 128 | .onChange(of: appCoordinator.isInitialized) { _, isInitialized in 129 | if isInitialized { 130 | Task { 131 | await onRefresh() 132 | if TutorialViewModel.shouldShowTutorial() { 133 | showTutorial = true 134 | } 135 | } 136 | } 137 | } 138 | .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("TrackFound"))) { _ in 139 | Task { await onRefresh() } 140 | } 141 | .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LibraryNeedsRefresh"))) { _ in 142 | Task { await onRefresh() } 143 | } 144 | .onChange(of: libraryIndexer.isIndexing) { _, isIndexing in 145 | if isIndexing { 146 | refreshTimer?.invalidate() 147 | refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in 148 | Task { await onRefresh() } 149 | } 150 | } else { 151 | refreshTimer?.invalidate() 152 | refreshTimer = nil 153 | Task { await onRefresh() } 154 | } 155 | } 156 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in 157 | // Save player state when app goes to background 158 | appCoordinator.playerEngine.savePlayerState() 159 | } 160 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification)) { _ in 161 | // Save player state when app is terminated 162 | appCoordinator.playerEngine.savePlayerState() 163 | } 164 | } 165 | } 166 | 167 | struct OverlayModifier: ViewModifier { 168 | let appCoordinator: AppCoordinator 169 | 170 | func body(content: Content) -> some View { 171 | content 172 | .overlay(alignment: .top) { 173 | if appCoordinator.isInitialized && appCoordinator.iCloudStatus == .offline { 174 | OfflineStatusView() 175 | .padding(.top) 176 | } 177 | } 178 | } 179 | } 180 | 181 | struct SheetModifier: ViewModifier { 182 | let appCoordinator: AppCoordinator 183 | @Binding var showTutorial: Bool 184 | @Binding var showPlaylistManagement: Bool 185 | @Binding var showSettings: Bool 186 | @State private var settings = DeleteSettings.load() 187 | 188 | func body(content: Content) -> some View { 189 | content 190 | .sheet(isPresented: $showTutorial) { 191 | TutorialView(onComplete: { 192 | showTutorial = false 193 | }) 194 | .accentColor(settings.backgroundColorChoice.color) 195 | } 196 | .sheet(isPresented: $showPlaylistManagement) { 197 | PlaylistManagementView() 198 | .accentColor(settings.backgroundColorChoice.color) 199 | } 200 | .sheet(isPresented: $showSettings) { 201 | SettingsView() 202 | .accentColor(settings.backgroundColorChoice.color) 203 | } 204 | .alert(Localized.libraryOutOfSync, isPresented: .init( 205 | get: { appCoordinator.showSyncAlert }, 206 | set: { appCoordinator.showSyncAlert = $0 } 207 | )) { 208 | Button(Localized.ok) { 209 | appCoordinator.showSyncAlert = false 210 | } 211 | } message: { 212 | Text(Localized.librarySyncMessage) 213 | } 214 | .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in 215 | settings = DeleteSettings.load() 216 | } 217 | 218 | } 219 | } 220 | 221 | #Preview { 222 | ContentView() 223 | .environmentObject(AppCoordinator.shared) 224 | } 225 | -------------------------------------------------------------------------------- /PlayerWidget/PlayerWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerWidget.swift 3 | // PlayerWidget 4 | // 5 | // Now Playing Widget for Cosmos Music Player 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | import AppIntents 11 | 12 | // MARK: - Color Extension 13 | fileprivate extension Color { 14 | init(hex: String) { 15 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 16 | var int: UInt64 = 0 17 | Scanner(string: hex).scanHexInt64(&int) 18 | let a, r, g, b: UInt64 19 | switch hex.count { 20 | case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 21 | case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 22 | case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 23 | default: (a, r, g, b) = (255, 255, 107, 157) 24 | } 25 | self.init(.sRGB, red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255, opacity: Double(a) / 255) 26 | } 27 | } 28 | 29 | // MARK: - Widget Timeline Provider 30 | struct PlayerWidgetProvider: TimelineProvider { 31 | func placeholder(in context: Context) -> WidgetEntry { 32 | WidgetEntry( 33 | date: Date(), 34 | trackData: WidgetTrackData( 35 | trackId: "placeholder", 36 | title: "Song Title", 37 | artist: "Artist Name", 38 | isPlaying: false, 39 | backgroundColorHex: "FF6B9D" 40 | ) 41 | ) 42 | } 43 | 44 | func getSnapshot(in context: Context, completion: @escaping (WidgetEntry) -> Void) { 45 | let entry: WidgetEntry 46 | if let trackData = WidgetDataManager.shared.getCurrentTrack() { 47 | entry = WidgetEntry(date: Date(), trackData: trackData) 48 | } else { 49 | entry = WidgetEntry( 50 | date: Date(), 51 | trackData: WidgetTrackData( 52 | trackId: "none", 53 | title: "No Music Playing", 54 | artist: "Open Cosmos to start", 55 | isPlaying: false, 56 | backgroundColorHex: "FF6B9D" 57 | ) 58 | ) 59 | } 60 | completion(entry) 61 | } 62 | 63 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { 64 | print("🔄 Widget Timeline: getTimeline called") 65 | let currentDate = Date() 66 | let entry: WidgetEntry 67 | 68 | if let trackData = WidgetDataManager.shared.getCurrentTrack() { 69 | print("✅ Widget Timeline: Got track data - \(trackData.title)") 70 | entry = WidgetEntry(date: currentDate, trackData: trackData) 71 | } else { 72 | print("⚠️ Widget Timeline: No track data available, showing placeholder") 73 | entry = WidgetEntry( 74 | date: currentDate, 75 | trackData: WidgetTrackData( 76 | trackId: "none", 77 | title: "No Music Playing", 78 | artist: "Open Cosmos to start", 79 | isPlaying: false, 80 | backgroundColorHex: "FF6B9D" 81 | ) 82 | ) 83 | } 84 | 85 | // Refresh every 15 seconds when music is playing 86 | let nextUpdate = entry.trackData.isPlaying ? Date().addingTimeInterval(15) : Date().addingTimeInterval(60) 87 | print("⏰ Widget Timeline: Next update in \(entry.trackData.isPlaying ? "15" : "60") seconds") 88 | let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) 89 | completion(timeline) 90 | } 91 | } 92 | 93 | // MARK: - Widget Entry 94 | struct WidgetEntry: TimelineEntry { 95 | let date: Date 96 | let trackData: WidgetTrackData 97 | } 98 | 99 | // MARK: - Widget View 100 | struct PlayerWidgetView: View { 101 | let entry: WidgetEntry 102 | @Environment(\.widgetFamily) var family 103 | 104 | var body: some View { 105 | switch family { 106 | case .systemMedium: 107 | MediumWidgetView(entry: entry) 108 | default: 109 | MediumWidgetView(entry: entry) 110 | } 111 | } 112 | } 113 | 114 | // MARK: - Medium Widget (Main) 115 | struct MediumWidgetView: View { 116 | let entry: WidgetEntry 117 | 118 | private var themeColor: Color { 119 | Color(hex: entry.trackData.backgroundColorHex) 120 | } 121 | 122 | var body: some View { 123 | ZStack { 124 | // Gradient background matching app design 125 | LinearGradient( 126 | gradient: Gradient(colors: [ 127 | themeColor.opacity(0.2), 128 | themeColor.opacity(0.05) 129 | ]), 130 | startPoint: .topLeading, 131 | endPoint: .bottomTrailing 132 | ) 133 | 134 | // Glass effect overlay 135 | Color.white.opacity(0.1) 136 | 137 | // Content 138 | HStack(spacing: 16) { 139 | // Album Artwork (Left) - loaded from file to avoid 4MB UserDefaults limit 140 | if let artworkData = WidgetDataManager.shared.getArtwork(), 141 | let uiImage = UIImage(data: artworkData) { 142 | Image(uiImage: uiImage) 143 | .resizable() 144 | .aspectRatio(contentMode: .fill) 145 | .frame(width: 130, height: 130) 146 | .clipShape(RoundedRectangle(cornerRadius: 16)) 147 | .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) 148 | } else { 149 | ZStack { 150 | RoundedRectangle(cornerRadius: 16) 151 | .fill( 152 | LinearGradient( 153 | gradient: Gradient(colors: [ 154 | themeColor.opacity(0.4), 155 | themeColor.opacity(0.2) 156 | ]), 157 | startPoint: .topLeading, 158 | endPoint: .bottomTrailing 159 | ) 160 | ) 161 | Image(systemName: "music.note") 162 | .font(.system(size: 40, weight: .light)) 163 | .foregroundColor(.white.opacity(0.8)) 164 | } 165 | .frame(width: 130, height: 130) 166 | .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) 167 | } 168 | 169 | // Track Info and Controls (Right) 170 | VStack(alignment: .leading, spacing: 10) { 171 | // Track info 172 | VStack(alignment: .leading, spacing: 4) { 173 | Text(entry.trackData.title) 174 | .font(.system(size: 14, weight: .semibold)) 175 | .foregroundColor(.primary) 176 | .lineLimit(2) 177 | .minimumScaleFactor(0.8) 178 | 179 | Text(entry.trackData.artist) 180 | .font(.system(size: 11, weight: .medium)) 181 | .foregroundColor(.secondary) 182 | .lineLimit(1) 183 | } 184 | 185 | Spacer() 186 | 187 | // Status indicator 188 | HStack(spacing: 8) { 189 | if entry.trackData.isPlaying { 190 | Image(systemName: "waveform") 191 | .font(.system(size: 14, weight: .semibold)) 192 | .foregroundStyle( 193 | LinearGradient( 194 | colors: [themeColor, themeColor.opacity(0.8)], 195 | startPoint: .leading, 196 | endPoint: .trailing 197 | ) 198 | ) 199 | .symbolEffect(.variableColor.iterative, isActive: true) 200 | 201 | Text("Now Playing") 202 | .font(.system(size: 11, weight: .medium)) 203 | .foregroundColor(themeColor) 204 | } else { 205 | Image(systemName: "pause.circle.fill") 206 | .font(.system(size: 14, weight: .medium)) 207 | .foregroundColor(.secondary) 208 | 209 | Text("Paused") 210 | .font(.system(size: 11, weight: .medium)) 211 | .foregroundColor(.secondary) 212 | } 213 | } 214 | } 215 | .padding(.vertical, 4) 216 | } 217 | .padding(16) 218 | } 219 | } 220 | } 221 | 222 | // MARK: - Widget Configuration 223 | struct PlayerWidget: Widget { 224 | let kind: String = "PlayerWidget" 225 | 226 | var body: some WidgetConfiguration { 227 | StaticConfiguration(kind: kind, provider: PlayerWidgetProvider()) { entry in 228 | PlayerWidgetView(entry: entry) 229 | .containerBackground(for: .widget) { 230 | Color.clear 231 | } 232 | } 233 | .configurationDisplayName("Now Playing") 234 | .description("Control your music playback from your home screen") 235 | .supportedFamilies([.systemMedium]) 236 | .contentMarginsDisabled() 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /Cosmos Music Player/ViewModels/TutorialViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TutorialViewModel.swift 3 | // Cosmos Music Player 4 | // 5 | // View model for the tutorial flow 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import CloudKit 11 | 12 | class TutorialViewModel: ObservableObject { 13 | @Published var currentStep: Int = 0 14 | @Published var isSignedIntoAppleID: Bool = false 15 | @Published var isiCloudDriveEnabled: Bool = false 16 | @Published var appleIDDetectionFailed: Bool = false 17 | @Published var iCloudDetectionFailed: Bool = false 18 | 19 | init() { 20 | setupNotificationObservers() 21 | } 22 | 23 | deinit { 24 | NotificationCenter.default.removeObserver(self) 25 | } 26 | 27 | private func setupNotificationObservers() { 28 | // Monitor iCloud Drive availability changes 29 | NotificationCenter.default.addObserver( 30 | forName: .NSUbiquityIdentityDidChange, 31 | object: nil, 32 | queue: .main 33 | ) { [weak self] _ in 34 | print("📱 iCloud Drive status changed - rechecking...") 35 | self?.checkiCloudDriveStatus() 36 | } 37 | 38 | // Monitor CloudKit account changes 39 | NotificationCenter.default.addObserver( 40 | forName: .CKAccountChanged, 41 | object: nil, 42 | queue: .main 43 | ) { [weak self] _ in 44 | print("📱 CloudKit account status changed - rechecking...") 45 | self?.checkAppleIDStatus() 46 | } 47 | } 48 | 49 | var canProceedFromAppleID: Bool { 50 | return true // Always allow proceeding - let user decide 51 | } 52 | 53 | var canProceedFromiCloud: Bool { 54 | return true // Always allow proceeding - let user decide 55 | } 56 | 57 | func nextStep() { 58 | if currentStep < 2 { 59 | currentStep += 1 60 | } 61 | } 62 | 63 | func previousStep() { 64 | if currentStep > 0 { 65 | currentStep -= 1 66 | } 67 | } 68 | 69 | func checkAppleIDStatus() { 70 | // Use Apple's recommended CloudKit approach for detecting iCloud sign-in status 71 | CKContainer.default().accountStatus { [weak self] accountStatus, error in 72 | DispatchQueue.main.async { 73 | guard let self = self else { return } 74 | 75 | if let error = error { 76 | print("📱 Apple ID check: ❓ CloudKit error: \(error.localizedDescription)") 77 | // Fallback to FileManager approach 78 | self.fallbackAppleIDCheck() 79 | return 80 | } 81 | 82 | switch accountStatus { 83 | case .available: 84 | self.isSignedIntoAppleID = true 85 | self.appleIDDetectionFailed = false 86 | print("📱 Apple ID check: ✅ Confirmed signed into iCloud (CloudKit)") 87 | 88 | case .noAccount: 89 | self.isSignedIntoAppleID = false 90 | self.appleIDDetectionFailed = false 91 | print("📱 Apple ID check: ❌ Not signed into iCloud (CloudKit)") 92 | 93 | case .restricted: 94 | self.isSignedIntoAppleID = false 95 | self.appleIDDetectionFailed = true 96 | print("📱 Apple ID check: ⚠️ iCloud access restricted (CloudKit)") 97 | 98 | case .couldNotDetermine: 99 | self.isSignedIntoAppleID = false 100 | self.appleIDDetectionFailed = true 101 | print("📱 Apple ID check: ❓ Could not determine status (CloudKit)") 102 | 103 | case .temporarilyUnavailable: 104 | print("Error line 104 of TutorialViewModel") 105 | @unknown default: 106 | self.isSignedIntoAppleID = false 107 | self.appleIDDetectionFailed = true 108 | print("📱 Apple ID check: ❓ Unknown CloudKit status") 109 | } 110 | } 111 | } 112 | } 113 | 114 | private func fallbackAppleIDCheck() { 115 | // Fallback to FileManager approach if CloudKit fails 116 | let hasIdentityToken = FileManager.default.ubiquityIdentityToken != nil 117 | let hasContainerAccess = FileManager.default.url(forUbiquityContainerIdentifier: nil) != nil 118 | 119 | if hasIdentityToken || hasContainerAccess { 120 | isSignedIntoAppleID = true 121 | appleIDDetectionFailed = false 122 | print("📱 Apple ID check: ✅ Fallback detection successful") 123 | } else { 124 | isSignedIntoAppleID = false 125 | appleIDDetectionFailed = true 126 | print("📱 Apple ID check: ❓ Fallback detection failed") 127 | } 128 | } 129 | 130 | func checkiCloudDriveStatus() { 131 | // Check specifically for iCloud Drive document storage availability 132 | // This is the correct use of ubiquityIdentityToken according to Apple docs 133 | 134 | let hasIdentityToken = FileManager.default.ubiquityIdentityToken != nil 135 | let hasContainerAccess = FileManager.default.url(forUbiquityContainerIdentifier: nil) != nil 136 | 137 | print("📱 iCloud Drive check: Identity token: \(hasIdentityToken), Container access: \(hasContainerAccess)") 138 | 139 | if hasIdentityToken { 140 | // Identity token exists - iCloud Drive document storage is definitely enabled 141 | isiCloudDriveEnabled = true 142 | iCloudDetectionFailed = false 143 | print("📱 iCloud Drive check: ✅ Confirmed enabled (identity token present)") 144 | 145 | } else if hasContainerAccess { 146 | // Has container URL but no identity token 147 | // This can happen when user is signed into iCloud but iCloud Drive is disabled 148 | // Let's try to create a test file to verify write access 149 | checkiCloudDriveWriteAccess() 150 | 151 | } else { 152 | // No container access - either not signed in or iCloud Drive completely disabled 153 | isiCloudDriveEnabled = false 154 | iCloudDetectionFailed = false 155 | print("📱 iCloud Drive check: ❌ Disabled (no container access)") 156 | } 157 | } 158 | 159 | private func checkiCloudDriveWriteAccess() { 160 | guard let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { 161 | isiCloudDriveEnabled = false 162 | iCloudDetectionFailed = true 163 | print("📱 iCloud Drive check: ❓ Container became unavailable") 164 | return 165 | } 166 | 167 | // Try to check if the container is actually writable 168 | do { 169 | let testFolderURL = containerURL.appendingPathComponent("Cosmos Player", isDirectory: true) 170 | 171 | // Try to create the app folder (this is what our app would do anyway) 172 | if !FileManager.default.fileExists(atPath: testFolderURL.path) { 173 | try FileManager.default.createDirectory(at: testFolderURL, 174 | withIntermediateDirectories: true, 175 | attributes: nil) 176 | } 177 | 178 | // If we can access and create directories, iCloud Drive is working 179 | isiCloudDriveEnabled = true 180 | iCloudDetectionFailed = false 181 | print("📱 iCloud Drive check: ✅ Enabled (verified write access)") 182 | 183 | } catch { 184 | // Cannot write to container - iCloud Drive is likely disabled for this app 185 | isiCloudDriveEnabled = false 186 | iCloudDetectionFailed = false 187 | print("📱 iCloud Drive check: ❌ Disabled (no write access: \(error.localizedDescription))") 188 | } 189 | } 190 | 191 | @MainActor func openAppleIDSettings() { 192 | // Try multiple URL schemes for Apple ID settings 193 | let appleIDUrls = [ 194 | "prefs:root=APPLE_ACCOUNT", 195 | "prefs:root=APPLE_ACCOUNT&path=SIGN_IN", 196 | "App-prefs:APPLE_ACCOUNT", 197 | "App-prefs:root=APPLE_ACCOUNT" 198 | ] 199 | 200 | for urlString in appleIDUrls { 201 | if let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) { 202 | UIApplication.shared.open(url) 203 | return 204 | } 205 | } 206 | 207 | // If none work, open main Settings app (user can navigate to Apple ID from there) 208 | openMainSettings() 209 | } 210 | 211 | @MainActor func openiCloudSettings() { 212 | // Try multiple URL schemes for iCloud settings 213 | let iCloudUrls = [ 214 | "prefs:root=CASTLE", 215 | "prefs:root=CASTLE&path=STORAGE_AND_BACKUP", 216 | "App-prefs:CASTLE", 217 | "App-prefs:root=CASTLE" 218 | ] 219 | 220 | for urlString in iCloudUrls { 221 | if let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) { 222 | UIApplication.shared.open(url) 223 | return 224 | } 225 | } 226 | 227 | // If none work, open main Settings app (user can navigate to iCloud from there) 228 | openMainSettings() 229 | } 230 | 231 | @MainActor private func openMainSettings() { 232 | // Open the main Settings app (not app-specific settings) 233 | if let url = URL(string: "prefs:"), UIApplication.shared.canOpenURL(url) { 234 | UIApplication.shared.open(url) 235 | } else if let url = URL(string: "App-Prefs:"), UIApplication.shared.canOpenURL(url) { 236 | UIApplication.shared.open(url) 237 | } else { 238 | // Last resort: open app-specific settings 239 | guard let url = URL(string: UIApplication.openSettingsURLString) else { 240 | return 241 | } 242 | UIApplication.shared.open(url) 243 | } 244 | } 245 | 246 | func completeTutorial() { 247 | // Save that tutorial has been completed 248 | UserDefaults.standard.set(true, forKey: "HasCompletedTutorial") 249 | print("✅ Tutorial completed and saved to UserDefaults") 250 | } 251 | 252 | static func shouldShowTutorial() -> Bool { 253 | return !UserDefaults.standard.bool(forKey: "HasCompletedTutorial") 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /PlayerWidget/PlaylistWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistWidget.swift 3 | // PlayerWidget 4 | // 5 | // Horizontal scrollable playlist browser widget with album art mashup 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | // MARK: - Color Extension (fix for crash) 12 | fileprivate extension Color { 13 | init(hex: String) { 14 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 15 | var int: UInt64 = 0 16 | Scanner(string: hex).scanHexInt64(&int) 17 | let a, r, g, b: UInt64 18 | switch hex.count { 19 | case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 20 | case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 21 | case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 22 | default: (a, r, g, b) = (255, 255, 107, 157) 23 | } 24 | self.init(.sRGB, red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255, opacity: Double(a) / 255) 25 | } 26 | } 27 | 28 | // MARK: - Playlist Widget Provider 29 | struct PlaylistWidgetProvider: TimelineProvider { 30 | func placeholder(in context: Context) -> PlaylistWidgetEntry { 31 | PlaylistWidgetEntry( 32 | date: Date(), 33 | playlists: [ 34 | WidgetPlaylistData(id: "1", name: "Favorites", trackCount: 25, colorHex: "FF6B9D", artworkPaths: []) 35 | ] 36 | ) 37 | } 38 | 39 | func getSnapshot(in context: Context, completion: @escaping (PlaylistWidgetEntry) -> Void) { 40 | let playlists = PlaylistDataManager.shared.getPlaylists() 41 | let entry = PlaylistWidgetEntry(date: Date(), playlists: playlists) 42 | completion(entry) 43 | } 44 | 45 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { 46 | print("🔄 Playlist Widget Timeline: getTimeline called") 47 | 48 | let playlists = PlaylistDataManager.shared.getPlaylists() 49 | let entry = PlaylistWidgetEntry(date: Date(), playlists: playlists) 50 | 51 | // Refresh every hour 52 | let nextUpdate = Date().addingTimeInterval(3600) 53 | print("⏰ Playlist Widget Timeline: Next update in 1 hour, \(playlists.count) playlists") 54 | let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) 55 | completion(timeline) 56 | } 57 | } 58 | 59 | // MARK: - Playlist Widget Entry 60 | struct PlaylistWidgetEntry: TimelineEntry { 61 | let date: Date 62 | let playlists: [WidgetPlaylistData] 63 | } 64 | 65 | // MARK: - Playlist Widget View 66 | struct PlaylistWidgetView: View { 67 | let entry: PlaylistWidgetEntry 68 | @Environment(\.widgetFamily) var family 69 | 70 | var body: some View { 71 | MediumPlaylistView(playlists: entry.playlists) 72 | } 73 | } 74 | 75 | // MARK: - Medium Playlist View 76 | struct MediumPlaylistView: View { 77 | let playlists: [WidgetPlaylistData] 78 | 79 | private var themeColor: Color { 80 | // Use the theme color from settings (stored in playlist data) 81 | if let firstPlaylist = playlists.first { 82 | return Color(hex: firstPlaylist.colorHex) 83 | } else { 84 | return Color(hex: "b11491") // Default violet 85 | } 86 | } 87 | 88 | var body: some View { 89 | ZStack { 90 | // Gradient background matching PlayerWidget design - EXACT SAME 91 | LinearGradient( 92 | gradient: Gradient(colors: [ 93 | themeColor.opacity(0.2), 94 | themeColor.opacity(0.05) 95 | ]), 96 | startPoint: .topLeading, 97 | endPoint: .bottomTrailing 98 | ) 99 | 100 | // Glass effect overlay - EXACT SAME 101 | Color.white.opacity(0.1) 102 | 103 | // Fixed grid of playlists (widgets don't support scrolling) 104 | if playlists.isEmpty { 105 | VStack(spacing: 8) { 106 | Image(systemName: "music.note.list") 107 | .font(.system(size: 32, weight: .light)) 108 | .foregroundColor(.secondary.opacity(0.5)) 109 | 110 | Text("No playlists yet") 111 | .font(.system(size: 12, weight: .medium)) 112 | .foregroundColor(.secondary) 113 | } 114 | .frame(maxWidth: .infinity, maxHeight: .infinity) 115 | } else { 116 | // Show up to 3 playlists in a horizontal layout 117 | HStack(spacing: 12) { 118 | ForEach(Array(playlists.prefix(3)), id: \.id) { playlist in 119 | PlaylistCard(playlist: playlist, themeColor: themeColor) 120 | } 121 | } 122 | .padding(.horizontal, 16) 123 | .padding(.vertical, 16) 124 | .frame(maxWidth: .infinity, maxHeight: .infinity) 125 | } 126 | } 127 | } 128 | } 129 | 130 | // MARK: - Playlist Card 131 | struct PlaylistCard: View { 132 | let playlist: WidgetPlaylistData 133 | let themeColor: Color 134 | 135 | var body: some View { 136 | Link(destination: URL(string: "cosmos-music://playlist/\(playlist.id)")!) { 137 | VStack(spacing: 8) { 138 | // Show custom cover if available, otherwise show album cover mashup (2x2 grid) 139 | if let customPath = playlist.customCoverImagePath, 140 | !customPath.isEmpty, 141 | let customCoverData = loadArtworkFromFile(customPath), 142 | let customCoverImage = UIImage(data: customCoverData) { 143 | // Custom cover image 144 | Image(uiImage: customCoverImage) 145 | .resizable() 146 | .aspectRatio(contentMode: .fill) 147 | .frame(width: 100, height: 100) 148 | .clipShape(RoundedRectangle(cornerRadius: 12)) 149 | .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) 150 | } else { 151 | // Album cover mashup (2x2 grid) - matching app's design 152 | VStack(spacing: 2) { 153 | HStack(spacing: 2) { 154 | // Top left 155 | artworkTile(index: 0, opacity: 0.6) 156 | // Top right 157 | artworkTile(index: 1, opacity: 0.5) 158 | } 159 | HStack(spacing: 2) { 160 | // Bottom left 161 | artworkTile(index: 2, opacity: 0.45) 162 | // Bottom right 163 | artworkTile(index: 3, opacity: 0.55) 164 | } 165 | } 166 | .frame(width: 100, height: 100) 167 | .clipShape(RoundedRectangle(cornerRadius: 12)) 168 | .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) 169 | } 170 | 171 | // Playlist title and song count 172 | VStack(spacing: 2) { 173 | Text(playlist.name) 174 | .font(.system(size: 12, weight: .semibold)) 175 | .foregroundColor(.primary) 176 | .lineLimit(1) 177 | .frame(width: 100) 178 | 179 | Text("\(playlist.trackCount) \(playlist.trackCount == 1 ? "song" : "songs")") 180 | .font(.system(size: 10, weight: .medium)) 181 | .foregroundColor(.secondary) 182 | } 183 | } 184 | } 185 | } 186 | 187 | @ViewBuilder 188 | private func artworkTile(index: Int, opacity: Double) -> some View { 189 | // Ensure each tile shows a DIFFERENT artwork - no duplicates 190 | if index < playlist.artworkPaths.count, !playlist.artworkPaths[index].isEmpty { 191 | // Load artwork from shared container file 192 | if let artworkData = loadArtworkFromFile(playlist.artworkPaths[index]), 193 | let uiImage = UIImage(data: artworkData) { 194 | Image(uiImage: uiImage) 195 | .resizable() 196 | .aspectRatio(contentMode: .fill) 197 | .frame(width: 49, height: 49) 198 | .clipped() 199 | } else { 200 | placeholderTile(opacity: opacity) 201 | } 202 | } else { 203 | // Show placeholder if we don't have enough unique artworks 204 | placeholderTile(opacity: opacity) 205 | } 206 | } 207 | 208 | @ViewBuilder 209 | private func placeholderTile(opacity: Double) -> some View { 210 | ZStack { 211 | Rectangle() 212 | .fill( 213 | LinearGradient( 214 | colors: [themeColor.opacity(opacity), themeColor.opacity(opacity * 0.7)], 215 | startPoint: .topLeading, 216 | endPoint: .bottomTrailing 217 | ) 218 | ) 219 | .frame(width: 49, height: 49) 220 | 221 | Image(systemName: "music.note") 222 | .font(.system(size: 16, weight: .light)) 223 | .foregroundColor(.white.opacity(0.7)) 224 | } 225 | } 226 | 227 | private func loadArtworkFromFile(_ filename: String) -> Data? { 228 | guard let containerURL = FileManager.default.containerURL( 229 | forSecurityApplicationGroupIdentifier: "group.dev.clq.Cosmos-Music-Player" 230 | ) else { 231 | return nil 232 | } 233 | 234 | let fileURL = containerURL.appendingPathComponent(filename) 235 | return try? Data(contentsOf: fileURL) 236 | } 237 | } 238 | 239 | // MARK: - Playlist Widget Configuration 240 | struct PlaylistWidget: Widget { 241 | let kind: String = "PlaylistWidget" 242 | 243 | var body: some WidgetConfiguration { 244 | StaticConfiguration(kind: kind, provider: PlaylistWidgetProvider()) { entry in 245 | PlaylistWidgetView(entry: entry) 246 | .containerBackground(for: .widget) { 247 | Color.clear 248 | } 249 | } 250 | .configurationDisplayName("My Playlists") 251 | .description("Quick access to your 3 most recently played playlists") 252 | .supportedFamilies([.systemMedium]) 253 | .contentMarginsDisabled() 254 | } 255 | } 256 | 257 | // MARK: - Preview 258 | #Preview(as: .systemMedium) { 259 | PlaylistWidget() 260 | } timeline: { 261 | PlaylistWidgetEntry( 262 | date: Date(), 263 | playlists: [ 264 | WidgetPlaylistData(id: "1", name: "Favorites", trackCount: 25, colorHex: "FF6B9D", artworkPaths: []), 265 | WidgetPlaylistData(id: "2", name: "Chill Vibes", trackCount: 30, colorHex: "FF6B9D", artworkPaths: []), 266 | WidgetPlaylistData(id: "3", name: "Workout Mix", trackCount: 45, colorHex: "FF6B9D", artworkPaths: []) 267 | ] 268 | ) 269 | } 270 | -------------------------------------------------------------------------------- /Cosmos Music Player/Services/DiscogsAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiscogsAPI.swift 3 | // Cosmos Music Player 4 | // 5 | // Discogs API service for fetching artist information 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Discogs API Models 11 | 12 | struct DiscogsSearchResponse: Codable { 13 | let pagination: DiscogsPagination 14 | let results: [DiscogsSearchResult] 15 | } 16 | 17 | struct DiscogsPagination: Codable { 18 | let page: Int 19 | let pages: Int 20 | let perPage: Int 21 | let items: Int 22 | let urls: DiscogsUrls? 23 | 24 | enum CodingKeys: String, CodingKey { 25 | case page, pages, items, urls 26 | case perPage = "per_page" 27 | } 28 | } 29 | 30 | struct DiscogsUrls: Codable { 31 | let last: String? 32 | let next: String? 33 | } 34 | 35 | struct DiscogsSearchResult: Codable { 36 | let id: Int 37 | let type: String 38 | let userDataId: Int? 39 | let masterID: Int? 40 | let masterUrl: String? 41 | let uri: String 42 | let title: String 43 | let thumb: String 44 | let coverImage: String 45 | let resourceUrl: String 46 | 47 | enum CodingKeys: String, CodingKey { 48 | case id, type, uri, title, thumb 49 | case userDataId = "user_data" 50 | case masterID = "master_id" 51 | case masterUrl = "master_url" 52 | case coverImage = "cover_image" 53 | case resourceUrl = "resource_url" 54 | } 55 | } 56 | 57 | struct DiscogsArtist: Codable { 58 | let id: Int 59 | let name: String 60 | let resourceUrl: String 61 | let uri: String 62 | let releasesUrl: String 63 | let images: [DiscogsImage] 64 | let profile: String 65 | let urls: [String]? 66 | let nameVariations: [String]? 67 | let aliases: [DiscogsAlias]? 68 | let members: [DiscogsMember]? 69 | 70 | enum CodingKeys: String, CodingKey { 71 | case id, name, uri, images, profile, urls, aliases, members 72 | case resourceUrl = "resource_url" 73 | case releasesUrl = "releases_url" 74 | case nameVariations = "namevariations" 75 | } 76 | } 77 | 78 | struct DiscogsImage: Codable { 79 | let type: String 80 | let uri: String 81 | let resourceUrl: String 82 | let uri150: String 83 | let width: Int 84 | let height: Int 85 | 86 | enum CodingKeys: String, CodingKey { 87 | case type, uri, width, height 88 | case resourceUrl = "resource_url" 89 | case uri150 = "uri150" 90 | } 91 | } 92 | 93 | struct DiscogsAlias: Codable { 94 | let id: Int 95 | let name: String 96 | let resourceUrl: String 97 | 98 | enum CodingKeys: String, CodingKey { 99 | case id, name 100 | case resourceUrl = "resource_url" 101 | } 102 | } 103 | 104 | struct DiscogsMember: Codable { 105 | let id: Int 106 | let name: String 107 | let resourceUrl: String 108 | let active: Bool? 109 | 110 | enum CodingKeys: String, CodingKey { 111 | case id, name, active 112 | case resourceUrl = "resource_url" 113 | } 114 | } 115 | 116 | // MARK: - Cached Artist Data 117 | 118 | class CachedArtistInfo: NSObject, Codable { 119 | let artistName: String 120 | let discogsArtist: DiscogsArtist 121 | let cachedAt: Date 122 | 123 | init(artistName: String, discogsArtist: DiscogsArtist, cachedAt: Date) { 124 | self.artistName = artistName 125 | self.discogsArtist = discogsArtist 126 | self.cachedAt = cachedAt 127 | super.init() 128 | } 129 | 130 | var isExpired: Bool { 131 | // Cache for 7 days 132 | return Date().timeIntervalSince(cachedAt) > 7 * 24 * 60 * 60 133 | } 134 | } 135 | 136 | // MARK: - Discogs API Service 137 | 138 | class DiscogsAPIService: ObservableObject, @unchecked Sendable { 139 | @MainActor static let shared = DiscogsAPIService() 140 | 141 | private let consumerKey = EnvironmentLoader.shared.discogsConsumerKey 142 | private let consumerSecret = EnvironmentLoader.shared.discogsConsumerSecret 143 | private let baseURL = "https://api.discogs.com" 144 | 145 | private let cache = NSCache() 146 | private let cacheDirectory: URL 147 | 148 | private init() { 149 | // Set up cache directory 150 | let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! 151 | cacheDirectory = documentsPath.appendingPathComponent("DiscogsCache") 152 | 153 | // Create cache directory if it doesn't exist 154 | try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) 155 | 156 | // Configure NSCache 157 | cache.countLimit = 100 // Limit to 100 cached artists 158 | } 159 | 160 | // MARK: - Public API 161 | 162 | func searchArtist(name: String) async throws -> DiscogsArtist? { 163 | print("🎵 Discogs: Searching for artist: \(name)") 164 | 165 | // Check cache first 166 | if let cached = getCachedArtist(name: name), !cached.isExpired { 167 | print("✅ Discogs: Found cached artist: \(name)") 168 | return cached.discogsArtist 169 | } 170 | 171 | // Search for artist 172 | let searchResults = try await performSearch(query: name, type: "artist") 173 | 174 | // Find best match (exact match or case-insensitive match) 175 | guard let bestMatch = findBestMatch(for: name, in: searchResults) else { 176 | print("❌ Discogs: No matching artist found for: \(name)") 177 | return nil 178 | } 179 | 180 | print("🎯 Discogs: Found match: \(bestMatch.title)") 181 | 182 | // Get detailed artist information 183 | let artist = try await getArtistDetails(from: bestMatch.resourceUrl) 184 | 185 | // Cache the result 186 | cacheArtist(name: name, artist: artist) 187 | 188 | return artist 189 | } 190 | 191 | // MARK: - Private Methods 192 | 193 | private func performSearch(query: String, type: String) async throws -> [DiscogsSearchResult] { 194 | let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query 195 | let urlString = "\(baseURL)/database/search?type=\(type)&q=\(encodedQuery)&per_page=5" 196 | 197 | guard let url = URL(string: urlString) else { 198 | throw DiscogsAPIError.invalidURL 199 | } 200 | 201 | var request = URLRequest(url: url) 202 | request.setValue("Discogs key=\(consumerKey), secret=\(consumerSecret)", forHTTPHeaderField: "Authorization") 203 | request.setValue("CosmosPlayer/1.0", forHTTPHeaderField: "User-Agent") 204 | 205 | print("🌐 Discogs: Making request to: \(urlString)") 206 | 207 | let (data, response) = try await URLSession.shared.data(for: request) 208 | 209 | if let httpResponse = response as? HTTPURLResponse { 210 | print("📡 Discogs: Response status: \(httpResponse.statusCode)") 211 | if httpResponse.statusCode != 200 { 212 | throw DiscogsAPIError.httpError(httpResponse.statusCode) 213 | } 214 | } 215 | 216 | let searchResponse = try JSONDecoder().decode(DiscogsSearchResponse.self, from: data) 217 | print("🔍 Discogs: Found \(searchResponse.results.count) results") 218 | 219 | return searchResponse.results 220 | } 221 | 222 | private func findBestMatch(for artistName: String, in results: [DiscogsSearchResult]) -> DiscogsSearchResult? { 223 | let normalizedName = artistName.lowercased().trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters)) 224 | 225 | // Try exact match first 226 | for result in results { 227 | let resultName = result.title.lowercased().trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters)) 228 | if resultName == normalizedName { 229 | return result 230 | } 231 | } 232 | 233 | // Try partial match 234 | for result in results { 235 | let resultName = result.title.lowercased() 236 | if resultName.contains(normalizedName) || normalizedName.contains(resultName) { 237 | return result 238 | } 239 | } 240 | 241 | // Return first result if no exact match 242 | return results.first 243 | } 244 | 245 | private func getArtistDetails(from resourceUrl: String) async throws -> DiscogsArtist { 246 | guard let url = URL(string: resourceUrl) else { 247 | throw DiscogsAPIError.invalidURL 248 | } 249 | 250 | var request = URLRequest(url: url) 251 | request.setValue("Discogs key=\(consumerKey), secret=\(consumerSecret)", forHTTPHeaderField: "Authorization") 252 | request.setValue("CosmosPlayer/1.0", forHTTPHeaderField: "User-Agent") 253 | 254 | print("🌐 Discogs: Fetching artist details from: \(resourceUrl)") 255 | 256 | let (data, response) = try await URLSession.shared.data(for: request) 257 | 258 | if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { 259 | throw DiscogsAPIError.httpError(httpResponse.statusCode) 260 | } 261 | 262 | return try JSONDecoder().decode(DiscogsArtist.self, from: data) 263 | } 264 | 265 | // MARK: - Caching 266 | 267 | private func getCachedArtist(name: String) -> CachedArtistInfo? { 268 | let key = NSString(string: name.lowercased()) 269 | 270 | // Check memory cache first 271 | if let cached = cache.object(forKey: key) { 272 | return cached 273 | } 274 | 275 | // Check disk cache 276 | let filename = name.lowercased().replacingOccurrences(of: " ", with: "_") + ".json" 277 | let fileURL = cacheDirectory.appendingPathComponent(filename) 278 | 279 | guard let data = try? Data(contentsOf: fileURL), 280 | let cached = try? JSONDecoder().decode(CachedArtistInfo.self, from: data) else { 281 | return nil 282 | } 283 | 284 | // Store in memory cache 285 | cache.setObject(cached, forKey: key) 286 | return cached 287 | } 288 | 289 | private func cacheArtist(name: String, artist: DiscogsArtist) { 290 | let cached = CachedArtistInfo(artistName: name, discogsArtist: artist, cachedAt: Date()) 291 | let key = NSString(string: name.lowercased()) 292 | 293 | // Store in memory cache 294 | cache.setObject(cached, forKey: key) 295 | 296 | // Store in disk cache 297 | let filename = name.lowercased().replacingOccurrences(of: " ", with: "_") + ".json" 298 | let fileURL = cacheDirectory.appendingPathComponent(filename) 299 | 300 | do { 301 | let data = try JSONEncoder().encode(cached) 302 | try data.write(to: fileURL) 303 | print("💾 Discogs: Cached artist data for: \(name)") 304 | } catch { 305 | print("❌ Discogs: Failed to cache artist data: \(error)") 306 | } 307 | } 308 | } 309 | 310 | // MARK: - Errors 311 | 312 | enum DiscogsAPIError: Error, LocalizedError { 313 | case invalidURL 314 | case httpError(Int) 315 | case decodingError(Error) 316 | case networkError(Error) 317 | 318 | var errorDescription: String? { 319 | switch self { 320 | case .invalidURL: 321 | return "Invalid URL" 322 | case .httpError(let code): 323 | return "HTTP Error: \(code)" 324 | case .decodingError(let error): 325 | return "Decoding Error: \(error.localizedDescription)" 326 | case .networkError(let error): 327 | return "Network Error: \(error.localizedDescription)" 328 | } 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /Cosmos Music Player/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Playlist related strings */ 2 | "playlists" = "Playlists"; 3 | "no_playlists_yet" = "No playlists yet"; 4 | "create_playlists_instruction" = "Create playlists by adding songs to them from the library"; 5 | "add_to_playlist" = "Add to Playlist"; 6 | "create_new_playlist" = "Create New Playlist"; 7 | "create_playlist" = "Create Playlist"; 8 | "playlist_name_placeholder" = "Playlist name"; 9 | "enter_playlist_name" = "Enter a name for your new playlist"; 10 | "delete_playlist" = "Delete Playlist"; 11 | "delete_playlist_confirmation" = "Are you sure you want to delete '%@'? This action cannot be undone."; 12 | "delete" = "Delete"; 13 | "cancel" = "Cancel"; 14 | "create" = "Create"; 15 | "done" = "Done"; 16 | "edit" = "Edit"; 17 | "edit_playlist" = "Edit Playlist"; 18 | "save" = "Save"; 19 | "enter_new_name" = "Enter a new name for the playlist"; 20 | "manage_playlists" = "Manage Playlists"; 21 | "playlist" = "Playlist"; 22 | "songs_count_singular" = "%d song"; 23 | "songs_count_plural" = "%d songs"; 24 | "created_date" = "Created %@"; 25 | "no_songs_found" = "No songs found"; 26 | "your_music_will_appear_here" = "To add your music:\n\n1. Open the Files app (or tap 'Add Songs')\n2. Choose either:\n • iCloud Drive → Cosmos Music Player folder\n • On My iPhone → Cosmos Music Player\n3. Drag your music files into either location"; 27 | "create_first_playlist" = "Create your first playlist to get started"; 28 | 29 | /* General UI strings */ 30 | "all_songs" = "All Songs"; 31 | "liked_songs" = "Liked Songs"; 32 | "add_songs" = "Open Files"; 33 | "import_music_files" = "Import music files"; 34 | "ok" = "OK"; 35 | "settings" = "Settings"; 36 | "retry" = "Retry"; 37 | "open_settings" = "Open Settings"; 38 | "continue" = "Continue"; 39 | "back" = "Back"; 40 | "get_started" = "Get Started"; 41 | "im_signed_in" = "I'm signed in"; 42 | "its_enabled" = "It's enabled"; 43 | 44 | /* Library and Navigation */ 45 | "library" = "Library"; 46 | "artists" = "Artists"; 47 | "albums" = "Albums"; 48 | "search" = "Search"; 49 | "browse" = "Browse"; 50 | "songs" = "Songs"; 51 | "processing" = "Processing"; 52 | "waiting" = "Waiting"; 53 | "and_more" = "... and %d more"; 54 | 55 | /* Artist/Album/Track Info */ 56 | "no_artists_found" = "No artists found"; 57 | "artists_will_appear" = "Artists will appear here once you add music to your library"; 58 | "no_albums_found" = "No albums found"; 59 | "albums_will_appear" = "Albums will appear here once you add music to your library"; 60 | "artist" = "Artist"; 61 | "album" = "Album"; 62 | "track_number" = "%d"; 63 | "play" = "Play"; 64 | "shuffle" = "Shuffle"; 65 | "wrong_artist" = "Wrong artist?"; 66 | "data_provided_by" = "Data provided by %@"; 67 | "open_spotify" = "OPEN SPOTIFY"; 68 | "loading_artist" = "Loading artist..."; 69 | 70 | /* Player */ 71 | "playing_queue" = "Playing Queue"; 72 | "no_songs_in_queue" = "No songs in queue"; 73 | "no_track_selected" = "No track selected"; 74 | 75 | /* Search */ 76 | "search_your_music_library" = "Search your music library"; 77 | "find_songs_artists_albums_playlists" = "Find songs, artists, albums, and playlists"; 78 | "no_results_found" = "No results found"; 79 | "try_different_keywords" = "Try searching with different keywords"; 80 | 81 | /* Context Menu Actions */ 82 | "show_artist_page" = "Show Artist Page"; 83 | "add_to_playlist_ellipsis" = "Add to Playlist..."; 84 | "delete_file" = "Delete File"; 85 | "delete_file_confirmation" = "Are you sure you want to delete '%@'? This action cannot be undone."; 86 | 87 | /* Settings */ 88 | "appearance" = "Appearance"; 89 | "information" = "Information"; 90 | "minimalist_library_icons" = "Minimalist library icons"; 91 | "force_dark_mode" = "Force Dark Mode"; 92 | "version" = "Version"; 93 | "app_name" = "App Name"; 94 | "cosmos_music_player" = "Cosmos Music Player"; 95 | "github_repository" = "GitHub Repository"; 96 | "use_simple_icons" = "Use simple icons without colored backgrounds in the library sections."; 97 | "override_system_appearance" = "Override system appearance and force the app to use dark mode."; 98 | "background_color" = "Background Color"; 99 | "choose_color_theme" = "Choose the color theme for background effects across all screens."; 100 | 101 | /* Liked Songs Actions */ 102 | "add_to_liked_songs" = "Add to Liked Songs"; 103 | "remove_from_liked_songs" = "Remove from Liked Songs"; 104 | 105 | /* Search Categories */ 106 | "all" = "All"; 107 | 108 | /* Sync and Connection */ 109 | "library_out_of_sync" = "Library Out of Sync"; 110 | "library_sync_message" = "Your music library is currently out of sync with iCloud. Some features may not work until you sign back in."; 111 | "offline_mode" = "Offline Mode"; 112 | "offline_mode_message" = "iCloud sync disabled. Music files in Documents folder only."; 113 | "icloud_connection_required" = "iCloud Connection Required"; 114 | 115 | /* Tutorial/Onboarding */ 116 | "welcome_to_cosmos" = "Welcome to Cosmos"; 117 | "sign_in_to_apple_id" = "Sign in to Apple ID"; 118 | "sign_in_message" = "To sync your music and favorites across devices, please make sure you're signed in to your Apple ID."; 119 | "if_signed_in_continue" = "If you're signed in, you can continue"; 120 | "enable_icloud_drive" = "Enable iCloud Drive"; 121 | "icloud_drive_message" = "iCloud Drive lets you access your music files from any device and keeps your favorites synced."; 122 | "if_icloud_enabled_continue" = "If iCloud Drive is enabled, you can continue"; 123 | "add_your_music" = "Add Your Music"; 124 | "how_to_add_music" = "You can add music files to either iCloud Drive (syncs across devices) or locally (this device only):"; 125 | "open_files_app" = "Open Files App"; 126 | "navigate_to_icloud_drive" = "Navigate to iCloud Drive"; 127 | "find_cosmos_player_folder" = "Find Cosmos Player Folder"; 128 | "add_your_music_instruction" = "Add Your Music"; 129 | 130 | /* Tutorial Status Messages */ 131 | "signed_in_to_apple_id" = "Signed in to Apple ID"; 132 | "not_signed_in_to_apple_id" = "Not signed in to Apple ID"; 133 | "cannot_detect_apple_id_status" = "Cannot detect Apple ID status"; 134 | "icloud_drive_enabled" = "iCloud Drive is enabled"; 135 | "icloud_drive_not_enabled" = "iCloud Drive is not enabled"; 136 | "cannot_detect_icloud_status" = "Cannot detect iCloud Drive status"; 137 | 138 | /* Tutorial Instructions */ 139 | "find_open_files_app" = "Find and open the Files app on your device"; 140 | "tap_icloud_drive_sidebar" = "Choose 'iCloud Drive' or 'On My iPhone' in the sidebar"; 141 | "look_for_cosmos_folder" = "Find the 'Cosmos Music Player' folder and open it"; 142 | "copy_music_files" = "Copy your FLAC or MP3 files into this folder"; 143 | 144 | /* Library Processing */ 145 | "found_tracks" = "Found %d tracks"; 146 | "processing_colon" = "🎵 Processing:"; 147 | "waiting_colon" = "⏳ Waiting:"; 148 | "percent_complete" = "%d%%"; 149 | 150 | /* Subtitles and descriptions */ 151 | "songs_count" = "%d songs"; 152 | "your_favorites" = "Your favorites"; 153 | "your_playlists" = "Your playlists"; 154 | "browse_by_artist" = "Browse by artist"; 155 | "browse_by_album" = "Browse by album"; 156 | "unknown_album" = "Unknown Album"; 157 | "unknown_artist" = "Unknown Artist"; 158 | 159 | /* Sync result messages */ 160 | "sync_no_new_tracks" = "No new music found"; 161 | "sync_one_new_track" = "1 new song found"; 162 | "sync_multiple_new_tracks" = "%d new songs found"; 163 | "sync_one_track_deleted" = "1 song removed"; 164 | "sync_multiple_tracks_deleted" = "%d songs removed"; 165 | "sync_no_changes" = "Library is up to date"; 166 | 167 | /* Equalizer strings */ 168 | "equalizer" = "Equalizer"; 169 | "graphic_equalizer" = "Graphic Equalizer"; 170 | "enable_equalizer" = "Enable Equalizer"; 171 | "enable_disable_eq_description" = "Enable or disable the graphic equalizer"; 172 | 173 | /* Manual EQ Presets */ 174 | "manual_eq_presets" = "Manual 16-Band Presets"; 175 | "no_manual_presets_created" = "No manual presets created"; 176 | "create_manual_eq_description" = "Create a custom 16-band equalizer preset with adjustable sliders"; 177 | "create_manual_16band_eq" = "Create Manual 16-Band EQ"; 178 | "manual_16band_eq" = "Manual 16-Band EQ"; 179 | "manual_16band_description" = "Creates a 16-band equalizer with standard ISO frequencies that you can adjust manually"; 180 | "adjust_bands_after_creation" = "You'll be able to adjust all frequency bands after creation"; 181 | "frequency_bands" = "Frequency Bands"; 182 | "edit_equalizer" = "Edit Equalizer"; 183 | "reset_to_flat" = "Reset to Flat"; 184 | 185 | /* Imported GraphicEQ Presets */ 186 | "imported_presets" = "Imported Presets"; 187 | "no_presets_imported" = "No presets imported"; 188 | "import_graphiceq_description" = "Import GraphicEQ .txt files to get started"; 189 | "imported_graphiceq" = "Imported GraphicEQ"; 190 | "import_graphiceq_file" = "Import GraphicEQ File"; 191 | 192 | /* General EQ Settings */ 193 | "global_settings" = "Global Settings"; 194 | "global_gain" = "Global Gain"; 195 | "global_gain_description" = "Adjust the overall volume level after EQ processing"; 196 | "about_graphiceq_format" = "About GraphicEQ Format"; 197 | "import_graphiceq_format_description" = "Import GraphicEQ .txt files with the format:"; 198 | "frequency_gain_pair_description" = "Each pair represents frequency (Hz) and gain (dB)"; 199 | "delete" = "Delete"; 200 | "export" = "Export"; 201 | "edit" = "Edit"; 202 | "cancel" = "Cancel"; 203 | "done" = "Done"; 204 | "save" = "Save"; 205 | "create" = "Create"; 206 | "preset_info" = "Preset Info"; 207 | 208 | /* GraphicEQ Import View */ 209 | "import_graphiceq" = "Import GraphicEQ"; 210 | "preset_name" = "Preset Name"; 211 | "enter_preset_name" = "Enter preset name"; 212 | "import_methods" = "Import Methods"; 213 | "import_from_txt_file" = "Import from .txt File"; 214 | "paste_graphiceq_text" = "Paste GraphicEQ Text"; 215 | "error" = "Error"; 216 | "format_info" = "Format Info"; 217 | "expected_graphiceq_format" = "Expected GraphicEQ format:"; 218 | "frequency_gain_pair" = "Each pair: frequency (Hz) gain (dB)"; 219 | 220 | /* Text Import View */ 221 | "paste_graphiceq" = "Paste GraphicEQ"; 222 | "paste_graphiceq_text_section" = "Paste GraphicEQ Text"; 223 | "example" = "Example"; 224 | "import" = "Import"; 225 | 226 | /* Error Messages */ 227 | "failed_to_import" = "Failed to import: %@"; 228 | "file_import_failed" = "File import failed: %@"; 229 | "failed_to_create" = "Failed to create: %@"; 230 | "failed_to_export" = "Failed to export preset"; 231 | "failed_to_delete" = "Failed to delete preset"; 232 | 233 | /* EQ Band Information */ 234 | "band_count_info" = "%d bands (reduced from %d)"; 235 | "bands_reduced_description" = "Your GraphicEQ has more bands than iOS supports. The system intelligently maps your %d bands to %d bands using frequency averaging."; 236 | "bands_limited_warning" = "⚠️ GraphicEQ reduced from %d to %d bands (iOS limit)"; 237 | 238 | /* Audio Settings */ 239 | "audio_settings" = "Audio"; 240 | "dsd_playback_mode" = "DSD Playback Mode"; 241 | "dsd_playback_mode_description" = "Choose how DSD audio files (DSF/DFF) are played back"; 242 | "dsd_mode_auto" = "Auto (Detect DAC)"; 243 | "dsd_mode_pcm" = "PCM Conversion"; 244 | "dsd_mode_dop" = "DoP (DSD over PCM)"; 245 | "dsd_mode_auto_description" = "Automatically detect external DAC and use DoP if available, otherwise convert to PCM"; 246 | "dsd_mode_pcm_description" = "Always convert DSD to PCM for playback (compatible with all devices)"; 247 | "dsd_mode_dop_description" = "Always use DoP encoding (requires external DAC that supports DSD)"; 248 | 249 | /* Sort Options */ 250 | "sort_date_newest" = "Date Added (Newest)"; 251 | "sort_date_oldest" = "Date Added (Oldest)"; 252 | "sort_name_az" = "Name (A-Z)"; 253 | "sort_name_za" = "Name (Z-A)"; 254 | "sort_size_largest" = "Size (Largest)"; 255 | "sort_size_smallest" = "Size (Smallest)"; 256 | 257 | /* Queue Actions */ 258 | "play_next" = "Play Next"; 259 | "add_to_queue" = "Add to Queue"; 260 | 261 | /* Bulk Selection */ 262 | "select" = "Select"; 263 | "select_all" = "Select All"; 264 | "bulk_actions" = "Actions"; 265 | "add_to_liked" = "Add to Liked"; 266 | "remove_from_liked" = "Remove from Liked"; 267 | "delete_files" = "Delete Files"; 268 | "delete_files_confirmation" = "Delete Files"; 269 | "selected_count_singular" = "%d selected"; 270 | "selected_count_plural" = "%d selected"; 271 | "delete_files_confirmation_message" = "Are you sure you want to delete %d file(s)? This action cannot be undone."; 272 | -------------------------------------------------------------------------------- /Cosmos Music Player/Cosmos_Music_PlayerApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cosmos_Music_PlayerApp.swift 3 | // Cosmos Music Player 4 | // 5 | // Created by CLQ on 28/08/2025. 6 | // 7 | 8 | import SwiftUI 9 | import AVFoundation 10 | import Intents 11 | 12 | class AppDelegate: NSObject, UIApplicationDelegate { 13 | func application(_ application: UIApplication, handle intent: INIntent, completionHandler: @escaping (INIntentResponse) -> Void) { 14 | guard let playMediaIntent = intent as? INPlayMediaIntent else { 15 | completionHandler(INPlayMediaIntentResponse(code: .failure, userActivity: nil)) 16 | return 17 | } 18 | 19 | Task { @MainActor in 20 | await AppCoordinator.shared.handleSiriPlaybackIntent(playMediaIntent, completion: completionHandler) 21 | } 22 | } 23 | 24 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 25 | // Set up Siri vocabulary and media context 26 | setupSiriIntegration() 27 | return true 28 | } 29 | 30 | private func setupSiriIntegration() { 31 | DispatchQueue.global(qos: .userInitiated).async { 32 | // Set up vocabulary for playlists, artists, and albums 33 | Task { @MainActor in 34 | do { 35 | // Playlist vocabulary 36 | let playlists = try AppCoordinator.shared.databaseManager.getAllPlaylists() 37 | var playlistVocabulary = playlists.map { $0.title } 38 | 39 | // Add French playlist generic terms to help recognition 40 | playlistVocabulary.append(contentsOf: [ 41 | "ma playlist", "ma liste de lecture", "mes playlists", 42 | "liste de lecture", "playlist", "playlists" 43 | ]) 44 | 45 | let playlistNames = NSOrderedSet(array: playlistVocabulary) 46 | INVocabulary.shared().setVocabularyStrings(playlistNames, of: .mediaPlaylistTitle) 47 | print("✅ Set up vocabulary for \(playlistNames.count) playlist terms") 48 | 49 | } catch { 50 | print("❌ Failed to set up vocabulary: \\(error)") 51 | } 52 | } 53 | 54 | // Create media user context 55 | let context = INMediaUserContext() 56 | Task { @MainActor in 57 | do { 58 | let trackCount = try AppCoordinator.shared.databaseManager.getAllTracks().count 59 | context.numberOfLibraryItems = trackCount 60 | context.subscriptionStatus = .notSubscribed // Since this is a local music app 61 | context.becomeCurrent() 62 | } catch { 63 | print("❌ Failed to set up media context: \\(error)") 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | @main 71 | struct Cosmos_Music_PlayerApp: App { 72 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 73 | @StateObject private var appCoordinator = AppCoordinator.shared 74 | 75 | var body: some Scene { 76 | WindowGroup { 77 | ContentView() 78 | .environmentObject(appCoordinator) 79 | .task { 80 | await appCoordinator.initialize() 81 | await createiCloudContainerPlaceholder() 82 | } 83 | .onReceive(NotificationCenter.default.publisher(for: UIScene.didEnterBackgroundNotification)) { _ in 84 | handleDidEnterBackground() 85 | } 86 | .onReceive(NotificationCenter.default.publisher(for: UIScene.willEnterForegroundNotification)) { _ in 87 | handleWillEnterForeground() 88 | } 89 | .onReceive(NotificationCenter.default.publisher(for: UIScene.willDeactivateNotification)) { _ in 90 | handleWillResignActive() 91 | } 92 | .onOpenURL { url in 93 | handleOpenURL(url) 94 | } 95 | .onContinueUserActivity("com.cosmos.music.play") { userActivity in 96 | handleSiriIntent(userActivity) 97 | } 98 | } 99 | } 100 | 101 | private func handleDidEnterBackground() { 102 | print("🔍 DIAGNOSTIC - backgroundTimeRemaining:", UIApplication.shared.backgroundTimeRemaining) 103 | 104 | // Configure audio for background playback - critical for SFBAudioEngine stability 105 | Task { @MainActor in 106 | // Optimize SFBAudioEngine for lock screen stability 107 | if PlayerEngine.shared.isPlaying { 108 | await optimizeSFBAudioForBackground() 109 | } 110 | 111 | // Stop high-frequency timers when backgrounded 112 | PlayerEngine.shared.stopPlaybackTimer() 113 | } 114 | } 115 | 116 | private func handleWillEnterForeground() { 117 | // Restart timers when foregrounding 118 | Task { @MainActor in 119 | // Restore audio configuration when returning to foreground 120 | if PlayerEngine.shared.isPlaying { 121 | await optimizeSFBAudioForForeground() 122 | PlayerEngine.shared.startPlaybackTimer() 123 | } 124 | 125 | // Check for new shared files and refresh library 126 | await LibraryIndexer.shared.copyFilesFromSharedContainer() 127 | 128 | // Only auto-scan if it's been a long time since last scan 129 | if !LibraryIndexer.shared.isIndexing { 130 | let settings = DeleteSettings.load() 131 | if shouldPerformAutoScan(lastScanDate: settings.lastLibraryScanDate) { 132 | print("🔄 Foreground: Starting library scan (been a while since last scan)") 133 | LibraryIndexer.shared.start() 134 | } else { 135 | print("⏭️ Foreground: Skipping auto-scan (use manual sync button)") 136 | } 137 | } 138 | } 139 | } 140 | 141 | private func shouldPerformAutoScan(lastScanDate: Date?) -> Bool { 142 | // If never scanned before, definitely scan 143 | guard let lastScanDate = lastScanDate else { 144 | print("🆕 Never scanned before - will perform scan") 145 | return true 146 | } 147 | 148 | // Check if it's been more than 1 hour since last scan 149 | let hoursSinceLastScan = Date().timeIntervalSince(lastScanDate) / 3600 150 | let shouldScan = hoursSinceLastScan >= 1.0 151 | 152 | if shouldScan { 153 | print("⏰ Last scan was \(String(format: "%.1f", hoursSinceLastScan)) hours ago - will scan") 154 | } else { 155 | print("⏰ Last scan was \(String(format: "%.1f", hoursSinceLastScan)) hours ago - skipping") 156 | } 157 | 158 | return shouldScan 159 | } 160 | 161 | private func handleWillResignActive() { 162 | // Re-assert the session as we background - no mixWithOthers in background 163 | do { 164 | let s = AVAudioSession.sharedInstance() 165 | try s.setCategory(.playback, mode: .default, options: []) // no mixWithOthers in bg 166 | try s.setActive(true, options: []) 167 | print("🎧 Session keepalive on resign active - success") 168 | } catch { 169 | print("❌ Session keepalive fail:", error) 170 | } 171 | } 172 | 173 | private func handleOpenURL(_ url: URL) { 174 | print("🔗 Received URL: \(url.absoluteString)") 175 | 176 | guard url.scheme == "cosmos-music" else { 177 | print("❌ Unknown URL scheme: \(url.scheme ?? "nil")") 178 | return 179 | } 180 | 181 | Task { @MainActor in 182 | switch url.host { 183 | case "refresh": 184 | print("📁 URL triggered library refresh - this is a manual refresh so always scan") 185 | await LibraryIndexer.shared.copyFilesFromSharedContainer() 186 | if !LibraryIndexer.shared.isIndexing { 187 | LibraryIndexer.shared.start() 188 | } 189 | 190 | case "playlist": 191 | // Extract playlist ID from path 192 | let playlistId = url.pathComponents.dropFirst().joined(separator: "/") 193 | print("📋 Widget: Opening playlist - \(playlistId)") 194 | 195 | // Navigate to playlist 196 | if let playlistIdInt = Int64(playlistId) { 197 | do { 198 | let playlists = try appCoordinator.databaseManager.getAllPlaylists() 199 | if let playlist = playlists.first(where: { $0.id == playlistIdInt }) { 200 | // Post notification to navigate to playlist 201 | NotificationCenter.default.post( 202 | name: NSNotification.Name("NavigateToPlaylist"), 203 | object: nil, 204 | userInfo: ["playlistId": playlistIdInt] 205 | ) 206 | print("✅ Widget: Navigating to playlist \(playlist.title)") 207 | } 208 | } catch { 209 | print("❌ Widget: Failed to find playlist: \(error)") 210 | } 211 | } 212 | 213 | default: 214 | print("⚠️ Unknown URL host: \(url.host ?? "nil")") 215 | } 216 | } 217 | } 218 | 219 | private func handleSiriIntent(_ userActivity: NSUserActivity) { 220 | print("🎤 Received Siri intent: \(userActivity.activityType)") 221 | Task { @MainActor in 222 | await appCoordinator.handleSiriPlayIntent(userActivity: userActivity) 223 | } 224 | } 225 | 226 | private func createiCloudContainerPlaceholder() async { 227 | guard let iCloudURL = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { 228 | print("❌ iCloud Drive not available") 229 | return 230 | } 231 | 232 | let documentsURL = iCloudURL.appendingPathComponent("Documents") 233 | let placeholderURL = documentsURL.appendingPathComponent(".cosmos_placeholder") 234 | 235 | do { 236 | // Create Documents directory if it doesn't exist 237 | try FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true, attributes: nil) 238 | 239 | // Create placeholder file if it doesn't exist 240 | if !FileManager.default.fileExists(atPath: placeholderURL.path) { 241 | let placeholderText = "This folder contains music files for Cosmos Music Player.\nPlace your FLAC files here to add them to your library." 242 | try placeholderText.write(to: placeholderURL, atomically: true, encoding: .utf8) 243 | print("✅ Created iCloud Drive placeholder file to ensure folder visibility") 244 | } 245 | } catch { 246 | print("❌ Failed to create iCloud Drive placeholder: \(error)") 247 | } 248 | } 249 | 250 | // MARK: - SFBAudioEngine Background Optimization 251 | 252 | private func optimizeSFBAudioForBackground() async { 253 | print("🔒 Optimizing SFBAudioEngine for background/lock screen") 254 | 255 | // Increase buffer size significantly for background stability 256 | do { 257 | let session = AVAudioSession.sharedInstance() 258 | try session.setPreferredIOBufferDuration(0.100) // 100ms buffer for lock screen 259 | print("✅ Increased buffer to 100ms for lock screen stability") 260 | } catch { 261 | print("⚠️ Failed to increase buffer for background: \(error)") 262 | } 263 | 264 | // Simplified audio session for background 265 | do { 266 | let session = AVAudioSession.sharedInstance() 267 | try session.setCategory(.playback, mode: .default, options: []) 268 | print("✅ Audio session optimized for background playback") 269 | } catch { 270 | print("⚠️ Failed to optimize audio session for background: \(error)") 271 | } 272 | } 273 | 274 | private func optimizeSFBAudioForForeground() async { 275 | print("🔓 Restoring SFBAudioEngine for foreground") 276 | 277 | // Restore normal buffer size 278 | do { 279 | let session = AVAudioSession.sharedInstance() 280 | try session.setPreferredIOBufferDuration(0.040) // Back to 40ms 281 | print("✅ Restored buffer to 40ms for foreground") 282 | } catch { 283 | print("⚠️ Failed to restore buffer for foreground: \(error)") 284 | } 285 | 286 | // Restore full audio session options 287 | do { 288 | let session = AVAudioSession.sharedInstance() 289 | try session.setCategory(.playback, mode: .default, options: [.allowBluetoothA2DP]) 290 | print("✅ Audio session restored for foreground playback") 291 | } catch { 292 | print("⚠️ Failed to restore audio session for foreground: \(error)") 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /Cosmos Music Player/Resources/fr.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Playlist related strings */ 2 | "playlists" = "Playlist"; 3 | "no_playlists_yet" = "Aucune playlist"; 4 | "create_playlists_instruction" = "Créez des playlist en y ajoutant des musiques depuis la bibliothèque"; 5 | "add_to_playlist" = "Ajouter à la playlist"; 6 | "create_new_playlist" = "Créer une nouvelle playlist"; 7 | "create_playlist" = "Créer une playlist"; 8 | "playlist_name_placeholder" = "Nom de la playlist"; 9 | "enter_playlist_name" = "Entrez un nom pour votre nouvelle playlist"; 10 | "delete_playlist" = "Supprimer la playlist"; 11 | "delete_playlist_confirmation" = "Êtes-vous sûr de vouloir supprimer '%@' ? Cette action ne peut pas être annulée."; 12 | "delete" = "Supprimer"; 13 | "cancel" = "Annuler"; 14 | "create" = "Créer"; 15 | "done" = "Terminé"; 16 | "edit" = "Modifier"; 17 | "edit_playlist" = "Modifier la playlist"; 18 | "save" = "Enregistrer"; 19 | "enter_new_name" = "Entrez un nouveau nom pour la playlist"; 20 | "manage_playlists" = "Gérer les playlist"; 21 | "playlist" = "Playlist"; 22 | "songs_count_singular" = "%d musique"; 23 | "songs_count_plural" = "%d musiques"; 24 | "created_date" = "Créée le %@"; 25 | "no_songs_found" = "Aucune musique trouvée"; 26 | "your_music_will_appear_here" = "Pour ajouter vos musiques :\n\n1. Ouvrez l'app Fichiers (ou appuyez sur 'Ouvrir Fichiers')\n2. Choisissez soit :\n • iCloud Drive → dossier Cosmos Music Player\n • Sur mon iPhone → Cosmos Music Player\n3. Glissez vos musiques dans l'un des deux emplacements"; 27 | "create_first_playlist" = "Créez votre première playlist pour commencer"; 28 | 29 | /* General UI strings */ 30 | "all_songs" = "Vos musiques"; 31 | "liked_songs" = "Favoris"; 32 | "add_songs" = "Ouvrir Fichiers"; 33 | "import_music_files" = "Importer des musiques"; 34 | "ok" = "OK"; 35 | "settings" = "Réglages"; 36 | "retry" = "Réessayer"; 37 | "open_settings" = "Ouvrir les Réglages"; 38 | "continue" = "Continuer"; 39 | "back" = "Retour"; 40 | "get_started" = "Commencer"; 41 | "im_signed_in" = "Connecter"; 42 | "its_enabled" = "Activer"; 43 | 44 | /* Library and Navigation */ 45 | "library" = "Cosmos"; 46 | "artists" = "Artistes"; 47 | "albums" = "Albums"; 48 | "search" = "Rechercher"; 49 | "browse" = "Parcourir"; 50 | "songs" = "Musiques"; 51 | "processing" = "Traitement"; 52 | "waiting" = "En attente"; 53 | "and_more" = "... et %d de plus"; 54 | 55 | /* Artist/Album/Track Info */ 56 | "no_artists_found" = "Aucun artiste trouvé"; 57 | "artists_will_appear" = "Les artistes apparaîtront ici une fois que vous ajouterez de la musique à votre bibliothèque"; 58 | "no_albums_found" = "Aucun album trouvé"; 59 | "albums_will_appear" = "Les albums apparaîtront ici une fois que vous ajouterez de la musique à votre bibliothèque"; 60 | "artist" = "Artiste"; 61 | "album" = "Album"; 62 | "track_number" = "%d"; 63 | "play" = "Lecture"; 64 | "shuffle" = "Aléatoire"; 65 | "wrong_artist" = "Mauvais artiste ?"; 66 | "data_provided_by" = "Données fournies par %@"; 67 | "open_spotify" = "OUVRIR SPOTIFY"; 68 | "loading_artist" = "Chargement de l'artiste..."; 69 | 70 | /* Player */ 71 | "playing_queue" = "File de lecture"; 72 | "no_songs_in_queue" = "Aucune musique dans la file"; 73 | "no_track_selected" = "Aucune piste sélectionnée"; 74 | 75 | /* Search */ 76 | "search_your_music_library" = "Rechercher dans votre bibliothèque musicale"; 77 | "find_songs_artists_albums_playlists" = "Trouvez des musiques, artistes, albums et listes de lecture"; 78 | "no_results_found" = "Aucun résultat trouvé"; 79 | "try_different_keywords" = "Essayez avec des mots-clés différents"; 80 | 81 | /* Context Menu Actions */ 82 | "show_artist_page" = "Afficher la page de l'artiste"; 83 | "add_to_playlist_ellipsis" = "Ajouter à la liste de lecture..."; 84 | "delete_file" = "Supprimer le fichier"; 85 | "delete_file_confirmation" = "Êtes-vous sûr de vouloir supprimer '%@' ? Cette action ne peut pas être annulée."; 86 | 87 | /* Settings */ 88 | "appearance" = "Apparence"; 89 | "information" = "Informations"; 90 | "minimalist_library_icons" = "Icônes minimalistes"; 91 | "force_dark_mode" = "Mode sombre forcé"; 92 | "version" = "Version"; 93 | "app_name" = "Nom de l'app"; 94 | "cosmos_music_player" = "Cosmos Music Player"; 95 | "github_repository" = "Dépôt GitHub"; 96 | "use_simple_icons" = "Utilisez des icônes simples sans arrière-plans colorés dans les sections de la bibliothèque."; 97 | "override_system_appearance" = "Remplacer l'apparence du système et forcer l'application à utiliser le mode sombre."; 98 | "background_color" = "Couleur d'arrière-plan"; 99 | "choose_color_theme" = "Choisissez le thème de couleur pour les effets d'arrière-plan sur tous les écrans."; 100 | 101 | /* Liked Songs Actions */ 102 | "add_to_liked_songs" = "Ajouter aux favoris"; 103 | "remove_from_liked_songs" = "Retirer des favoris"; 104 | 105 | /* Search Categories */ 106 | "all" = "Tout"; 107 | 108 | /* Sync and Connection */ 109 | "library_out_of_sync" = "Bibliothèque désynchronisée"; 110 | "library_sync_message" = "Votre bibliothèque musicale n'est actuellement pas synchronisée avec iCloud. Certaines fonctionnalités pourraient ne pas fonctionner jusqu'à ce que vous vous reconnectiez."; 111 | "offline_mode" = "Mode hors ligne"; 112 | "offline_mode_message" = "Synchronisation iCloud désactivée. Fichiers musicaux dans le dossier Documents uniquement."; 113 | "icloud_connection_required" = "Connexion iCloud requise"; 114 | 115 | /* Tutorial/Onboarding */ 116 | "welcome_to_cosmos" = "Bienvenue dans Cosmos"; 117 | "sign_in_to_apple_id" = "Se connecter à l'identifiant Apple"; 118 | "sign_in_message" = "Pour synchroniser votre musique et vos favoris sur tous vos appareils, assurez-vous d'être connecté à votre identifiant Apple."; 119 | "if_signed_in_continue" = "Si vous êtes connecté, vous pouvez continuer"; 120 | "enable_icloud_drive" = "Activer iCloud Drive"; 121 | "icloud_drive_message" = "iCloud Drive vous permet d'accéder à vos fichiers musicaux depuis n'importe quel appareil et garde vos favoris synchronisés."; 122 | "if_icloud_enabled_continue" = "Si iCloud Drive est activé, vous pouvez continuer"; 123 | "add_your_music" = "Ajoutez votre musique"; 124 | "how_to_add_music" = "Vous pouvez ajouter vos musiques soit dans iCloud (synchronisé sur vos appareils) ou localement :"; 125 | "open_files_app" = "Ouvrir l'app Fichiers"; 126 | "navigate_to_icloud_drive" = "Naviguer vers iCloud Drive"; 127 | "find_cosmos_player_folder" = "Trouver le dossier Cosmos Player"; 128 | "add_your_music_instruction" = "Ajoutez votre musique"; 129 | 130 | /* Tutorial Status Messages */ 131 | "signed_in_to_apple_id" = "Connecté à l'identifiant Apple"; 132 | "not_signed_in_to_apple_id" = "Non connecté à l'identifiant Apple"; 133 | "cannot_detect_apple_id_status" = "Impossible de détecter le statut de l'identifiant Apple"; 134 | "icloud_drive_enabled" = "iCloud Drive est activé"; 135 | "icloud_drive_not_enabled" = "iCloud Drive n'est pas activé"; 136 | "cannot_detect_icloud_status" = "Impossible de détecter le statut d'iCloud Drive"; 137 | 138 | /* Tutorial Instructions */ 139 | "find_open_files_app" = "Trouvez et ouvrez l'app Fichiers"; 140 | "tap_icloud_drive_sidebar" = "Choisissez 'iCloud Drive' ou 'Sur mon iPhone' dans la barre latérale"; 141 | "look_for_cosmos_folder" = "Trouvez le dossier 'Cosmos Music Player' et ouvrez-le"; 142 | "copy_music_files" = "Copiez vos fichiers FLAC ou MP3 dans ce dossier"; 143 | 144 | /* Library Processing */ 145 | "found_tracks" = "%d pistes trouvées"; 146 | "processing_colon" = "🎵 Traitement :"; 147 | "waiting_colon" = "⏳ En attente :"; 148 | "percent_complete" = "%d%%"; 149 | 150 | /* Subtitles and descriptions */ 151 | "songs_count" = "%d musiques"; 152 | "your_favorites" = "Vos favoris"; 153 | "your_playlists" = "Vos listes de lecture"; 154 | "browse_by_artist" = "Parcourir par artiste"; 155 | "browse_by_album" = "Parcourir par album"; 156 | "unknown_album" = "Album inconnu"; 157 | "unknown_artist" = "Artiste inconnu"; 158 | 159 | /* Sync result messages */ 160 | "sync_no_new_tracks" = "Aucune nouvelle musique trouvée"; 161 | "sync_one_new_track" = "1 nouvelle musique trouvée"; 162 | "sync_multiple_new_tracks" = "%d nouvelles musiques trouvées"; 163 | "sync_one_track_deleted" = "1 musique supprimée"; 164 | "sync_multiple_tracks_deleted" = "%d musiques supprimées"; 165 | "sync_no_changes" = "Bibliothèque à jour"; 166 | 167 | /* Equalizer strings */ 168 | "equalizer" = "Égaliseur"; 169 | "graphic_equalizer" = "Égaliseur Graphique"; 170 | "enable_equalizer" = "Activer l'Égaliseur"; 171 | "enable_disable_eq_description" = "Activer ou désactiver l'égaliseur graphique"; 172 | 173 | /* Manual EQ Presets */ 174 | "manual_eq_presets" = "Préréglages Manuels 16 Bandes"; 175 | "no_manual_presets_created" = "Aucun préréglage manuel créé"; 176 | "create_manual_eq_description" = "Créez un préréglage d'égaliseur 16 bandes personnalisé avec des curseurs ajustables"; 177 | "create_manual_16band_eq" = "Créer un EQ Manuel 16 Bandes"; 178 | "manual_16band_eq" = "EQ Manuel 16 Bandes"; 179 | "manual_16band_description" = "Crée un égaliseur 16 bandes avec des fréquences ISO standard que vous pouvez ajuster manuellement"; 180 | "adjust_bands_after_creation" = "Vous pourrez ajuster toutes les bandes de fréquence après la création"; 181 | "frequency_bands" = "Bandes de Fréquence"; 182 | "edit_equalizer" = "Modifier l'Égaliseur"; 183 | "reset_to_flat" = "Réinitialiser à Plat"; 184 | 185 | /* Imported GraphicEQ Presets */ 186 | "imported_presets" = "Préréglages Importés"; 187 | "no_presets_imported" = "Aucun préréglage importé"; 188 | "import_graphiceq_description" = "Importez des fichiers GraphicEQ .txt pour commencer"; 189 | "imported_graphiceq" = "GraphicEQ Importé"; 190 | "import_graphiceq_file" = "Importer un Fichier GraphicEQ"; 191 | 192 | /* General EQ Settings */ 193 | "global_settings" = "Réglages Globaux"; 194 | "global_gain" = "Gain Global"; 195 | "global_gain_description" = "Ajustez le niveau de volume global après traitement EQ"; 196 | "about_graphiceq_format" = "À propos du Format GraphicEQ"; 197 | "import_graphiceq_format_description" = "Importez des fichiers GraphicEQ .txt avec le format :"; 198 | "frequency_gain_pair_description" = "Chaque paire représente la fréquence (Hz) et le gain (dB)"; 199 | "delete" = "Supprimer"; 200 | "export" = "Exporter"; 201 | "edit" = "Modifier"; 202 | "cancel" = "Annuler"; 203 | "done" = "Terminé"; 204 | "save" = "Enregistrer"; 205 | "create" = "Créer"; 206 | "preset_info" = "Infos du Préréglage"; 207 | 208 | /* GraphicEQ Import View */ 209 | "import_graphiceq" = "Importer GraphicEQ"; 210 | "preset_name" = "Nom du Préréglage"; 211 | "enter_preset_name" = "Entrer le nom du préréglage"; 212 | "import_methods" = "Méthodes d'Importation"; 213 | "import_from_txt_file" = "Importer depuis un Fichier .txt"; 214 | "paste_graphiceq_text" = "Coller le Texte GraphicEQ"; 215 | "error" = "Erreur"; 216 | "format_info" = "Informations sur le Format"; 217 | "expected_graphiceq_format" = "Format GraphicEQ attendu :"; 218 | "frequency_gain_pair" = "Chaque paire : fréquence (Hz) gain (dB)"; 219 | 220 | /* Text Import View */ 221 | "paste_graphiceq" = "Coller GraphicEQ"; 222 | "paste_graphiceq_text_section" = "Coller le Texte GraphicEQ"; 223 | "example" = "Exemple"; 224 | "import" = "Importer"; 225 | 226 | /* Error Messages */ 227 | "failed_to_import" = "Échec de l'importation : %@"; 228 | "file_import_failed" = "Échec de l'importation du fichier : %@"; 229 | "failed_to_create" = "Échec de la création : %@"; 230 | "failed_to_export" = "Échec de l'exportation du préréglage"; 231 | "failed_to_delete" = "Échec de la suppression du préréglage"; 232 | 233 | /* EQ Band Information */ 234 | "band_count_info" = "%d bandes (réduites de %d)"; 235 | "bands_reduced_description" = "Votre GraphicEQ a plus de bandes qu'iOS ne supporte. Le système mappe intelligemment vos %d bandes vers %d bandes en utilisant la moyenne des fréquences."; 236 | "bands_limited_warning" = "⚠️ GraphicEQ réduit de %d à %d bandes (limite iOS)"; 237 | 238 | /* Audio Settings */ 239 | "audio_settings" = "Audio"; 240 | "dsd_playback_mode" = "Mode de Lecture DSD"; 241 | "dsd_playback_mode_description" = "Choisissez comment les fichiers audio DSD (DSF/DFF) sont lus"; 242 | "dsd_mode_auto" = "Auto (Détection DAC)"; 243 | "dsd_mode_pcm" = "Conversion PCM"; 244 | "dsd_mode_dop" = "DoP (DSD sur PCM)"; 245 | "dsd_mode_auto_description" = "Détecte automatiquement le DAC externe et utilise DoP si disponible, sinon convertit en PCM"; 246 | "dsd_mode_pcm_description" = "Toujours convertir DSD en PCM pour la lecture (compatible avec tous les appareils)"; 247 | "dsd_mode_dop_description" = "Toujours utiliser l'encodage DoP (nécessite un DAC externe qui supporte DSD)"; 248 | 249 | /* Sort Options */ 250 | "sort_date_newest" = "Date d'ajout (Plus récent)"; 251 | "sort_date_oldest" = "Date d'ajout (Plus ancien)"; 252 | "sort_name_az" = "Nom (A-Z)"; 253 | "sort_name_za" = "Nom (Z-A)"; 254 | "sort_size_largest" = "Taille (Plus grand)"; 255 | "sort_size_smallest" = "Taille (Plus petit)"; 256 | 257 | /* Queue Actions */ 258 | "play_next" = "Lire ensuite"; 259 | "add_to_queue" = "Ajouter à la file"; 260 | 261 | /* Bulk Selection */ 262 | "select" = "Sélectionner"; 263 | "select_all" = "Tout sélectionner"; 264 | "bulk_actions" = "Actions"; 265 | "add_to_liked" = "Ajouter aux favoris"; 266 | "remove_from_liked" = "Retirer des favoris"; 267 | "delete_files" = "Supprimer les fichiers"; 268 | "delete_files_confirmation" = "Supprimer les fichiers"; 269 | "selected_count_singular" = "%d sélectionné"; 270 | "selected_count_plural" = "%d sélectionnés"; 271 | "delete_files_confirmation_message" = "Êtes-vous sûr de vouloir supprimer %d fichier(s) ? Cette action ne peut pas être annulée."; 272 | -------------------------------------------------------------------------------- /Cosmos Music Player/Services/SpotifyAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpotifyAPI.swift 3 | // Cosmos Music Player 4 | // 5 | // Spotify API service for fetching artist information 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Spotify API Models 11 | 12 | struct SpotifyAuthResponse: Codable { 13 | let accessToken: String 14 | let tokenType: String 15 | let expiresIn: Int 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case accessToken = "access_token" 19 | case tokenType = "token_type" 20 | case expiresIn = "expires_in" 21 | } 22 | } 23 | 24 | struct SpotifySearchResponse: Codable { 25 | let artists: SpotifyArtistsResponse 26 | } 27 | 28 | struct SpotifyArtistsResponse: Codable { 29 | let href: String 30 | let items: [SpotifyArtist] 31 | let limit: Int 32 | let next: String? 33 | let offset: Int 34 | let previous: String? 35 | let total: Int 36 | } 37 | 38 | struct SpotifyArtist: Codable { 39 | let id: String 40 | let name: String 41 | let genres: [String] 42 | let images: [SpotifyImage] 43 | let popularity: Int 44 | let followers: SpotifyFollowers 45 | let externalUrls: SpotifyExternalUrls 46 | let href: String 47 | let uri: String 48 | 49 | // Computed property for bio/profile (Spotify doesn't provide artist bios) 50 | var profile: String { 51 | var bio = "" 52 | 53 | if !genres.isEmpty { 54 | let genreDescription = genres.count == 1 ? genres.first! : genres.dropLast().joined(separator: ", ") + " and " + genres.last! 55 | bio += "Known for their work in \(genreDescription) music" 56 | 57 | if popularity > 80 { 58 | bio += ", this highly acclaimed artist has gained significant recognition in the music industry" 59 | } else if popularity > 60 { 60 | bio += ", this popular artist continues to build their reputation" 61 | } else if popularity > 40 { 62 | bio += ", this emerging talent is making their mark" 63 | } else { 64 | bio += ", this artist brings a unique sound to their musical style" 65 | } 66 | 67 | if let followerCount = followers.total, followerCount > 0 { 68 | if followerCount >= 1000000 { 69 | bio += " with millions of dedicated listeners" 70 | } else if followerCount >= 100000 { 71 | bio += " with hundreds of thousands of fans" 72 | } else if followerCount >= 10000 { 73 | bio += " with tens of thousands of followers" 74 | } else { 75 | bio += " with a growing fanbase" 76 | } 77 | } 78 | 79 | bio += "." 80 | } else { 81 | bio += "An artist exploring diverse musical territories" 82 | if popularity > 50 { 83 | bio += " with notable recognition in the music scene" 84 | } 85 | bio += "." 86 | } 87 | 88 | return bio 89 | } 90 | 91 | enum CodingKeys: String, CodingKey { 92 | case id, name, genres, images, popularity, followers, href, uri 93 | case externalUrls = "external_urls" 94 | } 95 | } 96 | 97 | struct SpotifyImage: Codable { 98 | let url: String 99 | let height: Int? 100 | let width: Int? 101 | } 102 | 103 | struct SpotifyFollowers: Codable { 104 | let href: String? 105 | let total: Int? 106 | } 107 | 108 | struct SpotifyExternalUrls: Codable { 109 | let spotify: String? 110 | } 111 | 112 | // MARK: - Cached Artist Data 113 | 114 | class CachedSpotifyArtistInfo: NSObject, Codable { 115 | let artistName: String 116 | let spotifyArtist: SpotifyArtist 117 | let cachedAt: Date 118 | 119 | init(artistName: String, spotifyArtist: SpotifyArtist, cachedAt: Date) { 120 | self.artistName = artistName 121 | self.spotifyArtist = spotifyArtist 122 | self.cachedAt = cachedAt 123 | super.init() 124 | } 125 | 126 | var isExpired: Bool { 127 | // Cache for 7 days 128 | return Date().timeIntervalSince(cachedAt) > 7 * 24 * 60 * 60 129 | } 130 | } 131 | 132 | // MARK: - Token Cache 133 | 134 | class SpotifyAccessToken { 135 | let token: String 136 | let expiresAt: Date 137 | 138 | init(token: String, expiresIn: Int) { 139 | self.token = token 140 | self.expiresAt = Date().addingTimeInterval(TimeInterval(expiresIn - 60)) // Refresh 1 minute early 141 | } 142 | 143 | var isExpired: Bool { 144 | return Date() >= expiresAt 145 | } 146 | } 147 | 148 | // MARK: - Spotify API Service 149 | 150 | class SpotifyAPIService: ObservableObject, @unchecked Sendable { 151 | @MainActor static let shared = SpotifyAPIService() 152 | 153 | private let clientId = EnvironmentLoader.shared.spotifyClientId 154 | private let clientSecret = EnvironmentLoader.shared.spotifyClientSecret 155 | private let baseURL = "https://api.spotify.com/v1" 156 | private let authURL = "https://accounts.spotify.com/api/token" 157 | 158 | private let cache = NSCache() 159 | private let cacheDirectory: URL 160 | private var accessToken: SpotifyAccessToken? 161 | 162 | private init() { 163 | // Set up cache directory 164 | let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! 165 | cacheDirectory = documentsPath.appendingPathComponent("SpotifyCache") 166 | 167 | // Create cache directory if it doesn't exist 168 | try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) 169 | 170 | // Configure NSCache 171 | cache.countLimit = 100 // Limit to 100 cached artists 172 | } 173 | 174 | // MARK: - Public API 175 | 176 | func searchArtist(name: String) async throws -> SpotifyArtist? { 177 | print("🎵 Spotify: Searching for artist: \(name)") 178 | 179 | // Check cache first 180 | if let cached = getCachedArtist(name: name), !cached.isExpired { 181 | print("✅ Spotify: Found cached artist: \(name)") 182 | return cached.spotifyArtist 183 | } 184 | 185 | // Ensure we have a valid access token 186 | try await ensureValidAccessToken() 187 | 188 | // Search for artist 189 | let artists = try await performSearch(query: name, type: "artist") 190 | 191 | // Find best match 192 | guard let bestMatch = findBestMatch(for: name, in: artists) else { 193 | print("❌ Spotify: No matching artist found for: \(name)") 194 | return nil 195 | } 196 | 197 | print("🎯 Spotify: Found match: \(bestMatch.name)") 198 | 199 | // Cache the result 200 | cacheArtist(name: name, artist: bestMatch) 201 | 202 | return bestMatch 203 | } 204 | 205 | // MARK: - Authentication 206 | 207 | private func ensureValidAccessToken() async throws { 208 | if let token = accessToken, !token.isExpired { 209 | return // Token is still valid 210 | } 211 | 212 | // Need to get a new token 213 | print("🔐 Spotify: Getting new access token...") 214 | accessToken = try await getAccessToken() 215 | print("✅ Spotify: Successfully obtained access token") 216 | } 217 | 218 | private func getAccessToken() async throws -> SpotifyAccessToken { 219 | guard let url = URL(string: authURL) else { 220 | throw SpotifyAPIError.invalidURL 221 | } 222 | 223 | // Create credentials string and encode it in base64 224 | let credentials = "\(clientId):\(clientSecret)" 225 | guard let credentialsData = credentials.data(using: .utf8) else { 226 | throw SpotifyAPIError.authenticationError 227 | } 228 | let base64Credentials = credentialsData.base64EncodedString() 229 | 230 | var request = URLRequest(url: url) 231 | request.httpMethod = "POST" 232 | request.setValue("Basic \(base64Credentials)", forHTTPHeaderField: "Authorization") 233 | request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 234 | 235 | let bodyString = "grant_type=client_credentials" 236 | request.httpBody = bodyString.data(using: .utf8) 237 | 238 | let (data, response) = try await URLSession.shared.data(for: request) 239 | 240 | if let httpResponse = response as? HTTPURLResponse { 241 | print("📡 Spotify Auth: Response status: \(httpResponse.statusCode)") 242 | if httpResponse.statusCode != 200 { 243 | throw SpotifyAPIError.httpError(httpResponse.statusCode) 244 | } 245 | } 246 | 247 | let authResponse = try JSONDecoder().decode(SpotifyAuthResponse.self, from: data) 248 | return SpotifyAccessToken(token: authResponse.accessToken, expiresIn: authResponse.expiresIn) 249 | } 250 | 251 | // MARK: - Search 252 | 253 | private func performSearch(query: String, type: String) async throws -> [SpotifyArtist] { 254 | guard let accessToken = accessToken else { 255 | throw SpotifyAPIError.noAccessToken 256 | } 257 | 258 | let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query 259 | let urlString = "\(baseURL)/search?q=\(encodedQuery)&type=\(type)&limit=10" 260 | 261 | guard let url = URL(string: urlString) else { 262 | throw SpotifyAPIError.invalidURL 263 | } 264 | 265 | var request = URLRequest(url: url) 266 | request.setValue("Bearer \(accessToken.token)", forHTTPHeaderField: "Authorization") 267 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 268 | 269 | print("🌐 Spotify: Making request to: \(urlString)") 270 | 271 | let (data, response) = try await URLSession.shared.data(for: request) 272 | 273 | if let httpResponse = response as? HTTPURLResponse { 274 | print("📡 Spotify: Response status: \(httpResponse.statusCode)") 275 | if httpResponse.statusCode != 200 { 276 | throw SpotifyAPIError.httpError(httpResponse.statusCode) 277 | } 278 | } 279 | 280 | let searchResponse = try JSONDecoder().decode(SpotifySearchResponse.self, from: data) 281 | print("🔍 Spotify: Found \(searchResponse.artists.items.count) results") 282 | 283 | return searchResponse.artists.items 284 | } 285 | 286 | private func findBestMatch(for artistName: String, in results: [SpotifyArtist]) -> SpotifyArtist? { 287 | let normalizedName = artistName.lowercased().trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters)) 288 | 289 | // Try exact match first 290 | for result in results { 291 | let resultName = result.name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters)) 292 | if resultName == normalizedName { 293 | return result 294 | } 295 | } 296 | 297 | // Try partial match 298 | for result in results { 299 | let resultName = result.name.lowercased() 300 | if resultName.contains(normalizedName) || normalizedName.contains(resultName) { 301 | return result 302 | } 303 | } 304 | 305 | // Return first result if no exact match 306 | return results.first 307 | } 308 | 309 | // MARK: - Caching 310 | 311 | private func getCachedArtist(name: String) -> CachedSpotifyArtistInfo? { 312 | let key = NSString(string: name.lowercased()) 313 | 314 | // Check memory cache first 315 | if let cached = cache.object(forKey: key) { 316 | return cached 317 | } 318 | 319 | // Check disk cache 320 | let filename = name.lowercased().replacingOccurrences(of: " ", with: "_") + ".json" 321 | let fileURL = cacheDirectory.appendingPathComponent(filename) 322 | 323 | guard let data = try? Data(contentsOf: fileURL), 324 | let cached = try? JSONDecoder().decode(CachedSpotifyArtistInfo.self, from: data) else { 325 | return nil 326 | } 327 | 328 | // Store in memory cache 329 | cache.setObject(cached, forKey: key) 330 | return cached 331 | } 332 | 333 | private func cacheArtist(name: String, artist: SpotifyArtist) { 334 | let cached = CachedSpotifyArtistInfo(artistName: name, spotifyArtist: artist, cachedAt: Date()) 335 | let key = NSString(string: name.lowercased()) 336 | 337 | // Store in memory cache 338 | cache.setObject(cached, forKey: key) 339 | 340 | // Store in disk cache 341 | let filename = name.lowercased().replacingOccurrences(of: " ", with: "_") + ".json" 342 | let fileURL = cacheDirectory.appendingPathComponent(filename) 343 | 344 | do { 345 | let data = try JSONEncoder().encode(cached) 346 | try data.write(to: fileURL) 347 | print("💾 Spotify: Cached artist data for: \(name)") 348 | } catch { 349 | print("❌ Spotify: Failed to cache artist data: \(error)") 350 | } 351 | } 352 | } 353 | 354 | // MARK: - Errors 355 | 356 | enum SpotifyAPIError: Error, LocalizedError { 357 | case invalidURL 358 | case httpError(Int) 359 | case decodingError(Error) 360 | case networkError(Error) 361 | case authenticationError 362 | case noAccessToken 363 | 364 | var errorDescription: String? { 365 | switch self { 366 | case .invalidURL: 367 | return "Invalid URL" 368 | case .httpError(let code): 369 | return "HTTP Error: \(code)" 370 | case .decodingError(let error): 371 | return "Decoding Error: \(error.localizedDescription)" 372 | case .networkError(let error): 373 | return "Network Error: \(error.localizedDescription)" 374 | case .authenticationError: 375 | return "Authentication Error" 376 | case .noAccessToken: 377 | return "No valid access token" 378 | } 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /Cosmos Music Player/Helpers/Cosmos Music Player 2025-10-04 10-22-35/DistributionSummary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Cosmos Music Player.ipa 6 | 7 | 8 | architectures 9 | 10 | arm64 11 | 12 | buildNumber 13 | 31 14 | certificate 15 | 16 | SHA1 17 | 90B3662A18B962192571D0A1691A769791AC241B 18 | dateExpires 19 | 05/09/2026 20 | type 21 | Cloud Managed Apple Distribution 22 | 23 | embeddedBinaries 24 | 25 | 26 | architectures 27 | 28 | arm64 29 | 30 | buildNumber 31 | 1 32 | certificate 33 | 34 | SHA1 35 | 90B3662A18B962192571D0A1691A769791AC241B 36 | dateExpires 37 | 05/09/2026 38 | type 39 | Cloud Managed Apple Distribution 40 | 41 | name 42 | FLAC.framework 43 | team 44 | 45 | id 46 | PC237X7NGJ 47 | name 48 | Raphael Boullay Le Fur 49 | 50 | versionNumber 51 | 1.0 52 | 53 | 54 | architectures 55 | 56 | arm64 57 | 58 | buildNumber 59 | 1 60 | certificate 61 | 62 | SHA1 63 | 90B3662A18B962192571D0A1691A769791AC241B 64 | dateExpires 65 | 05/09/2026 66 | type 67 | Cloud Managed Apple Distribution 68 | 69 | name 70 | lame.framework 71 | team 72 | 73 | id 74 | PC237X7NGJ 75 | name 76 | Raphael Boullay Le Fur 77 | 78 | versionNumber 79 | 1.0 80 | 81 | 82 | architectures 83 | 84 | arm64 85 | 86 | buildNumber 87 | 1 88 | certificate 89 | 90 | SHA1 91 | 90B3662A18B962192571D0A1691A769791AC241B 92 | dateExpires 93 | 05/09/2026 94 | type 95 | Cloud Managed Apple Distribution 96 | 97 | name 98 | mpc.framework 99 | team 100 | 101 | id 102 | PC237X7NGJ 103 | name 104 | Raphael Boullay Le Fur 105 | 106 | versionNumber 107 | 1.0 108 | 109 | 110 | architectures 111 | 112 | arm64 113 | 114 | buildNumber 115 | 1 116 | certificate 117 | 118 | SHA1 119 | 90B3662A18B962192571D0A1691A769791AC241B 120 | dateExpires 121 | 05/09/2026 122 | type 123 | Cloud Managed Apple Distribution 124 | 125 | name 126 | mpg123.framework 127 | team 128 | 129 | id 130 | PC237X7NGJ 131 | name 132 | Raphael Boullay Le Fur 133 | 134 | versionNumber 135 | 1.0 136 | 137 | 138 | architectures 139 | 140 | arm64 141 | 142 | buildNumber 143 | 1 144 | certificate 145 | 146 | SHA1 147 | 90B3662A18B962192571D0A1691A769791AC241B 148 | dateExpires 149 | 05/09/2026 150 | type 151 | Cloud Managed Apple Distribution 152 | 153 | name 154 | ogg.framework 155 | team 156 | 157 | id 158 | PC237X7NGJ 159 | name 160 | Raphael Boullay Le Fur 161 | 162 | versionNumber 163 | 1.0 164 | 165 | 166 | architectures 167 | 168 | arm64 169 | 170 | buildNumber 171 | 1 172 | certificate 173 | 174 | SHA1 175 | 90B3662A18B962192571D0A1691A769791AC241B 176 | dateExpires 177 | 05/09/2026 178 | type 179 | Cloud Managed Apple Distribution 180 | 181 | name 182 | opus.framework 183 | team 184 | 185 | id 186 | PC237X7NGJ 187 | name 188 | Raphael Boullay Le Fur 189 | 190 | versionNumber 191 | 1.0 192 | 193 | 194 | architectures 195 | 196 | arm64 197 | 198 | buildNumber 199 | 1 200 | certificate 201 | 202 | SHA1 203 | 90B3662A18B962192571D0A1691A769791AC241B 204 | dateExpires 205 | 05/09/2026 206 | type 207 | Cloud Managed Apple Distribution 208 | 209 | name 210 | sndfile.framework 211 | team 212 | 213 | id 214 | PC237X7NGJ 215 | name 216 | Raphael Boullay Le Fur 217 | 218 | versionNumber 219 | 1.0 220 | 221 | 222 | architectures 223 | 224 | arm64 225 | 226 | buildNumber 227 | 1 228 | certificate 229 | 230 | SHA1 231 | 90B3662A18B962192571D0A1691A769791AC241B 232 | dateExpires 233 | 05/09/2026 234 | type 235 | Cloud Managed Apple Distribution 236 | 237 | name 238 | tta-cpp.framework 239 | team 240 | 241 | id 242 | PC237X7NGJ 243 | name 244 | Raphael Boullay Le Fur 245 | 246 | versionNumber 247 | 1.0 248 | 249 | 250 | architectures 251 | 252 | arm64 253 | 254 | buildNumber 255 | 1 256 | certificate 257 | 258 | SHA1 259 | 90B3662A18B962192571D0A1691A769791AC241B 260 | dateExpires 261 | 05/09/2026 262 | type 263 | Cloud Managed Apple Distribution 264 | 265 | name 266 | vorbis.framework 267 | team 268 | 269 | id 270 | PC237X7NGJ 271 | name 272 | Raphael Boullay Le Fur 273 | 274 | versionNumber 275 | 1.0 276 | 277 | 278 | architectures 279 | 280 | arm64 281 | 282 | buildNumber 283 | 1 284 | certificate 285 | 286 | SHA1 287 | 90B3662A18B962192571D0A1691A769791AC241B 288 | dateExpires 289 | 05/09/2026 290 | type 291 | Cloud Managed Apple Distribution 292 | 293 | name 294 | wavpack.framework 295 | team 296 | 297 | id 298 | PC237X7NGJ 299 | name 300 | Raphael Boullay Le Fur 301 | 302 | versionNumber 303 | 1.0 304 | 305 | 306 | architectures 307 | 308 | arm64 309 | 310 | buildNumber 311 | 31 312 | certificate 313 | 314 | SHA1 315 | 90B3662A18B962192571D0A1691A769791AC241B 316 | dateExpires 317 | 05/09/2026 318 | type 319 | Cloud Managed Apple Distribution 320 | 321 | entitlements 322 | 323 | application-identifier 324 | PC237X7NGJ.dev.clq.Cosmos-Music-Player.Share 325 | com.apple.developer.team-identifier 326 | PC237X7NGJ 327 | com.apple.security.application-groups 328 | 329 | group.dev.clq.Cosmos-Music-Player 330 | 331 | get-task-allow 332 | 333 | 334 | name 335 | Share.appex 336 | profile 337 | 338 | UUID 339 | 67e219ad-3fdb-4c11-8281-9f2dded0b7ab 340 | dateExpires 341 | 05/09/2026 342 | name 343 | iOS Team Ad Hoc Provisioning Profile: dev.clq.Cosmos-Music-Player.Share 344 | 345 | team 346 | 347 | id 348 | PC237X7NGJ 349 | name 350 | Raphael Boullay Le Fur 351 | 352 | versionNumber 353 | 1.0.2 354 | 355 | 356 | architectures 357 | 358 | arm64 359 | 360 | buildNumber 361 | 31 362 | certificate 363 | 364 | SHA1 365 | 90B3662A18B962192571D0A1691A769791AC241B 366 | dateExpires 367 | 05/09/2026 368 | type 369 | Cloud Managed Apple Distribution 370 | 371 | entitlements 372 | 373 | application-identifier 374 | PC237X7NGJ.dev.clq.Cosmos-Music-Player.SiriIntentsExtension 375 | com.apple.developer.team-identifier 376 | PC237X7NGJ 377 | com.apple.security.application-groups 378 | 379 | group.dev.clq.Cosmos-Music-Player 380 | 381 | get-task-allow 382 | 383 | 384 | name 385 | SiriIntentsExtension.appex 386 | profile 387 | 388 | UUID 389 | cd752ba2-8823-4f94-ba0c-fbc6c1b1ae0d 390 | dateExpires 391 | 05/09/2026 392 | name 393 | iOS Team Ad Hoc Provisioning Profile: dev.clq.Cosmos-Music-Player.SiriIntentsExtension 394 | 395 | team 396 | 397 | id 398 | PC237X7NGJ 399 | name 400 | Raphael Boullay Le Fur 401 | 402 | versionNumber 403 | 1.0.2 404 | 405 | 406 | entitlements 407 | 408 | application-identifier 409 | PC237X7NGJ.dev.clq.Cosmos-Music-Player 410 | com.apple.developer.carplay-audio 411 | 412 | com.apple.developer.icloud-container-environment 413 | Production 414 | com.apple.developer.icloud-container-identifiers 415 | 416 | iCloud.dev.clq.Cosmos-Music-Player 417 | 418 | com.apple.developer.icloud-services 419 | 420 | CloudDocuments 421 | CloudKit 422 | 423 | com.apple.developer.siri 424 | 425 | com.apple.developer.team-identifier 426 | PC237X7NGJ 427 | com.apple.developer.ubiquity-container-identifiers 428 | 429 | iCloud.dev.clq.Cosmos-Music-Player 430 | 431 | com.apple.security.application-groups 432 | 433 | group.dev.clq.Cosmos-Music-Player 434 | 435 | get-task-allow 436 | 437 | 438 | name 439 | Cosmos Music Player.app 440 | profile 441 | 442 | UUID 443 | 4f5b42e0-fc14-4bd7-b2e1-f45bd66ab963 444 | dateExpires 445 | 05/09/2026 446 | name 447 | iOS Team Ad Hoc Provisioning Profile: dev.clq.Cosmos-Music-Player 448 | 449 | team 450 | 451 | id 452 | PC237X7NGJ 453 | name 454 | Raphael Boullay Le Fur 455 | 456 | versionNumber 457 | 1.0.2 458 | 459 | 460 | 461 | 462 | --------------------------------------------------------------------------------