Update from NavidromePlayer.zip (2026-04-04 16:12)
This commit is contained in:
parent
a8d669824e
commit
7094ffab16
15 changed files with 165 additions and 75 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue