Queue mgmt, Podcast support, Favorites section.
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user