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
+66 -1
View File
@@ -187,6 +187,10 @@
"comment" : "A separator between the year and the number of tracks in an album.",
"isCommentAutoGenerated" : true
},
"★" : {
"comment" : "A star emoji.",
"isCommentAutoGenerated" : true
},
"1. Open Music Assistant in a browser" : {
"localizations" : {
"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" : {
"localizations" : {
"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" : {
"localizations" : {
"de" : {
@@ -1546,10 +1576,18 @@
}
}
},
"Maybe Later" : {
"comment" : "A button that dismisses the support nudge.",
"isCommentAutoGenerated" : true
},
"Mobile MA" : {
"comment" : "The name of the app.",
"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" : {
"extractionState" : "stale",
"localizations" : {
@@ -1986,7 +2024,6 @@
"isCommentAutoGenerated" : true
},
"Now Playing" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"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" : {
"localizations" : {
"de" : {
@@ -2654,6 +2713,9 @@
}
}
}
},
"Supporter" : {
},
"Synced to: %@" : {
"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." : {
"localizations" : {
@@ -149,6 +149,11 @@ final class MAPlayerManager {
/// Finds the best currently-playing player and pushes its state to the Live Activity.
/// Spawns a Task to fetch artwork with auth before updating.
private func updateLiveActivity() {
guard UserDefaults.standard.object(forKey: "liveActivityEnabled") as? Bool ?? true else {
liveActivityManager.end()
return
}
let playing = players.values
.filter { $0.state == .playing }
.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? {
// 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.
@@ -55,6 +55,7 @@ final class MAWebSocketClient {
// MARK: - Properties
private(set) var connectionState: ConnectionState = .disconnected
var isConnected: Bool { connectionState == .connected }
private var webSocketTask: URLSessionWebSocketTask?
private let session: URLSession
@@ -13,6 +13,7 @@ private let syncLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Mobi
struct MainTabView: View {
@Environment(MAService.self) private var service
@Environment(MAStoreManager.self) private var storeManager
@Environment(\.scenePhase) private var scenePhase
@State private var selectedTab: String = "library"
@@ -40,6 +41,7 @@ struct MainTabView: View {
Tab("Settings", systemImage: "gear", value: "settings") {
SettingsView()
}
.badge(storeManager.hasEverSupported ? Text("") : nil)
}
.withToast()
.task {
@@ -597,6 +599,7 @@ struct SettingsView: View {
@Environment(MAStoreManager.self) private var storeManager
@State private var showThankYou = false
@State private var showClearCacheConfirm = false
@AppStorage("liveActivityEnabled") private var liveActivityEnabled = true
var body: some View {
NavigationStack {
@@ -679,6 +682,22 @@ struct SettingsView: View {
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
Section {
Button(role: .destructive) {
@@ -698,6 +717,20 @@ struct SettingsView: View {
// Support Development 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 {
Label(loadError, systemImage: "exclamationmark.triangle")
.font(.caption)
@@ -10,10 +10,12 @@ import UIKit
struct RootView: View {
@Environment(MAService.self) private var service
@Environment(MAStoreManager.self) private var storeManager
@State private var isInitializing = true
@State private var loadingProgress: Double = 0.0
@State private var loadingPhase: String = "Starting…"
@State private var showNudge = false
var body: some View {
Group {
@@ -32,9 +34,26 @@ struct RootView: View {
.animation(.easeInOut(duration: 0.4), value: service.isConnected)
.applyTheme()
.applyLocale()
.sheet(isPresented: $showNudge, onDismiss: {
storeManager.recordNudgeShown()
}) {
SupportNudgeView(isPresented: $showNudge)
}
.task {
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