my music view fix
This commit is contained in:
parent
228b186569
commit
55820fdb38
2 changed files with 136 additions and 20 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue