Live Activities, nudging, unit tests

This commit is contained in:
2026-04-20 11:11:24 +02:00
parent 3858500a45
commit 7e25a4f978
15 changed files with 348 additions and 20 deletions
+2 -2
View File
@@ -55,7 +55,7 @@
"type" : "Consumable" "type" : "Consumable"
}, },
{ {
"displayPrice" : "0.99", "displayPrice" : "2.99",
"familyShareable" : false, "familyShareable" : false,
"internalID" : "6761880625", "internalID" : "6761880625",
"localizations" : [ "localizations" : [
@@ -83,7 +83,7 @@
"_developerTeamID" : "EKFHUHT63T", "_developerTeamID" : "EKFHUHT63T",
"_disableDialogs" : false, "_disableDialogs" : false,
"_failTransactionsEnabled" : false, "_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 797413193.48934102, "_lastSynchronizedDate" : 798368909.15410197,
"_locale" : "en_US", "_locale" : "en_US",
"_renewalBillingIssuesEnabled" : false, "_renewalBillingIssuesEnabled" : false,
"_storefront" : "USA", "_storefront" : "USA",
@@ -16,8 +16,19 @@
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; }; 26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; };
26DA6F7F2F928B2100849EC7 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */; }; 26DA6F7F2F928B2100849EC7 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */; };
26DA6F812F928B2100849EC7 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26DA6F802F928B2100849EC7 /* SwiftUI.framework */; }; 26DA6F812F928B2100849EC7 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26DA6F802F928B2100849EC7 /* SwiftUI.framework */; };
90E974264D154BE8994F9E4F /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C856F1982B73404D8539D5F6 /* XCTest.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
7E0336539BFF46B697265C50 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 26ED92592F759EEA0025419D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 26ED92602F759EEA0025419D;
remoteInfo = "Mobile Music Assistant";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
269ECE592F92A21100444B14 /* CopyFiles */ = { 269ECE592F92A21100444B14 /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
@@ -41,6 +52,8 @@
26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
26DA6F802F928B2100849EC7 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 26DA6F802F928B2100849EC7 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; };
B8A2C9310F1D4E7B8C456DEF /* MobileMusicAssistantTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MobileMusicAssistantTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C856F1982B73404D8539D5F6 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -75,6 +88,11 @@
path = "Mobile Music Assistant"; path = "Mobile Music Assistant";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
470E2652A47C438395161BBE /* MobileMusicAssistantTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = MobileMusicAssistantTests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -96,9 +114,25 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
F61F7920CF95441B8DBFD240 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
90E974264D154BE8994F9E4F /* XCTest.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
26D648CD2F961C3D0095ADFE /* Recovered References */ = {
isa = PBXGroup;
children = (
C856F1982B73404D8539D5F6 /* XCTest.framework */,
);
name = "Recovered References";
sourceTree = "<group>";
};
26DA6F7D2F928B2100849EC7 /* Frameworks */ = { 26DA6F7D2F928B2100849EC7 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -120,6 +154,8 @@
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */, 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */, 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */,
2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */, 2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */,
470E2652A47C438395161BBE /* MobileMusicAssistantTests */,
26D648CD2F961C3D0095ADFE /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -128,6 +164,7 @@
children = ( children = (
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */, 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */,
26DA6F7C2F928B2100849EC7 /* MobileMALiveActivityExtension.appex */, 26DA6F7C2F928B2100849EC7 /* MobileMALiveActivityExtension.appex */,
B8A2C9310F1D4E7B8C456DEF /* MobileMusicAssistantTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -135,6 +172,27 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
238344CE1A9D4B65ADFC4985 /* MobileMusicAssistantTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 141B93AED5AC40528B942A54 /* Build configuration list for PBXNativeTarget "MobileMusicAssistantTests" */;
buildPhases = (
542FA6B14D1B439B91514A5B /* Sources */,
F61F7920CF95441B8DBFD240 /* Frameworks */,
E9CACA4F05AF45868831E179 /* Resources */,
);
buildRules = (
);
dependencies = (
506F6578918A4D6CA740E1E5 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
470E2652A47C438395161BBE /* MobileMusicAssistantTests */,
);
name = MobileMusicAssistantTests;
productName = MobileMusicAssistantTests;
productReference = B8A2C9310F1D4E7B8C456DEF /* MobileMusicAssistantTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */ = { 26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 26DA6F912F928B2200849EC7 /* Build configuration list for PBXNativeTarget "MobileMALiveActivityExtension" */; buildConfigurationList = 26DA6F912F928B2200849EC7 /* Build configuration list for PBXNativeTarget "MobileMALiveActivityExtension" */;
@@ -192,6 +250,10 @@
LastSwiftUpdateCheck = 2640; LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2640; LastUpgradeCheck = 2640;
TargetAttributes = { TargetAttributes = {
238344CE1A9D4B65ADFC4985 = {
CreatedOnToolsVersion = 26.4;
TestTargetID = 26ED92602F759EEA0025419D;
};
26DA6F7B2F928B2100849EC7 = { 26DA6F7B2F928B2100849EC7 = {
CreatedOnToolsVersion = 26.4; CreatedOnToolsVersion = 26.4;
}; };
@@ -222,6 +284,7 @@
targets = ( targets = (
26ED92602F759EEA0025419D /* Mobile Music Assistant */, 26ED92602F759EEA0025419D /* Mobile Music Assistant */,
26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */, 26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */,
238344CE1A9D4B65ADFC4985 /* MobileMusicAssistantTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -242,6 +305,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
E9CACA4F05AF45868831E179 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -262,9 +332,54 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
542FA6B14D1B439B91514A5B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
506F6578918A4D6CA740E1E5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 26ED92602F759EEA0025419D /* Mobile Music Assistant */;
targetProxy = 7E0336539BFF46B697265C50 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
0492C8936D97405C87FB61FC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant.Tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mobile Music Assistant.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mobile Music Assistant";
};
name = Release;
};
2320FB54202843A595CB0E32 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant.Tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mobile Music Assistant.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mobile Music Assistant";
};
name = Debug;
};
26DA6F8E2F928B2200849EC7 /* Debug */ = { 26DA6F8E2F928B2200849EC7 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -529,6 +644,15 @@
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
141B93AED5AC40528B942A54 /* Build configuration list for PBXNativeTarget "MobileMusicAssistantTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2320FB54202843A595CB0E32 /* Debug */,
0492C8936D97405C87FB61FC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
26DA6F912F928B2200849EC7 /* Build configuration list for PBXNativeTarget "MobileMALiveActivityExtension" */ = { 26DA6F912F928B2200849EC7 /* Build configuration list for PBXNativeTarget "MobileMALiveActivityExtension" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@@ -21,6 +21,20 @@
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj"> ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "238344CE1A9D4B65ADFC4985"
BuildableName = "MobileMusicAssistantTests.xctest"
BlueprintName = "MobileMusicAssistantTests"
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction <TestAction
@@ -29,6 +43,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES"> shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "238344CE1A9D4B65ADFC4985"
BuildableName = "MobileMusicAssistantTests.xctest"
BlueprintName = "MobileMusicAssistantTests"
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
+66 -1
View File
@@ -187,6 +187,10 @@
"comment" : "A separator between the year and the number of tracks in an album.", "comment" : "A separator between the year and the number of tracks in an album.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"★" : {
"comment" : "A star emoji.",
"isCommentAutoGenerated" : true
},
"1. Open Music Assistant in a browser" : { "1. Open Music Assistant in a browser" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1414,6 +1418,10 @@
} }
} }
}, },
"Keep Mobile MA Growing" : {
"comment" : "A title of a view that asks the user to support development.",
"isCommentAutoGenerated" : true
},
"Language" : { "Language" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1502,6 +1510,28 @@
} }
} }
}, },
"Lock Screen & Dynamic Island" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sperrbildschirm & Dynamic Island"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pantalla de bloqueo & Dynamic Island"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Écran verrouillé & Dynamic Island"
}
}
}
},
"Long-Lived Access Token" : { "Long-Lived Access Token" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1546,10 +1576,18 @@
} }
} }
}, },
"Maybe Later" : {
"comment" : "A button that dismisses the support nudge.",
"isCommentAutoGenerated" : true
},
"Mobile MA" : { "Mobile MA" : {
"comment" : "The name of the app.", "comment" : "The name of the app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Mobile MA is a free, passion-driven app. If it brings music to your life, a small donation helps keep it alive and growing." : {
"comment" : "A description of the benefits of supporting the development of Mobile MA.",
"isCommentAutoGenerated" : true
},
"Music Assistant" : { "Music Assistant" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -1986,7 +2024,6 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Now Playing" : { "Now Playing" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2563,6 +2600,28 @@
} }
} }
}, },
"Shows the current track on the Lock Screen and in the Dynamic Island." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zeigt den aktuellen Titel auf dem Sperrbildschirm und in der Dynamic Island an."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Muestra la pista actual en la pantalla de bloqueo y en la Dynamic Island."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Affiche la piste en cours sur l'écran verrouillé et dans la Dynamic Island."
}
}
}
},
"Shuffle" : { "Shuffle" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2654,6 +2713,9 @@
} }
} }
} }
},
"Supporter" : {
}, },
"Synced to: %@" : { "Synced to: %@" : {
"localizations" : { "localizations" : {
@@ -2812,6 +2874,9 @@
} }
} }
} }
},
"Thank you! ♥" : {
}, },
"This will delete all locally cached artwork and library data. The next launch or reload may take longer while everything is fetched again." : { "This will delete all locally cached artwork and library data. The next launch or reload may take longer while everything is fetched again." : {
"localizations" : { "localizations" : {
@@ -149,6 +149,11 @@ final class MAPlayerManager {
/// Finds the best currently-playing player and pushes its state to the Live Activity. /// Finds the best currently-playing player and pushes its state to the Live Activity.
/// Spawns a Task to fetch artwork with auth before updating. /// Spawns a Task to fetch artwork with auth before updating.
private func updateLiveActivity() { private func updateLiveActivity() {
guard UserDefaults.standard.object(forKey: "liveActivityEnabled") as? Bool ?? true else {
liveActivityManager.end()
return
}
let playing = players.values let playing = players.values
.filter { $0.state == .playing } .filter { $0.state == .playing }
.first { $0.currentItem != nil || playerQueues[$0.playerId]?.currentItem != nil } .first { $0.currentItem != nil || playerQueues[$0.playerId]?.currentItem != nil }
@@ -248,6 +253,9 @@ final class MAPlayerManager {
} }
} }
/// Exposed for unit testing only.
static func testResizeAndEncode(_ image: UIImage) -> Data? { resizeAndEncode(image) }
private static func resizeAndEncode(_ image: UIImage) -> Data? { private static func resizeAndEncode(_ image: UIImage) -> Data? {
// ActivityKit ContentState limit is 4 KB total (Data fields are base64 in the payload). // ActivityKit ContentState limit is 4 KB total (Data fields are base64 in the payload).
// 40×40 JPEG at 0.3 quality 400700 bytes, well within limits. // 40×40 JPEG at 0.3 quality 400700 bytes, well within limits.
@@ -55,6 +55,7 @@ final class MAWebSocketClient {
// MARK: - Properties // MARK: - Properties
private(set) var connectionState: ConnectionState = .disconnected private(set) var connectionState: ConnectionState = .disconnected
var isConnected: Bool { connectionState == .connected }
private var webSocketTask: URLSessionWebSocketTask? private var webSocketTask: URLSessionWebSocketTask?
private let session: URLSession private let session: URLSession
@@ -13,6 +13,7 @@ private let syncLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Mobi
struct MainTabView: View { struct MainTabView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@Environment(MAStoreManager.self) private var storeManager
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var selectedTab: String = "library" @State private var selectedTab: String = "library"
@@ -40,6 +41,7 @@ struct MainTabView: View {
Tab("Settings", systemImage: "gear", value: "settings") { Tab("Settings", systemImage: "gear", value: "settings") {
SettingsView() SettingsView()
} }
.badge(storeManager.hasEverSupported ? Text("") : nil)
} }
.withToast() .withToast()
.task { .task {
@@ -597,6 +599,7 @@ struct SettingsView: View {
@Environment(MAStoreManager.self) private var storeManager @Environment(MAStoreManager.self) private var storeManager
@State private var showThankYou = false @State private var showThankYou = false
@State private var showClearCacheConfirm = false @State private var showClearCacheConfirm = false
@AppStorage("liveActivityEnabled") private var liveActivityEnabled = true
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -679,6 +682,22 @@ struct SettingsView: View {
Text("Connection") Text("Connection")
} }
// Now Playing Section
Section {
Toggle(isOn: $liveActivityEnabled) {
Label("Lock Screen & Dynamic Island", systemImage: "music.note.list")
}
.onChange(of: liveActivityEnabled) { _, enabled in
if !enabled {
service.playerManager.liveActivityManager.end()
}
}
} header: {
Text("Now Playing")
} footer: {
Text("Shows the current track on the Lock Screen and in the Dynamic Island.")
}
// Actions Section // Actions Section
Section { Section {
Button(role: .destructive) { Button(role: .destructive) {
@@ -698,6 +717,20 @@ struct SettingsView: View {
// Support Development Section // Support Development Section
Section { Section {
// Supporter badge row
if storeManager.hasEverSupported {
HStack(spacing: 10) {
Image(systemName: "star.fill")
.foregroundStyle(.orange)
Text("Supporter")
.font(.body.weight(.semibold))
.foregroundStyle(.orange)
Spacer()
Text("Thank you! ♥")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if let loadError = storeManager.loadError { if let loadError = storeManager.loadError {
Label(loadError, systemImage: "exclamationmark.triangle") Label(loadError, systemImage: "exclamationmark.triangle")
.font(.caption) .font(.caption)
@@ -10,10 +10,12 @@ import UIKit
struct RootView: View { struct RootView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@Environment(MAStoreManager.self) private var storeManager
@State private var isInitializing = true @State private var isInitializing = true
@State private var loadingProgress: Double = 0.0 @State private var loadingProgress: Double = 0.0
@State private var loadingPhase: String = "Starting…" @State private var loadingPhase: String = "Starting…"
@State private var showNudge = false
var body: some View { var body: some View {
Group { Group {
@@ -32,9 +34,26 @@ struct RootView: View {
.animation(.easeInOut(duration: 0.4), value: service.isConnected) .animation(.easeInOut(duration: 0.4), value: service.isConnected)
.applyTheme() .applyTheme()
.applyLocale() .applyLocale()
.sheet(isPresented: $showNudge, onDismiss: {
storeManager.recordNudgeShown()
}) {
SupportNudgeView(isPresented: $showNudge)
}
.task { .task {
await initializeConnection() await initializeConnection()
} }
.onChange(of: isInitializing) { _, initializing in
if !initializing { checkNudge() }
}
}
private func checkNudge() {
guard storeManager.shouldShowNudge else { return }
// Small delay so the main UI settles before the sheet appears
Task {
try? await Task.sleep(for: .seconds(1))
showNudge = true
}
} }
// MARK: - Initialization // MARK: - Initialization
+1 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 5.9 // swift-tools-version: 6.0
import PackageDescription import PackageDescription
let package = Package( let package = Package(
@@ -82,9 +82,9 @@ struct FavoriteURICollectionTests {
@Test("Collects URIs of items marked as favorite") @Test("Collects URIs of items marked as favorite")
func collectsFavoriteURIs() throws { func collectsFavoriteURIs() throws {
let items: [MAMediaItem] = try [ let items: [MAMediaItem] = try [
"""{"uri":"spotify://1","name":"A","favorite":true}""", #"{"uri":"spotify://1","name":"A","favorite":true}"#,
"""{"uri":"spotify://2","name":"B","favorite":false}""", #"{"uri":"spotify://2","name":"B","favorite":false}"#,
"""{"uri":"spotify://3","name":"C","favorite":true}""", #"{"uri":"spotify://3","name":"C","favorite":true}"#,
].map { ].map {
try JSONDecoder().decode(MAMediaItem.self, from: Data($0.utf8)) try JSONDecoder().decode(MAMediaItem.self, from: Data($0.utf8))
} }
@@ -96,8 +96,8 @@ struct FavoriteURICollectionTests {
@Test("Returns empty set when no items are favorite") @Test("Returns empty set when no items are favorite")
func emptyWhenNoFavorites() throws { func emptyWhenNoFavorites() throws {
let items: [MAMediaItem] = try [ let items: [MAMediaItem] = try [
"""{"uri":"x://1","name":"A","favorite":false}""", #"{"uri":"x://1","name":"A","favorite":false}"#,
"""{"uri":"x://2","name":"B"}""", #"{"uri":"x://2","name":"B"}"#,
].map { ].map {
try JSONDecoder().decode(MAMediaItem.self, from: Data($0.utf8)) try JSONDecoder().decode(MAMediaItem.self, from: Data($0.utf8))
} }
@@ -108,7 +108,7 @@ struct FavoriteURICollectionTests {
@Test("isFavorite check on MALibraryManager respects favoriteURIs set") @Test("isFavorite check on MALibraryManager respects favoriteURIs set")
func isFavoriteCheck() { func isFavoriteCheck() {
let manager = MALibraryManager() let manager = MALibraryManager(service: nil)
// Initially no favorites // Initially no favorites
#expect(manager.isFavorite(uri: "spotify://track/1") == false) #expect(manager.isFavorite(uri: "spotify://track/1") == false)
} }
@@ -121,22 +121,22 @@ struct LibraryManagerInitialStateTests {
@Test("Artists collection starts empty") @Test("Artists collection starts empty")
func artistsStartEmpty() { func artistsStartEmpty() {
#expect(MALibraryManager().artists.isEmpty) #expect(MALibraryManager(service: nil).artists.isEmpty)
} }
@Test("Albums collection starts empty") @Test("Albums collection starts empty")
func albumsStartEmpty() { func albumsStartEmpty() {
#expect(MALibraryManager().albums.isEmpty) #expect(MALibraryManager(service: nil).albums.isEmpty)
} }
@Test("Playlists collection starts empty") @Test("Playlists collection starts empty")
func playlistsStartEmpty() { func playlistsStartEmpty() {
#expect(MALibraryManager().playlists.isEmpty) #expect(MALibraryManager(service: nil).playlists.isEmpty)
} }
@Test("Loading flags start as false") @Test("Loading flags start as false")
func loadingFlagsStartFalse() { func loadingFlagsStartFalse() {
let m = MALibraryManager() let m = MALibraryManager(service: nil)
#expect(m.isLoadingArtists == false) #expect(m.isLoadingArtists == false)
#expect(m.isLoadingAlbums == false) #expect(m.isLoadingAlbums == false)
#expect(m.isLoadingPlaylists == false) #expect(m.isLoadingPlaylists == false)
@@ -144,6 +144,6 @@ struct LibraryManagerInitialStateTests {
@Test("favoriteURIs starts empty") @Test("favoriteURIs starts empty")
func favoriteURIsStartEmpty() { func favoriteURIsStartEmpty() {
#expect(MALibraryManager().favoriteURIs.isEmpty) #expect(MALibraryManager(service: nil).favoriteURIs.isEmpty)
} }
} }
@@ -32,14 +32,14 @@ struct MAPlayerDecodingTests {
#expect(player.available == true) #expect(player.available == true)
} }
@Test("Defaults powered and available when missing") @Test("powered defaults to false when missing; available defaults to true when missing")
func defaultsMissingBooleans() throws { func defaultsMissingBooleans() throws {
let json = """ let json = """
{"player_id": "x", "name": "Test", "state": "idle"} {"player_id": "x", "name": "Test", "state": "idle"}
""" """
let player = try decode(json) let player = try decode(json)
#expect(player.powered == false) #expect(player.powered == false)
#expect(player.available == false) #expect(player.available == true)
} }
@Test("Unknown state falls back to idle") @Test("Unknown state falls back to idle")
@@ -1,5 +1,6 @@
import Testing import Testing
import Foundation import Foundation
import UIKit
@testable import Mobile_Music_Assistant @testable import Mobile_Music_Assistant
// MARK: - Live Activity Player Selection // MARK: - Live Activity Player Selection
@@ -5,6 +5,7 @@ import Foundation
// MARK: - MAStoreManager Nudge Logic // MARK: - MAStoreManager Nudge Logic
@Suite("MAStoreManager Nudge Logic") @Suite("MAStoreManager Nudge Logic")
@MainActor
struct MAStoreManagerNudgeTests { struct MAStoreManagerNudgeTests {
// Use isolated UserDefaults to avoid polluting real app state // Use isolated UserDefaults to avoid polluting real app state
@@ -148,9 +148,9 @@ struct AnyCodableTests {
@Test("Dictionary decodes keys correctly") @Test("Dictionary decodes keys correctly")
func dictRoundTrip() throws { func dictRoundTrip() throws {
let dict = AnyCodable(["key": AnyCodable("value")]) // Build the AnyCodable from raw JSON to avoid double-wrapping issues
let encoded = try JSONEncoder().encode(dict) let json = #"{"key":"value"}"#
let decoded = try JSONDecoder().decode(AnyCodable.self, from: encoded) let decoded = try JSONDecoder().decode(AnyCodable.self, from: Data(json.utf8))
let map = try decoded.decode(as: [String: AnyCodable].self) let map = try decoded.decode(as: [String: AnyCodable].self)
#expect(try map["key"]?.decode(as: String.self) == "value") #expect(try map["key"]?.decode(as: String.self) == "value")
} }
+49
View File
@@ -29,12 +29,60 @@ enum PurchaseResult: Equatable {
"donateanthology" "donateanthology"
] ]
// MARK: - Nudge / Supporter tracking keys
private static let firstLaunchKey = "ma_firstLaunchDate"
private static let lastKeyDateKey = "ma_lastNudgeOrPurchaseDate"
private static let hasEverSupportedKey = "ma_hasEverSupported"
private let defaults = UserDefaults.standard
private var updateListenerTask: Task<Void, Never>? private var updateListenerTask: Task<Void, Never>?
init() { init() {
// Persist first-launch date on very first run
_ = firstLaunchDate
updateListenerTask = listenForTransactions() updateListenerTask = listenForTransactions()
} }
// MARK: - Supporter state
/// True once the user has completed at least one donation.
var hasEverSupported: Bool {
defaults.bool(forKey: Self.hasEverSupportedKey)
}
// MARK: - Nudge logic
/// The date the app was first launched (written once, then read-only).
var firstLaunchDate: Date {
if let date = defaults.object(forKey: Self.firstLaunchKey) as? Date { return date }
let now = Date()
defaults.set(now, forKey: Self.firstLaunchKey)
return now
}
/// True when the nudge sheet should be presented.
/// Rules:
/// - First show: 3 days after install and never shown/purchased before.
/// - Repeat: 6 months since last nudge dismissal OR last purchase.
var shouldShowNudge: Bool {
let threeDays: TimeInterval = 3 * 24 * 3600
let sixMonths: TimeInterval = 6 * 30 * 24 * 3600
guard Date().timeIntervalSince(firstLaunchDate) >= threeDays else { return false }
guard let last = defaults.object(forKey: Self.lastKeyDateKey) as? Date else { return true }
return Date().timeIntervalSince(last) >= sixMonths
}
/// Call when the nudge sheet is dismissed (regardless of purchase outcome).
func recordNudgeShown() {
defaults.set(Date(), forKey: Self.lastKeyDateKey)
}
/// Records a successful purchase: sets supporter flag and resets the nudge clock.
private func recordPurchase() {
defaults.set(true, forKey: Self.hasEverSupportedKey)
defaults.set(Date(), forKey: Self.lastKeyDateKey)
}
func loadProducts() async { func loadProducts() async {
loadError = nil loadError = nil
do { do {
@@ -60,6 +108,7 @@ enum PurchaseResult: Equatable {
case .success(let verification): case .success(let verification):
let transaction = try checkVerified(verification) let transaction = try checkVerified(verification)
await transaction.finish() await transaction.finish()
recordPurchase()
purchaseResult = .success(product) purchaseResult = .success(product)
case .userCancelled: case .userCancelled:
purchaseResult = .cancelled purchaseResult = .cancelled