512 lines
16 KiB
Swift
512 lines
16 KiB
Swift
//
|
|
// FavoritesView.swift
|
|
// Mobile Music Assistant
|
|
//
|
|
// Created by Sven Hanold on 08.04.26.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
enum FavoritesTab: CaseIterable {
|
|
case artists, albums, songs, radios, podcasts
|
|
|
|
var title: LocalizedStringKey {
|
|
switch self {
|
|
case .artists: return "Artists"
|
|
case .albums: return "Albums"
|
|
case .songs: return "Songs"
|
|
case .radios: return "Radios"
|
|
case .podcasts: return "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 .songs: FavoriteSongsSection()
|
|
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.title).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?
|
|
@State private var errorMessage: String?
|
|
@State private var showError = false
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.refreshable {
|
|
await reloadArtists()
|
|
}
|
|
.alert("Error", isPresented: $showError) {
|
|
Button("OK", role: .cancel) { }
|
|
} message: {
|
|
if let errorMessage { Text(errorMessage) }
|
|
}
|
|
}
|
|
|
|
private func reloadArtists() async {
|
|
do {
|
|
try await service.libraryManager.loadArtists(refresh: true)
|
|
try await service.libraryManager.loadAlbumArtists(refresh: true)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Favorite Albums
|
|
|
|
private struct FavoriteAlbumsSection: View {
|
|
@Environment(MAService.self) private var service
|
|
@State private var errorMessage: String?
|
|
@State private var showError = false
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
.refreshable {
|
|
await reloadAlbums()
|
|
}
|
|
.alert("Error", isPresented: $showError) {
|
|
Button("OK", role: .cancel) { }
|
|
} message: {
|
|
if let errorMessage { Text(errorMessage) }
|
|
}
|
|
}
|
|
|
|
private func reloadAlbums() async {
|
|
do {
|
|
try await service.libraryManager.loadAlbums(refresh: true)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Favorite Songs
|
|
|
|
private struct FavoriteSongsSection: View {
|
|
@Environment(MAService.self) private var service
|
|
|
|
@State private var tracks: [MAMediaItem] = []
|
|
@State private var isLoading = true
|
|
@State private var errorMessage: String?
|
|
@State private var showError = false
|
|
@State private var selectedTrackIndex: Int?
|
|
@State private var showPlayerPicker = false
|
|
|
|
private var players: [MAPlayer] {
|
|
Array(service.playerManager.players.values)
|
|
.filter { $0.available }
|
|
.sorted { $0.name < $1.name }
|
|
}
|
|
|
|
private var nowPlayingURIs: Set<String> {
|
|
Set(service.playerManager.playerQueues.values.compactMap {
|
|
$0.currentItem?.mediaItem?.uri
|
|
})
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isLoading {
|
|
ProgressView()
|
|
} else if tracks.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Favorite Songs",
|
|
systemImage: "heart.slash",
|
|
description: Text("Tap the heart icon on any song to add it here.")
|
|
)
|
|
} else {
|
|
List {
|
|
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
|
|
Button {
|
|
handleTrackTap(index: index)
|
|
} label: {
|
|
TrackRow(
|
|
track: track,
|
|
trackNumber: index + 1,
|
|
isPlaying: nowPlayingURIs.contains(track.uri)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowSeparator(.visible)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
.task {
|
|
await loadTracks()
|
|
}
|
|
.refreshable {
|
|
await loadTracks()
|
|
}
|
|
.alert("Error", isPresented: $showError) {
|
|
Button("OK", role: .cancel) { }
|
|
} message: {
|
|
if let errorMessage { Text(errorMessage) }
|
|
}
|
|
.sheet(isPresented: $showPlayerPicker) {
|
|
EnhancedPlayerPickerView(
|
|
players: players,
|
|
showNowPlayingOnSelect: true,
|
|
onSelect: { player in
|
|
if let index = selectedTrackIndex {
|
|
Task { await playFrom(index: index, on: player) }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private func handleTrackTap(index: Int) {
|
|
if players.count == 1 {
|
|
Task { await playFrom(index: index, on: players.first!) }
|
|
} else {
|
|
selectedTrackIndex = index
|
|
showPlayerPicker = true
|
|
}
|
|
}
|
|
|
|
private func loadTracks() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
do {
|
|
// Fetch all favorited tracks from the server directly
|
|
var allTracks: [MAMediaItem] = []
|
|
var offset = 0
|
|
let pageSize = 50
|
|
var hasMore = true
|
|
while hasMore {
|
|
let page = try await service.getTracks(favorite: true, limit: pageSize, offset: offset)
|
|
allTracks.append(contentsOf: page)
|
|
offset += page.count
|
|
hasMore = page.count >= pageSize
|
|
}
|
|
tracks = allTracks.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
private func playFrom(index: Int, on player: MAPlayer) async {
|
|
let uris = tracks[index...].map { $0.uri }
|
|
do {
|
|
try await service.playerManager.playMedia(playerId: player.playerId, uris: uris)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
showNowPlayingOnSelect: true,
|
|
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
|
|
@State private var errorMessage: String?
|
|
@State private var showError = false
|
|
|
|
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)
|
|
}
|
|
}
|
|
.refreshable {
|
|
await reloadPodcasts()
|
|
}
|
|
.alert("Error", isPresented: $showError) {
|
|
Button("OK", role: .cancel) { }
|
|
} message: {
|
|
if let errorMessage { Text(errorMessage) }
|
|
}
|
|
}
|
|
|
|
private func reloadPodcasts() async {
|
|
do {
|
|
try await service.libraryManager.loadPodcasts(refresh: true)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
FavoritesView()
|
|
.environment(MAService())
|
|
}
|