// // 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() 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 { 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()) }