├── .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 |
--------------------------------------------------------------------------------