Merge branch 'main' of ssh://dallasgroot@10.0.0.224:22/Users/dallasgroot/NavidromePlayer/.git

This commit is contained in:
Dallas Groot 2026-03-31 18:30:10 -07:00
commit 4d56ede402
9 changed files with 384 additions and 32 deletions

View file

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 285 KiB

View file

@ -761,7 +761,23 @@ class AudioPlayer: NSObject, ObservableObject {
seek(to: duration * pct)
}
func toggleShuffle() { shuffleEnabled.toggle() }
func toggleShuffle() {
shuffleEnabled.toggle()
if !queue.isEmpty {
if shuffleEnabled {
// Save current song, shuffle the rest
let current = queue[queueIndex]
var rest = queue
rest.remove(at: queueIndex)
rest.shuffle()
queue = [current] + rest
queueIndex = 0
} else {
// Unshuffle restore original order would need storing it.
// For now, keep current order but ensure current song stays in place.
}
}
}
func cycleRepeat() {
switch repeatMode {
@ -792,6 +808,31 @@ class AudioPlayer: NSObject, ObservableObject {
play(song: song)
}
/// Move a song in the queue (for drag reorder)
func moveQueueItem(from source: IndexSet, to destination: Int) {
let oldIndex = queueIndex
let currentId = queue.indices.contains(oldIndex) ? queue[oldIndex].id : nil
queue.move(fromOffsets: source, toOffset: destination)
// Maintain queueIndex pointing to the currently playing song
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
queueIndex = newIdx
}
}
/// Remove a song from the queue
func removeFromQueue(at offsets: IndexSet) {
let currentId = queue.indices.contains(queueIndex) ? queue[queueIndex].id : nil
queue.remove(atOffsets: offsets)
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
queueIndex = newIdx
} else {
queueIndex = min(queueIndex, max(queue.count - 1, 0))
}
}
func playInstantMix(basedOn song: Song) {
Task {
let similar = try? await ServerManager.shared.client.getSimilarSongs2(id: song.id, count: 50)

View file

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 285 KiB

View file

@ -206,6 +206,52 @@ class AlbumCoverStore: ObservableObject {
}
}
// MARK: - Artist Cover Store (custom artist photos)
class ArtistCoverStore: ObservableObject {
static let shared = ArtistCoverStore()
@Published var updateTrigger = UUID()
private let fileManager = FileManager.default
private let coverDir: URL
private init() {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
coverDir = docs.appendingPathComponent("ArtistCovers", isDirectory: true)
try? fileManager.createDirectory(at: coverDir, withIntermediateDirectories: true)
}
private func coverURL(for artistId: String) -> URL {
let safe = artistId.replacingOccurrences(of: "/", with: "_")
return coverDir.appendingPathComponent("\(safe).jpg")
}
func hasCover(for artistId: String) -> Bool {
fileManager.fileExists(atPath: coverURL(for: artistId).path)
}
func loadCover(for artistId: String) -> UIImage? {
let url = coverURL(for: artistId)
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}
func saveCover(_ image: UIImage, for artistId: String) {
let url = coverURL(for: artistId)
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: url, options: .atomic)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for artistId: String) {
let url = coverURL(for: artistId)
try? fileManager.removeItem(at: url)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
// MARK: - Cached Image Loader (ObservableObject per-view)
class CachedImageLoader: ObservableObject {

View file

@ -1,4 +1,5 @@
import SwiftUI
import PhotosUI
struct MyMusicView: View {
@EnvironmentObject var serverManager: ServerManager
@ -14,6 +15,7 @@ struct MyMusicView: View {
@State private var playlists: [Playlist] = []
@State private var artists: [ArtistIndex] = []
@State private var genres: [Genre] = []
@State private var favouriteSongs: [Song] = []
@State private var isLoading = true
@State private var sortMode: SortMode = .recentlyAdded
@State private var showServerPicker = false
@ -34,10 +36,17 @@ struct MyMusicView: View {
@State private var singleEditAlbumId: String?
@State private var showSingleAlbumEditor = false
// Artist cover picker
@State private var showArtistCoverPicker = false
@State private var artistCoverPickerItem: PhotosPickerItem?
@State private var artistCoverTargetId: String?
@ObservedObject private var artistCoverStore = ArtistCoverStore.shared
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
enum SortMode: String, CaseIterable {
case recentlyAdded = "Recently Added"
case favourites = "Favourites"
case artists = "Artists"
case albums = "Albums"
case songs = "Songs"
@ -55,13 +64,19 @@ struct MyMusicView: View {
sortMode = mode
searchText = ""
}) {
Text(mode.rawValue)
.font(.system(size: 13, weight: .medium))
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(sortMode == mode ? accentPink : Color.white.opacity(0.1))
.foregroundColor(sortMode == mode ? .white : .gray)
.cornerRadius(16)
HStack(spacing: 4) {
if mode == .favourites {
Image(systemName: "heart.fill")
.font(.system(size: 11))
}
Text(mode == .favourites ? "Favourites" : mode.rawValue)
.font(.system(size: 13, weight: .medium))
}
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(sortMode == mode ? accentPink : Color.white.opacity(0.1))
.foregroundColor(sortMode == mode ? .white : .gray)
.cornerRadius(16)
}
}
}
@ -79,6 +94,7 @@ struct MyMusicView: View {
VStack(alignment: .leading, spacing: 0) {
switch sortMode {
case .recentlyAdded: recentlyAddedTab
case .favourites: favouritesTab
case .artists: artistsTab
case .albums: albumsTab
case .songs: songsTab
@ -370,6 +386,107 @@ struct MyMusicView: View {
}
}
// MARK: - Favourites Tab
private var favouritesTab: some View {
let filtered = searchText.isEmpty ? favouriteSongs : favouriteSongs.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
}
return VStack(spacing: 0) {
// Header buttons
if !favouriteSongs.isEmpty {
HStack(spacing: 12) {
Button(action: downloadAllFavourites) {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle")
Text("Download All")
}
.font(.system(size: 13, weight: .medium))
.foregroundColor(accentPink)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(accentPink.opacity(0.15))
.cornerRadius(8)
}
if WatchConnectivityManager.shared.isWatchAvailable {
Button(action: sendAllFavouritesToWatch) {
HStack(spacing: 4) {
Image(systemName: "applewatch.and.arrow.forward")
Text("Send to Watch")
}
.font(.system(size: 13, weight: .medium))
.foregroundColor(accentPink)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(accentPink.opacity(0.15))
.cornerRadius(8)
}
}
Spacer()
Text("\(favouriteSongs.count) songs")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
// Song list with swipe-to-delete
LazyVStack(spacing: 0) {
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in
songRow(song: song, index: index, allSongs: filtered)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
removeFavourite(song)
} label: {
Label("Unfavourite", systemImage: "heart.slash")
}
.tint(.red)
}
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 72)
}
if filtered.isEmpty && !isLoading {
emptyState(searchText.isEmpty ? "No favourites yet — star songs to see them here" : "No matches")
}
}
.padding(.top, 4)
}
}
private func removeFavourite(_ song: Song) {
Task {
try? await serverManager.client.unstar(id: song.id)
await MainActor.run {
favouriteSongs.removeAll { $0.id == song.id }
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
}
}
}
private func downloadAllFavourites() {
guard let server = serverManager.activeServer else { return }
for song in favouriteSongs {
if !offlineManager.isSongDownloaded(song.id) {
offlineManager.downloadSong(song, server: server)
}
}
}
private func sendAllFavouritesToWatch() {
for song in favouriteSongs {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}
}
// MARK: - Artists Tab
private var artistsTab: some View {
@ -382,9 +499,17 @@ struct MyMusicView: View {
ForEach(filtered) { artist in
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
HStack(spacing: 14) {
AsyncCoverArt(coverArtId: artist.coverArt, size: 48)
.frame(width: 48, height: 48)
.clipShape(Circle())
if let customImage = artistCoverStore.loadCover(for: artist.id) {
Image(uiImage: customImage)
.resizable()
.scaledToFill()
.frame(width: 48, height: 48)
.clipShape(Circle())
} else {
AsyncCoverArt(coverArtId: artist.coverArt, size: 48)
.frame(width: 48, height: 48)
.clipShape(Circle())
}
Text(artist.name)
.font(.system(size: 16))
@ -405,6 +530,22 @@ struct MyMusicView: View {
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.contextMenu {
Button(action: {
artistCoverTargetId = artist.id
showArtistCoverPicker = true
}) {
Label("Change Artist Cover", systemImage: "photo.on.rectangle")
}
if artistCoverStore.hasCover(for: artist.id) {
Button(role: .destructive, action: {
artistCoverStore.removeCover(for: artist.id)
}) {
Label("Remove Custom Cover", systemImage: "xmark.circle")
}
}
}
Divider()
.background(Color.white.opacity(0.1))
@ -416,6 +557,22 @@ struct MyMusicView: View {
}
}
.padding(.top, 4)
.photosPicker(
isPresented: $showArtistCoverPicker,
selection: $artistCoverPickerItem,
matching: .images
)
.onChange(of: artistCoverPickerItem) { _, newItem in
guard let item = newItem, let targetId = artistCoverTargetId else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
artistCoverStore.saveCover(image, for: targetId)
}
artistCoverPickerItem = nil
artistCoverTargetId = nil
}
}
}
// MARK: - Albums Tab (grid with selection)
@ -775,6 +932,7 @@ struct MyMusicView: View {
if artists.isEmpty, let cached = cache.loadArtists() { artists = cached }
if let cached = cache.load([Genre].self, key: "genres") { genres = cached }
if let cached = cache.load([Album].self, key: "all_albums") { allAlbums = cached }
if favouriteSongs.isEmpty, let cached = cache.load([Song].self, key: "starred_songs") { favouriteSongs = cached }
hadCache = !recentAlbums.isEmpty || !playlists.isEmpty || !artists.isEmpty
@ -789,9 +947,10 @@ struct MyMusicView: View {
async let allAlbumsReq = client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: 0)
async let genresReq = client.getGenres()
async let songsReq = client.search3(query: "", songCount: 100, songOffset: 0)
async let starredReq = client.getStarred2()
let (albums, playlistList, artistList, albumsFull, genreList, searchResult) = try await (
albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq
let (albums, playlistList, artistList, albumsFull, genreList, searchResult, starred) = try await (
albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq, starredReq
)
cache.cacheAlbums(albums)
@ -799,6 +958,9 @@ struct MyMusicView: View {
cache.cacheArtists(artistList)
cache.save(genreList, key: "genres")
cache.save(albumsFull, key: "all_albums")
if let starredSongs = starred?.song {
cache.save(starredSongs, key: "starred_songs")
}
await MainActor.run {
self.recentAlbums = albums
@ -811,6 +973,9 @@ struct MyMusicView: View {
self.allSongs = searchResult?.song ?? []
self.songOffset = 100
self.hasMoreSongs = (searchResult?.song?.count ?? 0) >= 100
if let starredSongs = starred?.song {
self.favouriteSongs = starredSongs
}
self.isLoading = false
}
} catch {

View file

@ -1104,34 +1104,101 @@ struct AddToPlaylistSheet: View {
// MARK: - Queue View
struct QueueView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@State private var editMode: EditMode = .active
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
List {
ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in
HStack(spacing: 12) {
if index == audioPlayer.queueIndex {
Image(systemName: "waveform").font(.system(size: 12))
.foregroundColor(accentPink).frame(width: 20)
} else {
Text("\(index + 1)").font(.system(size: 13))
.foregroundColor(.gray).frame(width: 20)
// Currently playing header
if let song = audioPlayer.currentSong {
Section {
HStack(spacing: 12) {
Image(systemName: "waveform")
.font(.system(size: 14))
.foregroundColor(accentPink)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15, weight: .medium))
.foregroundColor(accentPink)
.lineLimit(1)
Text(song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(.gray)
}
VStack(alignment: .leading, spacing: 2) {
Text(song.title).font(.system(size: 15))
.foregroundColor(index == audioPlayer.queueIndex ? accentPink : .white)
Text(song.artist ?? "").font(.system(size: 12)).foregroundColor(.gray)
}
Spacer()
Text(song.durationFormatted).font(.system(size: 13)).foregroundColor(.gray)
} header: {
Text("Now Playing")
}
}
// Up next
let upNext = Array(audioPlayer.queue.enumerated()).filter { $0.offset != audioPlayer.queueIndex }
Section {
ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in
if index != audioPlayer.queueIndex {
HStack(spacing: 12) {
Text("\(index + 1)")
.font(.system(size: 13, design: .monospaced))
.foregroundColor(.gray)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(.white)
.lineLimit(1)
Text(song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(.gray)
}
.contentShape(Rectangle())
.onTapGesture {
audioPlayer.play(song: song, at: index)
}
}
}
.onMove(perform: audioPlayer.moveQueueItem)
.onDelete(perform: audioPlayer.removeFromQueue)
} header: {
HStack {
Text("Up Next")
Spacer()
Text("\(audioPlayer.queue.count) songs")
.font(.caption)
.foregroundColor(.gray)
}
.contentShape(Rectangle())
.onTapGesture { audioPlayer.play(song: song, at: index) }
}
}
.navigationTitle("Up Next")
.environment(\.editMode, $editMode)
.navigationTitle("Queue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 16) {
Button(action: { audioPlayer.toggleShuffle() }) {
Image(systemName: "shuffle")
.foregroundColor(audioPlayer.shuffleEnabled ? accentPink : .gray)
}
Button(action: { audioPlayer.cycleRepeat() }) {
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
.foregroundColor(audioPlayer.repeatMode != .off ? accentPink : .gray)
}
}
}
}
}
}
}

View file

@ -623,6 +623,39 @@ struct VisualizerSettingsView: View {
Text("Viscosity is the most important slider — it controls how quickly the wave reacts. 0.150.25 = heavy, slow liquid (cinematic). 0.5 = responsive. 0.8+ = snappy EQ meter.\n\nSensitivity multiplies the incoming audio. Push up if the wave looks flat.\n\nBase multiplier is a second gain stage for FFT data. Higher values reveal quiet passages.\n\nFrequency cutoff limits how many FFT bins are used. At 80 (default), mostly bass/mids. At 150+, treble detail like hi-hats shows up.\n\nReal Audio Analysis uses FFT on downloaded songs. Streams always use simulated animation.\n\nFPS drops to 24 automatically in Low Power Mode.")
}
// Smart DJ Crossfade
Section {
let crossfade = SmartCrossfadeManager.shared
Toggle("Smart Crossfade", isOn: Binding(
get: { crossfade.isEnabled },
set: { crossfade.isEnabled = $0 }
)).tint(pink)
if crossfade.isEnabled {
HStack {
Text("Duration")
Spacer()
Text("\(String(format: "%.1f", crossfade.crossfadeDuration))s")
.foregroundColor(.gray)
}
Slider(
value: Binding(
get: { crossfade.crossfadeDuration },
set: { crossfade.crossfadeDuration = $0 }
),
in: 1...10,
step: 0.5
).tint(pink)
Toggle("Skip Silence", isOn: Binding(
get: { crossfade.skipSilence },
set: { crossfade.skipSilence = $0 }
)).tint(pink)
}
} header: { Text("CROSSFADE") } footer: {
Text("Smart Crossfade uses Companion API profiles to blend songs seamlessly. Duration controls how long the fade lasts. Skip Silence jumps past leading/trailing silence.")
}
// Presets
Section {
Button(action: applyDeepOcean) {

View file

@ -14,7 +14,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
ZIP_FILE="NavidromePlayer.zip"
ICON_FILE="1024.png"
ICON_FILE="AppIcon.png"
# Check zip exists
if [ ! -f "$ZIP_FILE" ]; then

View file

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 285 KiB