Queue mgmt, Podcast support, Favorites section.

This commit is contained in:
2026-04-08 10:26:50 +02:00
parent f55b7e478b
commit d7e7bef83f
14 changed files with 1177 additions and 60 deletions
@@ -0,0 +1,324 @@
//
// FavoritesView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 08.04.26.
//
import SwiftUI
import UIKit
enum FavoritesTab: String, CaseIterable {
case artists = "Artists"
case albums = "Albums"
case radios = "Radios"
case podcasts = "Podcasts"
}
struct FavoritesView: View {
@Environment(MAService.self) private var service
@State private var selectedTab: FavoritesTab = .artists
init() {
UISegmentedControl.appearance().setTitleTextAttributes(
[.font: UIFont.systemFont(ofSize: 11, weight: .medium)],
for: .normal
)
}
var body: some View {
NavigationStack {
Group {
switch selectedTab {
case .artists: FavoriteArtistsSection()
case .albums: FavoriteAlbumsSection()
case .radios: FavoriteRadiosSection()
case .podcasts: FavoritePodcastsSection()
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Picker("Favorites", selection: $selectedTab) {
ForEach(FavoritesTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 360)
}
}
.withMANavigation()
}
}
}
// MARK: - Favorite Artists
private struct FavoriteArtistsSection: View {
@Environment(MAService.self) private var service
@State private var scrollPosition: String?
private var favoriteArtists: [MAArtist] {
// Merge artists + albumArtists, deduplicate by URI, filter favorites
var seen = Set<String>()
let all = service.libraryManager.artists + service.libraryManager.albumArtists
return all.filter { artist in
guard !seen.contains(artist.uri) else { return false }
seen.insert(artist.uri)
return service.libraryManager.isFavorite(uri: artist.uri)
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
private var artistsByLetter: [(String, [MAArtist])] {
let grouped = Dictionary(grouping: favoriteArtists) { artist -> String in
let first = artist.name.prefix(1).uppercased()
return first.first?.isLetter == true ? String(first) : "#"
}
return grouped.sorted {
if $0.key == "#" { return false }
if $1.key == "#" { return true }
return $0.key < $1.key
}
}
private var availableLetters: [String] {
artistsByLetter.map { $0.0 }
}
private let allLetters: [String] = (65...90).map { String(UnicodeScalar($0)!) } + ["#"]
private let columns = [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
]
var body: some View {
Group {
if favoriteArtists.isEmpty {
ContentUnavailableView(
"No Favorite Artists",
systemImage: "heart.slash",
description: Text("Tap the heart icon on any artist to add them here.")
)
} else {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(artistsByLetter, id: \.0) { letter, letterArtists in
Text(letter)
.font(.headline)
.fontWeight(.bold)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 4)
.id("section-\(letter)")
LazyVGrid(columns: columns, spacing: 8) {
ForEach(letterArtists) { artist in
NavigationLink(value: artist) {
ArtistGridItem(artist: artist)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
.padding(.trailing, 28)
}
.scrollPosition(id: $scrollPosition)
.overlay(alignment: .trailing) {
AlphabetIndexView(
letters: allLetters,
itemHeight: 17,
onSelect: { letter in
let target = "section-\(letter)"
scrollPosition = target
proxy.scrollTo(target, anchor: .top)
}
)
.padding(.vertical, 8)
.padding(.trailing, 2)
}
}
}
}
}
}
// MARK: - Favorite Albums
private struct FavoriteAlbumsSection: View {
@Environment(MAService.self) private var service
private var favoriteAlbums: [MAAlbum] {
service.libraryManager.albums
.filter { service.libraryManager.isFavorite(uri: $0.uri) }
.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
private let columns = [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
]
var body: some View {
Group {
if favoriteAlbums.isEmpty {
ContentUnavailableView(
"No Favorite Albums",
systemImage: "heart.slash",
description: Text("Tap the heart icon on any album to add it here.")
)
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(favoriteAlbums) { album in
NavigationLink(value: album) {
AlbumGridItem(album: album)
}
.buttonStyle(.plain)
}
}
.padding()
}
}
}
}
}
// MARK: - Favorite Radios
private struct FavoriteRadiosSection: View {
@Environment(MAService.self) private var service
@State private var allRadios: [MAMediaItem] = []
@State private var isLoading = true
@State private var errorMessage: String?
@State private var showError = false
@State private var selectedRadio: MAMediaItem?
private var favoriteRadios: [MAMediaItem] {
allRadios.filter { service.libraryManager.isFavorite(uri: $0.uri) }
}
private var players: [MAPlayer] {
Array(service.playerManager.players.values)
.filter { $0.available }
.sorted { $0.name < $1.name }
}
var body: some View {
Group {
if isLoading {
ProgressView()
} else if favoriteRadios.isEmpty {
ContentUnavailableView(
"No Favorite Radios",
systemImage: "heart.slash",
description: Text("Tap the heart icon on any radio station to add it here.")
)
} else {
List(favoriteRadios) { radio in
Button {
handleRadioTap(radio)
} label: {
RadioRow(radio: radio)
}
.buttonStyle(.plain)
.listRowSeparator(.visible)
}
.listStyle(.plain)
}
}
.task {
await loadRadios()
}
.refreshable {
await loadRadios()
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage { Text(errorMessage) }
}
.sheet(item: $selectedRadio) { radio in
EnhancedPlayerPickerView(
players: players,
onSelect: { player in
Task { await playRadio(radio, on: player) }
}
)
}
}
private func handleRadioTap(_ radio: MAMediaItem) {
if players.count == 1 {
Task { await playRadio(radio, on: players.first!) }
} else {
selectedRadio = radio
}
}
private func loadRadios() async {
isLoading = true
errorMessage = nil
do {
allRadios = try await service.getRadios()
} catch {
errorMessage = error.localizedDescription
showError = true
}
isLoading = false
}
private func playRadio(_ radio: MAMediaItem, on player: MAPlayer) async {
do {
try await service.playerManager.playMedia(playerId: player.playerId, uri: radio.uri)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Favorite Podcasts
private struct FavoritePodcastsSection: View {
@Environment(MAService.self) private var service
private var favoritePodcasts: [MAPodcast] {
service.libraryManager.podcasts
.filter { service.libraryManager.isFavorite(uri: $0.uri) }
.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
var body: some View {
Group {
if favoritePodcasts.isEmpty {
ContentUnavailableView(
"No Favorite Podcasts",
systemImage: "heart.slash",
description: Text("Tap the heart icon on any podcast to add it here.")
)
} else {
List(favoritePodcasts) { podcast in
NavigationLink(value: podcast) {
PodcastRow(podcast: podcast)
}
}
.listStyle(.plain)
}
}
}
}
#Preview {
FavoritesView()
.environment(MAService())
}