Update from NavidromePlayer.zip (2026-04-04 16:12)

This commit is contained in:
Dallas Groot 2026-04-04 16:12:28 -07:00
parent a8d669824e
commit 7094ffab16
15 changed files with 165 additions and 75 deletions

View file

@ -136,14 +136,14 @@ class AudioPlayer: NSObject, ObservableObject {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, options: [])
} catch {
print("Audio session category failed: \(error)")
alog("Audio session category failed: \(error)")
}
#elseif os(watchOS)
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
} catch {
print("watchOS audio session category failed: \(error)")
alog("watchOS audio session category failed: \(error)")
}
#endif
}
@ -152,12 +152,19 @@ class AudioPlayer: NSObject, ObservableObject {
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Audio session activation failed: \(error)")
alog("Audio session activation failed: \(error)")
}
}
// MARK: - Playback Controls
/// Jump to a song already in the queue by index without replacing the queue.
/// Used by QueueView tap keeps the existing queue intact.
func play(song: Song, at index: Int) {
queueIndex = index
play(song: song)
}
func play(song: Song, fromQueue: [Song]? = nil, at index: Int = 0, playlistId: String? = nil, playlistName: String? = nil) {
if let q = fromQueue {
queue = shuffleEnabled ? q.shuffled() : q
@ -401,7 +408,7 @@ class AudioPlayer: NSObject, ObservableObject {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Audio session reset failed: \(error)")
alog("Audio session reset failed: \(error)")
}
isUsingRealFFT = false
@ -906,9 +913,18 @@ class AudioPlayer: NSObject, ObservableObject {
}
@objc private func playerDidFinish() {
// When crossfade is active, needsNextTrack() already scrobbled the outgoing song.
// playerDidFinish fires as a safety-net fallback (boundary observer miss) skip
// scrobble here to avoid double-counting.
#if os(iOS)
if !isUsingCrossfade, let song = currentSong {
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
}
#else
if let song = currentSong {
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
}
#endif
if repeatMode == .one {
seek(to: 0)

View file

@ -1,17 +1,34 @@
import Foundation
import Network
import Combine
/// Caches library data (albums, artists, playlists, songs) to disk for offline browsing.
/// Also monitors network connectivity.
/// Also monitors network connectivity and server reachability.
class LibraryCache: ObservableObject {
static let shared = LibraryCache()
@Published var isOffline = false
/// True when a non-downloaded song can be streamed.
/// Combines NWPathMonitor (network) with ServerManager.connectionState (server).
var isServerAvailable: Bool {
guard !isOffline else { return false }
switch ServerManager.shared.connectionState {
case .connected: return true
case .connecting: return true
// If we've never connected yet (cold launch), treat as available optimistically
// to avoid songs flashing grey while the initial ping is in-flight.
// Once we get .error or explicitly .disconnected, grey out immediately.
case .disconnected: return !ServerManager.shared.hasEverConnected
case .error: return false
}
}
private let fileManager = FileManager.default
private let cacheDir: URL
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "com.navidromeplayer.network")
private var serverStateCancellable: AnyCancellable?
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
@ -36,6 +53,14 @@ class LibraryCache: ObservableObject {
}
}
monitor.start(queue: monitorQueue)
// Re-publish when server connection state changes so views observing
// LibraryCache also react to server becoming reachable/unreachable
serverStateCancellable = ServerManager.shared.$connectionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
deinit {

View file

@ -165,9 +165,9 @@ class OfflineManager: ObservableObject {
do {
let frames = try await OfflineAudioAnalyzer.shared.analyze(url: localURL)
try await storage.saveCache(frames: frames, for: song.id)
print("[Vis] Auto-analyzed: \(song.title)")
DebugLogger.shared.log("Auto-analyzed: \(song.title)", category: "Vis")
} catch {
print("[Vis] Auto-analyze failed for \(song.title): \(error.localizedDescription)")
DebugLogger.shared.log("Auto-analyze failed for \(song.title): \(error.localizedDescription)", category: "Vis")
}
}
}

View file

@ -12,6 +12,9 @@ class ServerManager: ObservableObject {
@Published var activeServer: ServerConfig?
@Published var connectionState: ConnectionState = .disconnected
@Published var lastFailoverMessage: String?
/// Set to true the first time a server ping succeeds. Used by LibraryCache.isServerAvailable
/// to avoid greying out songs on first launch while the initial connection is in-flight.
private(set) var hasEverConnected: Bool = false
private let storageKey = "navidrome_servers"
private let activeServerKey = "navidrome_active_server"
@ -132,6 +135,7 @@ class ServerManager: ObservableObject {
await MainActor.run {
self.activeServer = server
self.connectionState = .connected
self.hasEverConnected = true
self.client.isAuthenticated = true
self.saveActiveServer()
}

View file

@ -322,7 +322,7 @@ class WatchConnectivityManager: NSObject, ObservableObject {
self?.wcLog("Watch deleted: \(songId)")
}
}, errorHandler: { error in
print("Delete song failed: \(error)")
wlog("Delete song failed: \(error)")
})
}
@ -401,7 +401,7 @@ class WatchConnectivityManager: NSObject, ObservableObject {
"songCount": songs.count
])
} catch {
print("Failed to send catalog: \(error)")
wlog("Failed to send catalog: \(error)")
}
for downloaded in songs {
@ -566,7 +566,7 @@ extension WatchConnectivityManager: WCSessionDelegate {
guard let metadata = file.metadata,
let songId = metadata["id"] as? String,
let suffix = metadata["suffix"] as? String else {
print("Received file without proper metadata")
wlog("Received file without proper metadata")
return
}
@ -582,14 +582,14 @@ extension WatchConnectivityManager: WCSessionDelegate {
do {
try fm.moveItem(at: file.fileURL, to: destURL)
print("Saved offline song: \(songId) to \(destURL.lastPathComponent)")
wlog("Saved offline song: \(songId) to \(destURL.lastPathComponent)")
// Save metadata catalog
var catalog = loadWatchCatalog()
catalog[songId] = metadata
saveWatchCatalog(catalog)
} catch {
print("Failed to save transferred file: \(error)")
wlog("Failed to save transferred file: \(error)")
}
#endif
}

View file

@ -30,10 +30,22 @@ class SyncEngine: ObservableObject {
set { UserDefaults.standard.set(Int(newValue), forKey: "sync_last_timestamp") }
}
private var cancellables = Set<AnyCancellable>()
private init() {
if let ts = UserDefaults.standard.object(forKey: "sync_last_date") as? Date {
lastSyncDate = ts
}
// Companion push events immediate delta sync so new uploads/edits appear instantly
NotificationCenter.default.publisher(for: .companionMetadataUpdated)
.merge(with: NotificationCenter.default.publisher(for: .companionTrackUploaded))
.receive(on: DispatchQueue.main)
.debounce(for: .seconds(2), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.lastSyncTimestamp = 0 // force full re-fetch of changed items
self?.syncIfNeeded()
}
.store(in: &cancellables)
}
// MARK: - Public API

View file

@ -12,7 +12,12 @@ class CompanionSettings: ObservableObject {
didSet { UserDefaults.standard.set(port, forKey: "companion_port") }
}
@Published var isEnabled: Bool {
didSet { UserDefaults.standard.set(isEnabled, forKey: "companion_enabled") }
didSet {
UserDefaults.standard.set(isEnabled, forKey: "companion_enabled")
if !isEnabled {
CompanionPushClient.shared.disconnect()
}
}
}
@Published var smartDJEnabled: Bool {
didSet { UserDefaults.standard.set(smartDJEnabled, forKey: "companion_smart_dj") }

View file

@ -504,10 +504,10 @@ struct AlbumDetailView: View {
}
}
/// Whether a song is available (downloaded or online)
/// Whether a song is available (downloaded or server reachable)
private func isSongAvailable(_ song: Song) -> Bool {
if offlineManager.isSongDownloaded(song.id) { return true }
return !libraryCache.isOffline
return libraryCache.isServerAvailable
}
/// Resize image to max dimension while preserving aspect ratio

View file

@ -12,6 +12,7 @@ struct ArtistDetailView: View {
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private let cache = LibraryCache.shared
@ObservedObject private var libraryCache = LibraryCache.shared
var body: some View {
ScrollView {
@ -49,11 +50,12 @@ struct ArtistDetailView: View {
)
.frame(width: 56, height: 56)
.cornerRadius(4)
.opacity(libraryCache.isServerAvailable ? 1.0 : 0.5)
VStack(alignment: .leading, spacing: 3) {
Text(album.name)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.foregroundColor(libraryCache.isServerAvailable ? .white : .gray.opacity(0.5))
.lineLimit(1)
HStack(spacing: 4) {
@ -65,7 +67,7 @@ struct ArtistDetailView: View {
}
}
.font(.system(size: 12))
.foregroundColor(.gray)
.foregroundColor(libraryCache.isServerAvailable ? .gray : .gray.opacity(0.4))
}
Spacer()

View file

@ -35,6 +35,7 @@ struct MyMusicView: View {
@State private var artistCoverPickerItem: PhotosPickerItem?
@State private var artistCoverTargetId: String?
@ObservedObject private var artistCoverStore = ArtistCoverStore.shared
@ObservedObject private var libraryCache = LibraryCache.shared
// Song info / edit
@State private var getInfoSong: Song?
@ -762,9 +763,12 @@ struct MyMusicView: View {
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let dlState = offlineManager.downloads[song.id]
let available = isDownloaded || libraryCache.isServerAvailable
Button(action: {
audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index)
if available {
audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index)
}
}) {
HStack(spacing: 12) {
// Cover art with download progress overlay
@ -772,6 +776,7 @@ struct MyMusicView: View {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
.frame(width: 44, height: 44)
.cornerRadius(3)
.opacity(available ? 1.0 : 0.4)
if case .downloading(let progress) = dlState {
RoundedRectangle(cornerRadius: 3)
@ -793,11 +798,14 @@ struct MyMusicView: View {
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(audioPlayer.currentSong?.id == song.id ? accentPink : .white)
.foregroundColor(
!available ? .gray.opacity(0.35) :
audioPlayer.currentSong?.id == song.id ? accentPink : .white
)
.lineLimit(1)
Text("\(song.artist ?? "") · \(song.album ?? "")")
.font(.system(size: 12))
.foregroundColor(.gray)
.foregroundColor(available ? .gray : .gray.opacity(0.3))
.lineLimit(1)
// Download progress bar

View file

@ -356,7 +356,7 @@ struct PlaylistDetailView: View {
let dlState = offlineManager.downloads[song.id]
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let available = isDownloaded || !libraryCache.isOffline
let available = isDownloaded || libraryCache.isServerAvailable
Button(action: {
if available {

View file

@ -91,6 +91,7 @@ struct RadioStationCover: View {
struct RadioView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@ObservedObject private var libraryCache = LibraryCache.shared
@State private var stations: [RadioStation] = []
@State private var isLoading = true
@ -127,9 +128,24 @@ struct RadioView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(stations) { station in
stationRow(station)
VStack(spacing: 0) {
// Offline banner radio always requires a live connection
if !libraryCache.isServerAvailable {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.font(.system(size: 13))
Text("Radio requires a server connection")
.font(.system(size: 13))
}
.foregroundColor(.white.opacity(0.8))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.75))
}
List {
ForEach(stations) { station in
stationRow(station)
}
}
}
}
@ -205,31 +221,36 @@ struct RadioView: View {
// MARK: - Station Row
private func stationRow(_ station: RadioStation) -> some View {
Button(action: { playStation(station) }) {
let canPlay = libraryCache.isServerAvailable
Button(action: {
if canPlay { playStation(station) }
}) {
HStack(spacing: 14) {
RadioStationCover(
stationId: station.id,
isPlaying: playingStationId == station.id
)
.opacity(canPlay ? 1.0 : 0.4)
VStack(alignment: .leading, spacing: 3) {
Text(station.name)
.font(.system(size: 16, weight: .medium))
.foregroundColor(
!canPlay ? .gray.opacity(0.4) :
playingStationId == station.id ? accentPink : .white
)
if let home = station.homePageUrl, !home.isEmpty {
Text(home)
.font(.system(size: 12))
.foregroundColor(.gray)
.foregroundColor(canPlay ? .gray : .gray.opacity(0.3))
.lineLimit(1)
}
}
Spacer()
if playingStationId == station.id {
if playingStationId == station.id && canPlay {
Image(systemName: "waveform")
.font(.system(size: 14))
.foregroundColor(accentPink)

View file

@ -140,7 +140,7 @@ struct SearchView: View {
if !songs.isEmpty {
sectionHeader("Songs")
ForEach(songs) { song in
let available = offlineManager.isSongDownloaded(song.id) || !libraryCache.isOffline
let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let dlState = offlineManager.downloads[song.id]

View file

@ -235,35 +235,8 @@ struct NowPlayingView: View {
var body: some View {
GeometryReader { screenGeo in
ZStack {
// Visualizer behind content (portrait) extends to edges
if !isLandscape && visSettings.enabled && visSettings.nowPlayingEnabled {
VStack {
Spacer()
MitsuhaVisualizerView(
isPlaying: audioPlayer.isPlaying,
accentColor: accentPink
)
.frame(height: screenGeo.size.height * visSettings.nowPlayingHeightPct)
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
// Visualizer behind content (landscape) extends to edges
if isLandscape && visSettings.enabled && visSettings.nowPlayingEnabled {
VStack(spacing: 0) {
Spacer()
MitsuhaVisualizerView(
isPlaying: audioPlayer.isPlaying,
accentColor: accentPink
)
.frame(maxWidth: .infinity)
.frame(height: screenGeo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7))
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
visualizerLayer(geo: screenGeo)
if isLandscape {
landscapeLayout
} else {
@ -338,7 +311,6 @@ struct NowPlayingView: View {
dragOffset = 0
isStarred = audioPlayer.currentSong?.starred != nil
albumColors.extract(from: audioPlayer.currentSong?.coverArt)
StatusBarStyleManager.shared.update(albumColor: albumColors.primaryColor, isVisible: true)
}
.onChange(of: audioPlayer.currentSong?.id) { _, _ in
isStarred = audioPlayer.currentSong?.starred != nil
@ -347,15 +319,6 @@ struct NowPlayingView: View {
.onChange(of: audioPlayer.currentSong?.starred) { _, newVal in
isStarred = newVal != nil
}
.onChange(of: albumColors.primaryColor) { _, newColor in
// Album art resolved update status bar to match new brightness
if isPresented {
StatusBarStyleManager.shared.update(albumColor: newColor, isVisible: true)
}
}
.onChange(of: isPresented) { _, visible in
StatusBarStyleManager.shared.update(albumColor: albumColors.primaryColor, isVisible: visible)
}
.onReceive(timePoller) { _ in
playbackTime = audioPlayer.currentTime
playbackDuration = audioPlayer.duration
@ -417,6 +380,32 @@ struct NowPlayingView: View {
}
}
// MARK: - Visualizer Layer
@ViewBuilder
private func visualizerLayer(geo: GeometryProxy) -> some View {
if visSettings.enabled && visSettings.nowPlayingEnabled {
if isLandscape {
VStack(spacing: 0) {
Spacer()
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
.frame(maxWidth: .infinity)
.frame(height: geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7))
.allowsHitTesting(false)
}
.ignoresSafeArea()
} else {
VStack {
Spacer()
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
.frame(height: geo.size.height * visSettings.nowPlayingHeightPct)
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
}
}
// MARK: - Background
private var backgroundGradient: some View {
@ -1204,6 +1193,8 @@ struct AddToPlaylistSheet: View {
// MARK: - Queue View
struct QueueView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@ObservedObject private var libraryCache = LibraryCache.shared
@State private var editMode: EditMode = .active
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
@ -1242,29 +1233,35 @@ struct QueueView: View {
Section {
ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in
if index != audioPlayer.queueIndex {
let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable
HStack(spacing: 12) {
Text("\(index + 1)")
.font(.system(size: 13, design: .monospaced))
.foregroundColor(.gray)
.foregroundColor(available ? .gray : .gray.opacity(0.35))
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(.white)
.foregroundColor(available ? .white : .gray.opacity(0.4))
.lineLimit(1)
Text(song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
.foregroundColor(available ? .gray : .gray.opacity(0.3))
.lineLimit(1)
}
Spacer()
if offlineManager.isSongDownloaded(song.id) {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 11))
.foregroundColor(.green.opacity(0.6))
}
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(.gray)
.foregroundColor(available ? .gray : .gray.opacity(0.35))
}
.contentShape(Rectangle())
.onTapGesture {
audioPlayer.play(song: song, at: index)
if available { audioPlayer.play(song: song, at: index) }
}
}
}

View file

@ -280,12 +280,12 @@ class RadioStreamBuffer: NSObject, ObservableObject, URLSessionDataDelegate {
readHandle.closeFile()
try data.write(to: destURL)
print("Saved recording: \(filename) (\(data.count) bytes)")
DebugLogger.shared.log("Saved recording: \(filename) (\(data.count) bytes)", category: "Radio")
recordingTimeFormatted = "0:00"
return destURL
} catch {
print("Failed to save recording: \(error)")
DebugLogger.shared.log("Failed to save recording: \(error)", category: "Radio")
return nil
}
}