Live Activities fix

This commit is contained in:
2026-04-19 16:57:57 +02:00
parent 053c743c41
commit c41b58d837
24 changed files with 1079 additions and 7 deletions
@@ -13,6 +13,7 @@ enum MANavigationDestination: Hashable {
case album(MAAlbum)
case playlist(MAPlaylist)
case podcast(MAPodcast)
case genre(MAGenre)
}
/// ViewModifier to apply all navigation destinations consistently
@@ -31,6 +32,9 @@ struct MANavigationDestinations: ViewModifier {
.navigationDestination(for: MAPodcast.self) { podcast in
PodcastDetailView(podcast: podcast)
}
.navigationDestination(for: MAGenre.self) { genre in
GenreDetailView(genre: genre)
}
.navigationDestination(for: MANavigationDestination.self) { destination in
switch destination {
case .artist(let artist):
@@ -41,6 +45,8 @@ struct MANavigationDestinations: ViewModifier {
PlaylistDetailView(playlist: playlist)
case .podcast(let podcast):
PodcastDetailView(podcast: podcast)
case .genre(let genre):
GenreDetailView(genre: genre)
}
}
}
@@ -1317,6 +1317,10 @@
}
}
},
"Genres" : {
"comment" : "Title of the genres tab in the library view.",
"isCommentAutoGenerated" : true
},
"Group \"%@\" with \"%@\"?" : {
"localizations" : {
"de" : {
@@ -1771,6 +1775,14 @@
"comment" : "A title for a view that shows when a user has no favorite songs.",
"isCommentAutoGenerated" : true
},
"No Genres" : {
"comment" : "A title for a view that shows when a user has no genres in their library.",
"isCommentAutoGenerated" : true
},
"No Items" : {
"comment" : "A label displayed when a genre has no items.",
"isCommentAutoGenerated" : true
},
"No Players Found" : {
"localizations" : {
"de" : {
@@ -1969,6 +1981,10 @@
}
}
},
"Nothing found for this genre" : {
"comment" : "A description displayed when a genre has no items.",
"isCommentAutoGenerated" : true
},
"Now Playing" : {
"extractionState" : "stale",
"localizations" : {
@@ -2014,6 +2030,10 @@
}
}
},
"Other" : {
"comment" : "A section for items that don't fit into the \"Artists\" or \"Albums\" section.",
"isCommentAutoGenerated" : true
},
"Play" : {
"localizations" : {
"de" : {
@@ -2969,6 +2989,10 @@
}
}
},
"Your library doesn't contain any genres yet" : {
"comment" : "A description of the content of the \"No Genres\" view.",
"isCommentAutoGenerated" : true
},
"Your library doesn't contain any playlists yet" : {
"localizations" : {
"de" : {
@@ -469,6 +469,35 @@ struct MAPodcast: Codable, Identifiable, Hashable {
}
}
// MARK: - Genre
struct MAGenre: Codable, Identifiable, Hashable {
let uri: String
let name: String
let metadata: MediaItemMetadata?
var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey {
case uri, name, metadata
}
init(uri: String, name: String, metadata: MediaItemMetadata? = nil) {
self.uri = uri
self.name = name
self.metadata = metadata
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
uri = try c.decode(String.self, forKey: .uri)
name = try c.decode(String.self, forKey: .name)
metadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata)
}
}
// MARK: - Repeat Mode
enum RepeatMode: String, Codable, CaseIterable {
@@ -23,6 +23,7 @@ final class MALibraryManager {
private(set) var albums: [MAAlbum] = []
private(set) var playlists: [MAPlaylist] = []
private(set) var podcasts: [MAPodcast] = []
private(set) var genres: [MAGenre] = []
// Pagination
private var artistsOffset = 0
@@ -39,6 +40,7 @@ final class MALibraryManager {
private(set) var isLoadingAlbums = false
private(set) var isLoadingPlaylists = false
private(set) var isLoadingPodcasts = false
private(set) var isLoadingGenres = false
/// URIs currently marked as favorites source of truth for UI.
/// Populated from decoded model data, then mutated optimistically on toggle.
@@ -152,6 +154,7 @@ final class MALibraryManager {
albums = []
playlists = []
podcasts = []
genres = []
favoriteURIs = []
artistsOffset = 0
albumArtistsOffset = 0
@@ -397,6 +400,27 @@ final class MALibraryManager {
logger.info("Loaded \(loaded.count) podcasts")
}
// MARK: - Genres
func loadGenres(refresh: Bool = false) async throws {
guard !isLoadingGenres else { return }
guard genres.isEmpty || refresh else { return }
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
isLoadingGenres = true
defer { isLoadingGenres = false }
logger.info("Loading genres")
let loaded = try await service.getGenres()
genres = loaded.sorted { $0.name < $1.name }
logger.info("Loaded \(loaded.count) genres")
}
func browseGenre(genreUri: String) async throws -> [MAMediaItem] {
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
return try await service.browseGenre(genreUri: genreUri)
}
func getPodcastEpisodes(podcastUri: String) async throws -> [MAMediaItem] {
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
logger.info("Loading episodes for podcast \(podcastUri)")
@@ -0,0 +1,78 @@
//
// ServicesMALiveActivityManager.swift
// Mobile Music Assistant
//
import ActivityKit
import Foundation
import MobileMAShared
import OSLog
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "LiveActivity")
/// Manages the Now Playing Live Activity lifecycle.
@Observable
final class MALiveActivityManager {
private var currentActivity: Activity<MusicActivityAttributes>?
init() {
// End any orphaned activities left over from previous sessions or format changes.
Task {
for orphan in Activity<MusicActivityAttributes>.activities {
await orphan.end(dismissalPolicy: .immediate)
}
}
}
// MARK: - Public Interface
/// Start or update the Live Activity with current playback state.
func update(trackTitle: String, artistName: String, artworkData: Data?, isPlaying: Bool, playerName: String) {
let state = MusicActivityAttributes.ContentState(
trackTitle: trackTitle,
artistName: artistName,
artworkData: artworkData,
isPlaying: isPlaying,
playerName: playerName
)
if let activity = currentActivity {
Task {
await activity.update(ActivityContent(state: state, staleDate: nil))
logger.debug("Updated live activity: \(trackTitle)")
}
} else {
start(state: state)
}
}
/// End the Live Activity immediately.
func end() {
guard let activity = currentActivity else { return }
currentActivity = nil
Task {
await activity.end(dismissalPolicy: .immediate)
logger.debug("Ended live activity")
}
}
// MARK: - Private
private func start(state: MusicActivityAttributes.ContentState) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
logger.info("Live Activities not enabled on this device")
return
}
do {
let activity = try Activity.request(
attributes: MusicActivityAttributes(),
content: ActivityContent(state: state, staleDate: nil)
)
currentActivity = activity
logger.info("Started live activity: \(state.trackTitle)")
} catch {
logger.error("Failed to start live activity: \(error.localizedDescription)")
}
}
}
@@ -7,6 +7,7 @@
import Foundation
import OSLog
import UIKit
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "PlayerManager")
@@ -21,6 +22,7 @@ final class MAPlayerManager {
private weak var service: MAService?
private var eventTask: Task<Void, Never>?
let liveActivityManager = MALiveActivityManager()
// MARK: - Initialization
@@ -84,6 +86,7 @@ final class MAPlayerManager {
await MainActor.run {
players[player.playerId] = player
logger.debug("Updated player: \(player.name) state=\(player.state.rawValue) item=\(player.currentItem?.name ?? "nil")")
updateLiveActivity()
}
} catch {
logger.error("Failed to decode player_updated event: \(error)")
@@ -98,6 +101,7 @@ final class MAPlayerManager {
await MainActor.run {
playerQueues[queue.queueId] = queue
logger.debug("Updated queue state for player \(queue.queueId), current: \(queue.currentItem?.name ?? "nil")")
updateLiveActivity()
}
return
}
@@ -140,6 +144,119 @@ final class MAPlayerManager {
}
}
// MARK: - 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.
private func updateLiveActivity() {
let playing = players.values
.filter { $0.state == .playing }
.first { $0.currentItem != nil || playerQueues[$0.playerId]?.currentItem != nil }
?? players.values.first { $0.state == .playing }
guard let player = playing else {
liveActivityManager.end()
return
}
guard let item = playerQueues[player.playerId]?.currentItem ?? player.currentItem else {
liveActivityManager.end()
return
}
let media = item.mediaItem
let trackTitle = item.name.isEmpty ? (media?.name ?? "Unknown Track") : item.name
let artistName = media?.artists?.first?.name ?? ""
let isPlaying = player.state == .playing
let playerName = player.name
let imagePath = media?.imageUrl
let imageProvider = media?.imageProvider
logger.debug("updateLiveActivity: track='\(trackTitle)' imagePath=\(imagePath ?? "nil")")
// Update immediately so the live activity appears without waiting for artwork.
liveActivityManager.update(
trackTitle: trackTitle,
artistName: artistName,
artworkData: nil,
isPlaying: isPlaying,
playerName: playerName
)
// Then fetch artwork in background and refresh.
let capturedService = service
Task {
let artworkData = await Self.fetchArtworkData(path: imagePath, provider: imageProvider, service: capturedService)
logger.debug("fetchArtworkData result: \(artworkData != nil ? "\(artworkData!.count) bytes" : "nil")")
guard let artworkData else { return }
liveActivityManager.update(
trackTitle: trackTitle,
artistName: artistName,
artworkData: artworkData,
isPlaying: isPlaying,
playerName: playerName
)
}
}
/// Fetches artwork as small JPEG data for the Live Activity.
/// Checks the app's ImageCache at sizes the app normally loads (512, 64) before
/// falling back to a fresh network download at size 128 with auth.
private static func fetchArtworkData(path: String?, provider: String?, service: MAService?) async -> Data? {
guard let path, !path.isEmpty else {
logger.debug("fetchArtworkData: no image path")
return nil
}
guard let service else {
logger.debug("fetchArtworkData: service is nil")
return nil
}
// Check cache at sizes the app commonly loads
for size in [512, 64] {
guard let url = service.imageProxyURL(path: path, provider: provider, size: size) else { continue }
let key = ImageCache.shared.cacheKey(for: url)
if let img = ImageCache.shared.memoryImage(for: key) {
logger.debug("fetchArtworkData: memory cache hit at size \(size)")
return resizeAndEncode(img)
}
if let img = await Task.detached(priority: .userInitiated) { ImageCache.shared.diskImage(for: key) }.value {
logger.debug("fetchArtworkData: disk cache hit at size \(size)")
return resizeAndEncode(img)
}
}
// Not cached download fresh at compact size with auth header
guard let downloadURL = service.imageProxyURL(path: path, provider: provider, size: 128) else { return nil }
logger.debug("fetchArtworkData: downloading from \(downloadURL)")
var request = URLRequest(url: downloadURL)
if let token = service.authManager.currentToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else {
logger.warning("fetchArtworkData: no auth token available")
}
do {
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
logger.debug("fetchArtworkData: HTTP \(status), \(data.count) bytes")
guard status == 200, let img = UIImage(data: data) else { return nil }
ImageCache.shared.store(img, data: data, for: ImageCache.shared.cacheKey(for: downloadURL))
return resizeAndEncode(img)
} catch {
logger.error("fetchArtworkData: network error: \(error.localizedDescription)")
return nil
}
}
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.
let size = CGSize(width: 40, height: 40)
let renderer = UIGraphicsImageRenderer(size: size)
let scaled = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
return scaled.jpegData(compressionQuality: 0.3)
}
// MARK: - Data Loading
/// Load all players and their queue states
@@ -175,6 +292,7 @@ final class MAPlayerManager {
playerQueues[pid] = queue
}
logger.info("Loaded queue states for \(queueResults.count) players")
updateLiveActivity()
}
}
@@ -351,6 +351,50 @@ final class MAService {
)
}
/// Get genres
func getGenres() async throws -> [MAGenre] {
logger.debug("Fetching genres")
return try await webSocketClient.sendCommand(
"music/genres/library_items",
resultType: [MAGenre].self
)
}
/// Browse items under a genre URI.
/// MA returns provider sub-folders at the first level, so we auto-expand
/// them with a second browse pass to surface actual artists/albums.
func browseGenre(genreUri: String) async throws -> [MAMediaItem] {
logger.debug("Browsing genre \(genreUri)")
let firstLevel = try await webSocketClient.sendCommand(
"music/browse",
args: ["uri": genreUri],
resultType: [MAMediaItem].self
)
// If first level already contains real media items, return them.
let realItems = firstLevel.filter {
guard let t = $0.mediaType else { return false }
return t != .unknown
}
if !realItems.isEmpty { return realItems }
// Otherwise these are sub-folders (providers) browse each one.
var allItems: [MAMediaItem] = []
var seen = Set<String>()
for folder in firstLevel {
let items = (try? await webSocketClient.sendCommand(
"music/browse",
args: ["uri": folder.uri],
resultType: [MAMediaItem].self
)) ?? []
for item in items where seen.insert(item.uri).inserted {
allItems.append(item)
}
}
logger.debug("Genre browse returned \(allItems.count) items after expanding \(firstLevel.count) folders")
return allItems
}
/// Get radio stations
func getRadios() async throws -> [MAMediaItem] {
logger.debug("Fetching radios")
@@ -0,0 +1,201 @@
//
// GenresView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 17.04.26.
//
import SwiftUI
// MARK: - Genres List
struct GenresView: View {
@Environment(MAService.self) private var service
@State private var errorMessage: String?
@State private var showError = false
private var genres: [MAGenre] { service.libraryManager.genres }
private var isLoading: Bool { service.libraryManager.isLoadingGenres }
var body: some View {
List(genres) { genre in
NavigationLink(value: genre) {
HStack(spacing: 12) {
Image(systemName: "guitars")
.font(.title3)
.foregroundStyle(.tint)
.frame(width: 36, height: 36)
.background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
Text(genre.name.capitalized)
.font(.body)
}
.padding(.vertical, 2)
}
}
.listStyle(.plain)
.overlay {
if genres.isEmpty && isLoading {
ProgressView()
} else if genres.isEmpty && !isLoading {
ContentUnavailableView(
"No Genres",
systemImage: "guitars",
description: Text("Your library doesn't contain any genres yet")
)
}
}
.refreshable {
await loadGenres(refresh: true)
}
.task {
if genres.isEmpty {
await loadGenres(refresh: true)
}
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
private func loadGenres(refresh: Bool) async {
do {
try await service.libraryManager.loadGenres(refresh: refresh)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Genre Detail
struct GenreDetailView: View {
@Environment(MAService.self) private var service
let genre: MAGenre
@State private var items: [MAMediaItem] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showError = false
private var artists: [MAMediaItem] { items.filter { $0.mediaType == .artist }.sorted { $0.name < $1.name } }
private var albums: [MAMediaItem] { items.filter { $0.mediaType == .album }.sorted { $0.name < $1.name } }
private var others: [MAMediaItem] { items.filter { $0.mediaType != .artist && $0.mediaType != .album } }
var body: some View {
List {
if !artists.isEmpty {
Section("Artists") {
ForEach(artists) { item in
let artist = MAArtist(uri: item.uri, name: item.name,
imageUrl: item.imageUrl, imageProvider: item.imageProvider)
NavigationLink(value: artist) {
GenreItemRow(item: item, icon: "music.mic")
}
}
}
}
if !albums.isEmpty {
Section("Albums") {
ForEach(albums) { item in
let album = MAAlbum(uri: item.uri, name: item.name,
artists: item.artists,
imageUrl: item.imageUrl, imageProvider: item.imageProvider)
NavigationLink(value: album) {
GenreItemRow(item: item, icon: "square.stack")
}
}
}
}
if !others.isEmpty {
Section("Other") {
ForEach(others) { item in
GenreItemRow(item: item, icon: "music.note")
}
}
}
}
.navigationTitle(genre.name.capitalized)
.navigationBarTitleDisplayMode(.large)
.overlay {
if isLoading {
ProgressView()
} else if items.isEmpty && !isLoading {
ContentUnavailableView(
"No Items",
systemImage: "guitars",
description: Text("Nothing found for this genre")
)
}
}
.task { await loadItems() }
.refreshable { await loadItems() }
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
private func loadItems() async {
isLoading = true
do {
items = try await service.libraryManager.browseGenre(genreUri: genre.uri)
} catch {
errorMessage = error.localizedDescription
showError = true
}
isLoading = false
}
}
// MARK: - Genre Item Row
private struct GenreItemRow: View {
@Environment(MAService.self) private var service
let item: MAMediaItem
let icon: String
var body: some View {
HStack(spacing: 12) {
CachedAsyncImage(url: service.imageProxyURL(
path: item.imageUrl,
provider: item.imageProvider,
size: 64
)) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
.overlay {
Image(systemName: icon)
.foregroundStyle(.secondary)
}
}
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(.body)
.lineLimit(1)
if let artists = item.artists, !artists.isEmpty {
Text(artists.map(\.name).joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.padding(.vertical, 2)
}
}
#Preview {
NavigationStack {
GenresView()
.environment(MAService())
}
}
@@ -9,7 +9,7 @@ import SwiftUI
import UIKit
enum LibraryTab: CaseIterable {
case albumArtists, artists, albums, playlists, podcasts, radio
case albumArtists, artists, albums, playlists, genres, podcasts, radio
var title: LocalizedStringKey {
switch self {
@@ -17,6 +17,7 @@ enum LibraryTab: CaseIterable {
case .artists: return "Artists"
case .albums: return "Albums"
case .playlists: return "Playlists"
case .genres: return "Genres"
case .podcasts: return "Podcasts"
case .radio: return "Radio"
}
@@ -42,6 +43,7 @@ struct LibraryView: View {
case .artists: ArtistsView()
case .albums: AlbumsView()
case .playlists: PlaylistsView()
case .genres: GenresView()
case .podcasts: PodcastsView()
case .radio: RadiosView()
}
@@ -300,7 +300,7 @@ struct PodcastDetailView: View {
private func loadEpisodes() async {
isLoading = true
do {
episodes = try await service.libraryManager.getPodcastEpisodes(podcastUri: podcast.uri)
episodes = try await service.libraryManager.getPodcastEpisodes(podcastUri: podcast.uri).reversed()
isLoading = false
} catch is CancellationError {
return
@@ -13,6 +13,7 @@ private let syncLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Mobi
struct MainTabView: View {
@Environment(MAService.self) private var service
@Environment(\.scenePhase) private var scenePhase
@State private var selectedTab: String = "library"
var body: some View {
@@ -49,6 +50,11 @@ struct MainTabView: View {
.onDisappear {
service.playerManager.stopListening()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task { try? await service.playerManager.loadPlayers() }
}
}
}
}
@@ -93,7 +99,7 @@ struct PlayerListView: View {
var body: some View {
NavigationStack {
Group {
if isLoading {
if isLoading && !hasContent {
ProgressView()
} else if let errorMessage {
ContentUnavailableView(