From 55820fdb385e40aeddb491896c17326b8140dbce Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 10 Apr 2026 20:44:23 -0700 Subject: [PATCH] my music view fix --- iOS/Views/Library/MyMusicView.swift | 111 ++++++++++++++++++---- iOS/Views/NowPlaying/NowPlayingView.swift | 45 ++++++++- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index 692873e..ae6c97d 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -12,6 +12,8 @@ struct MyMusicView: View { @State private var recentAlbums: [Album] = [] @State private var allAlbums: [Album] = [] @State private var allSongs: [Song] = [] + @State private var songsLoaded = false + @State private var isLoadingSongs = false @State private var playlists: [Playlist] = [] @State private var artists: [ArtistIndex] = [] @State private var genres: [Genre] = [] @@ -735,28 +737,104 @@ struct MyMusicView: View { } // MARK: - Songs Tab (list) - + + /// First letter of a song title for section headers, collapsing digits/symbols to "#" + private func sectionKey(for song: Song) -> String { + let first = song.title.first ?? "#" + return first.isLetter ? String(first).uppercased() : "#" + } + private var songsTab: some View { - let filtered = searchText.isEmpty ? allSongs : allSongs.filter { - $0.title.localizedCaseInsensitiveContains(searchText) || - ($0.artist ?? "").localizedCaseInsensitiveContains(searchText) || - ($0.album ?? "").localizedCaseInsensitiveContains(searchText) - } - - return LazyVStack(spacing: 0) { - ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in - songRow(song: song, index: index, allSongs: filtered) - - Divider() - .background(Color.white.opacity(0.08)) - .padding(.leading, 72) + let filtered: [Song] = { + let base = allSongs + guard !searchText.isEmpty else { return base } + return base.filter { + $0.title.localizedCaseInsensitiveContains(searchText) || + ($0.artist ?? "").localizedCaseInsensitiveContains(searchText) || + ($0.album ?? "").localizedCaseInsensitiveContains(searchText) } - - if filtered.isEmpty && !isLoading { + }() + + // Group into alphabetical sections + let grouped: [(String, [Song])] = { + var dict: [String: [Song]] = [:] + for song in filtered { + let key = sectionKey(for: song) + dict[key, default: []].append(song) + } + // Letters A-Z then # for digits/symbols + let letters = dict.keys.filter { $0 != "#" }.sorted() + let keys = dict["#"] != nil ? letters + ["#"] : letters + return keys.compactMap { k in dict[k].map { (k, $0) } } + }() + + return LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { + if isLoadingSongs { + ProgressView() + .tint(accentPink) + .frame(maxWidth: .infinity) + .padding(.top, 60) + } else if filtered.isEmpty { emptyState("No songs found") + } else { + ForEach(grouped, id: \.0) { (letter, songs) in + Section { + ForEach(Array(songs.enumerated()), id: \.element.id) { idx, song in + songRow(song: song, index: idx, allSongs: Array(filtered)) + Divider() + .background(Color.white.opacity(0.08)) + .padding(.leading, 72) + } + } header: { + Text(letter) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(Color.black.opacity(0.85)) + } + } } } .padding(.top, 4) + .task { + guard !songsLoaded, !isLoadingSongs else { return } + await loadAllSongs() + } + } + + private func loadAllSongs() async { + // Cache hit → instant display + if let cached = LibraryCache.shared.load([Song].self, key: "all_songs_sorted") { + allSongs = cached + songsLoaded = true + return + } + isLoadingSongs = true + do { + var collected: [Song] = [] + var offset = 0 + while true { + let result = try await serverManager.client.search3( + query: "", artistCount: 0, artistOffset: 0, + albumCount: 0, albumOffset: 0, + songCount: 500, songOffset: offset) + let page = result?.song ?? [] + collected.append(contentsOf: page) + if page.count < 500 { break } + offset += 500 + } + let sorted = collected.sorted { + $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + LibraryCache.shared.save(sorted, key: "all_songs_sorted") + allSongs = sorted + songsLoaded = true + isLoadingSongs = false + } catch { + isLoadingSongs = false + } } @ViewBuilder @@ -1121,7 +1199,6 @@ struct MyMusicView: View { self.artists = artistList self.allAlbums = albumsFull self.genres = genreList - self.allSongs = [] // Songs loaded on-demand via search if let starredSongs = starred?.song { self.favouriteSongs = starredSongs } diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index e316ebe..7e6dcec 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -21,7 +21,8 @@ class AlbumColorExtractor: ObservableObject { private var lastCoverArtId: String? private var coverStoreObserver: AnyCancellable? - + private var radioCoverStoreObserver: AnyCancellable? + private init() { // Re-extract colors when custom album cover changes coverStoreObserver = AlbumCoverStore.shared.$updateTrigger @@ -31,6 +32,28 @@ class AlbumColorExtractor: ObservableObject { self.lastCoverArtId = nil // force re-extract self.extract(from: id) } + // Re-extract when a radio cover changes + radioCoverStoreObserver = RadioCoverStore.shared.$updateTrigger + .dropFirst() + .sink { [weak self] _ in + guard let self = self else { return } + self.lastCoverArtId = nil // force re-extract on next call + } + } + + /// Extract colors directly from a UIImage — used for radio stations whose + /// custom cover lives in RadioCoverStore rather than AlbumCoverStore. + func extractDirect(from image: UIImage, id: String) { + guard id != lastCoverArtId else { return } + lastCoverArtId = id + let colors = Self.dominantColors(from: image) + withAnimation(.easeInOut(duration: 1.0)) { + self.primaryColor = colors.0 + self.secondaryColor = colors.1 + self.currentImage = image + self.isLoaded = true + } + self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0) } func extract(from coverArtId: String?) { @@ -230,6 +253,18 @@ struct NowPlayingView: View { private var isRadio: Bool { audioPlayer.isRadioStream || audioPlayer.currentSong?.album == "Radio" } + + /// Routes color extraction to RadioCoverStore for radio stations with custom covers, + /// otherwise falls through to the standard AlbumColorExtractor path. + private func extractAlbumColors() { + if isRadio, + let song = audioPlayer.currentSong, + let customImg = RadioCoverStore.shared.loadCover(for: song.id) { + albumColors.extractDirect(from: customImg, id: song.id) + } else { + albumColors.extract(from: audioPlayer.currentSong?.coverArt) + } + } var body: some View { GeometryReader { screenGeo in @@ -309,11 +344,15 @@ struct NowPlayingView: View { .onAppear { dragOffset = 0 isStarred = audioPlayer.currentSong?.starred != nil - albumColors.extract(from: audioPlayer.currentSong?.coverArt) + extractAlbumColors() } .onChange(of: audioPlayer.currentSong?.id) { _, _ in isStarred = audioPlayer.currentSong?.starred != nil - albumColors.extract(from: audioPlayer.currentSong?.coverArt) + extractAlbumColors() + } + .onChange(of: RadioCoverStore.shared.updateTrigger) { _, _ in + // Radio cover changed — re-extract if a radio station is playing + if isRadio { extractAlbumColors() } } .onChange(of: audioPlayer.currentSong?.starred) { _, newVal in isStarred = newVal != nil