Live Activities fix
This commit is contained in:
@@ -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 ≈ 400–700 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(
|
||||
|
||||
Reference in New Issue
Block a user