Update from NavidromePlayer.zip (2026-04-04 06:58)

This commit is contained in:
Dallas Groot 2026-04-04 06:58:58 -07:00
parent 4787e2a5d4
commit c3bce4a997
10 changed files with 214 additions and 59 deletions

View file

@ -328,6 +328,15 @@ class SubsonicClient: ObservableObject {
for idx in songIndexesToRemove { params.append(URLQueryItem(name: "songIndexToRemove", value: "\(idx)")) }
_ = try await requestBody(endpoint: "updatePlaylist", params: params)
}
/// Reorders a playlist to match the supplied song array.
/// Removes all current entries then re-adds in new order atomic in one API call.
/// Subsonic applies removes first, then adds, so the result is exactly `songs` in order.
func reorderPlaylist(id: String, songs: [Song]) async throws {
let allIndices = Array(0..<songs.count)
let songIds = songs.map { $0.id }
try await updatePlaylist(id: id, songIdsToAdd: songIds, songIndexesToRemove: allIndices)
}
func deletePlaylist(id: String) async throws {
let params = [URLQueryItem(name: "id", value: id)]

View file

@ -226,7 +226,7 @@ struct PlaylistWithSongs: Codable, Identifiable {
let `public`: Bool?
let created: String?
let changed: String?
let entry: [Song]?
var entry: [Song]? // var PlaylistDetailView mutates this for optimistic reorder
}
// MARK: - Genres

View file

@ -556,6 +556,7 @@ struct MiniPlayerBar: View {
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
height: VisualizerSettings.shared.miniPlayerHeight
)
.matchedGeometryEffect(id: "visWave", in: namespace)
.offset(y: 10)
.opacity(VisualizerSettings.shared.miniOpacity)
.allowsHitTesting(false)
@ -696,6 +697,7 @@ struct DynamicIslandView: View {
accentColor: themeColor,
height: 32
)
.matchedGeometryEffect(id: "visWave", in: namespace)
.frame(width: 64, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {

View file

@ -2,6 +2,7 @@ import Foundation
import UIKit
import Combine
import UniformTypeIdentifiers
import ZIPFoundation
// MARK: - Zip Import Manager
@ -147,48 +148,24 @@ class ZipImportManager: NSObject, ObservableObject, URLSessionDataDelegate, URLS
}
}
// MARK: - Zip Extraction (Foundation-based)
/// Extracts a zip file using FileManager's built-in support (iOS 16+)
/// Falls back to manual extraction via compression framework
// MARK: - Zip Extraction (ZIPFoundation)
/// Extracts a zip file using ZIPFoundation the only reliable approach on iOS.
/// The previous NSFileCoordinator(.forUploading) implementation was wrong:
/// it returns a recompressed version of the source, not extracted file contents.
private func unzipFile(at zipURL: URL, to destDir: URL) throws {
// Use Process/unzip if available, otherwise use Foundation
// On iOS, we'll use a simple approach: copy zip and use FileManager
// Actually, iOS doesn't have native unzip API we need to use
// the compress/decompress approach or bundle a library.
// For simplicity, use the fact that .zip can be opened as a read archive.
// Use Apple's built-in support via spawning an extraction coordinator
let coordinator = NSFileCoordinator()
var coordError: NSError?
var extractError: Error?
coordinator.coordinate(readingItemAt: zipURL, options: [.forUploading], error: &coordError) { url in
// The system gives us a temp URL we can work with
do {
// Try to move/copy the contents
let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
for item in contents {
let dest = destDir.appendingPathComponent(item.lastPathComponent)
try FileManager.default.copyItem(at: item, to: dest)
}
} catch {
extractError = error
}
guard let archive = Archive(url: zipURL, accessMode: .read) else {
throw CompanionError.extractionFailed("Could not open archive at \(zipURL.lastPathComponent)")
}
if let error = coordError ?? extractError {
// Fallback: try treating the zip as a directory (works on some iOS versions)
// If that fails too, try a manual byte-level approach
let zipContents = try? FileManager.default.contentsOfDirectory(at: zipURL, includingPropertiesForKeys: nil)
if let contents = zipContents, !contents.isEmpty {
for item in contents {
let dest = destDir.appendingPathComponent(item.lastPathComponent)
try FileManager.default.copyItem(at: item, to: dest)
}
} else {
throw CompanionError.extractionFailed(error.localizedDescription)
for entry in archive {
guard entry.type == .file else { continue }
let destURL = destDir.appendingPathComponent(entry.path)
// Create intermediate directories for nested zip structure
let parentDir = destURL.deletingLastPathComponent()
if !fileManager.fileExists(atPath: parentDir.path) {
try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true)
}
_ = try archive.extract(entry, to: destURL, skipCRC32: false)
}
}

View file

@ -761,14 +761,34 @@ struct MyMusicView: View {
private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View {
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let dlState = offlineManager.downloads[song.id]
Button(action: {
audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index)
}) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
.frame(width: 44, height: 44)
.cornerRadius(3)
// Cover art with download progress overlay
ZStack {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
.frame(width: 44, height: 44)
.cornerRadius(3)
if case .downloading(let progress) = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.5))
.frame(width: 44, height: 44)
Circle()
.trim(from: 0, to: progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.frame(width: 24, height: 24)
.rotationEffect(.degrees(-90))
} else if case .queued = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.4))
.frame(width: 44, height: 44)
ProgressView().tint(accentPink).scaleEffect(0.6)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
@ -779,6 +799,18 @@ struct MyMusicView: View {
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
// Download progress bar
if case .downloading(let progress) = dlState {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08)).frame(height: 2)
Capsule().fill(accentPink)
.frame(width: geo.size.width * progress, height: 2)
}
}
.frame(height: 2)
}
}
Spacer()

View file

@ -128,6 +128,8 @@ struct PlaylistDetailView: View {
@State private var availablePlaylists: [Playlist] = []
@State private var getInfoSong: Song?
@State private var trackEditorSong: Song?
@State private var isReordering = false
@State private var reorderError = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
@ -149,6 +151,17 @@ struct PlaylistDetailView: View {
}
.background(Color(white: 0.06))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if playlist?.entry?.isEmpty == false {
Button(isReordering ? "Done" : "Reorder") {
withAnimation { isReordering.toggle() }
}
.foregroundColor(isReordering ? accentPink : .white)
.font(.system(size: 14, weight: isReordering ? .semibold : .regular))
}
}
}
.sheet(isPresented: $showPlaylistPicker) {
AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists)
}
@ -238,12 +251,73 @@ struct PlaylistDetailView: View {
@ViewBuilder
private func playlistSongList(_ playlist: PlaylistWithSongs) -> some View {
let songs = playlist.entry ?? []
LazyVStack(spacing: 0) {
ForEach(Array(songs.enumerated()), id: \.element.id) { index, song in
playlistSongRow(song: song, index: index, songs: songs)
if isReordering {
// Reorder mode: List with drag handles, dark styled to match the rest of the view
List {
ForEach(Array(songs.enumerated()), id: \.element.id) { index, song in
reorderSongRow(song: song)
.listRowBackground(Color(white: 0.06))
.listRowSeparatorTint(Color.white.opacity(0.08))
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
}
.onMove { from, to in
guard var entries = self.playlist?.entry else { return }
entries.move(fromOffsets: from, toOffset: to)
self.playlist?.entry = entries
// Sync to server in background optimistic local update above is instant
Task {
do {
try await serverManager.client.reorderPlaylist(id: playlistId, songs: entries)
} catch {
// Revert on failure by refreshing from server
self.playlist = try? await serverManager.client.getPlaylist(id: playlistId)
reorderError = true
}
}
}
// Bottom padding row
Color.clear.frame(height: 80)
.listRowBackground(Color(white: 0.06))
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color(white: 0.06))
.environment(\.editMode, .constant(.active))
.frame(minHeight: CGFloat(songs.count) * 60 + 80)
} else {
LazyVStack(spacing: 0) {
ForEach(Array(songs.enumerated()), id: \.element.id) { index, song in
playlistSongRow(song: song, index: index, songs: songs)
}
}
}
}
/// Minimal row shown in reorder mode just art, title, artist, drag handle cue
@ViewBuilder
private func reorderSongRow(song: Song) -> some View {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
.frame(width: 36, height: 36)
.cornerRadius(3)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.system(size: 12))
.foregroundColor(.gray.opacity(0.6))
}
.padding(.vertical, 8)
}
private func playPlaylist(shuffle: Bool) {
guard let songs = playlist?.entry, !songs.isEmpty else { return }

View file

@ -143,16 +143,35 @@ struct SearchView: View {
let available = offlineManager.isSongDownloaded(song.id) || !libraryCache.isOffline
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let dlState = offlineManager.downloads[song.id]
Button(action: {
if available {
audioPlayer.play(song: song, fromQueue: songs)
}
}) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
.frame(width: 44, height: 44)
.cornerRadius(3)
.opacity(available ? 1.0 : 0.4)
ZStack {
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
.frame(width: 44, height: 44)
.cornerRadius(3)
.opacity(available ? 1.0 : 0.4)
if case .downloading(let progress) = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.5))
.frame(width: 44, height: 44)
Circle()
.trim(from: 0, to: progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.frame(width: 24, height: 24)
.rotationEffect(.degrees(-90))
} else if case .queued = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.4))
.frame(width: 44, height: 44)
ProgressView().tint(accentPink).scaleEffect(0.6)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
@ -167,6 +186,17 @@ struct SearchView: View {
.font(.system(size: 12))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
.lineLimit(1)
if case .downloading(let progress) = dlState {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08)).frame(height: 2)
Capsule().fill(accentPink)
.frame(width: geo.size.width * progress, height: 2)
}
}
.frame(height: 2)
}
}
Spacer()

View file

@ -210,6 +210,19 @@ struct NowPlayingView: View {
vSizeClass == .compact
}
/// Derive preferred color scheme from album art dominant color brightness.
/// Most album art is dark enough for .dark (white status bar icons).
/// Very bright covers (white/yellow/light pop art) switch to .light so the
/// status bar icons don't disappear against the blurred background.
private var preferredSchemeForAlbum: ColorScheme {
guard albumColors.isLoaded else { return .dark }
var brightness: CGFloat = 0
UIColor(albumColors.primaryColor).getHue(nil, saturation: nil, brightness: &brightness, alpha: nil)
// Only flip to .light for genuinely bright albums (>0.85).
// The background overlay darkens most art, so the threshold is high.
return brightness > 0.85 ? .light : .dark
}
/// Whether the current song is a radio stream
private var isRadio: Bool {
audioPlayer.isRadioStream || audioPlayer.currentSong?.album == "Radio"
@ -278,7 +291,7 @@ struct NowPlayingView: View {
}
}
)
.preferredColorScheme(.dark)
.preferredColorScheme(preferredSchemeForAlbum)
.sheet(isPresented: $showQueue) {
if isRadio {
RadioRecordingsView(stationName: audioPlayer.currentSong?.title ?? "Radio")

View file

@ -128,6 +128,16 @@ class VisualizerSettings: ObservableObject {
private var saveTask: Task<Void, Never>?
}
// MARK: - Wave State Cache (shared between mini player and DI for morph continuity)
/// Single source of truth for compact visualizer levels.
/// Both MiniPlayerBar and DynamicIslandView seed from here on appear,
/// so the wave doesn't reset to idle when matchedGeometryEffect transitions between them.
final class WaveStateCache {
static let shared = WaveStateCache()
var compactLevels: [Float] = []
private init() {}
}
// MARK: - Main Visualizer View
struct MitsuhaVisualizerView: View {
/// Optional override levels for previews. When nil, pulls from AudioPlayer.
@ -152,13 +162,10 @@ struct MitsuhaVisualizerView: View {
GeometryReader { geo in
TimelineView(.animation(minimumInterval: 1.0 / settings.effectiveFPS)) { timeline in
// PULL: Grab latest levels right before rendering no @Published thrashing
let rawLevels: [Float]
if isPlaying {
rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels()
} else {
// Feed zeros so levels decay smoothly via viscosity
rawLevels = Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
}
// Ternary required: if/else assignment inside @ViewBuilder returns () which isn't View
let rawLevels: [Float] = isPlaying
? (previewLevels ?? AudioPlayer.shared.currentLevels())
: Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
let _ = updateDisplayLevelsIfNeeded(newRawLevels: rawLevels)
Canvas(opaque: false) { context, size in
@ -184,7 +191,10 @@ struct MitsuhaVisualizerView: View {
.opacity(isPlaying ? 1 : Double(settings.idleAmplitude) > 0 ? 0.4 : 0)
.animation(.easeInOut(duration: 0.8), value: isPlaying)
.onAppear {
displayLevels = Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
let cached = compact ? WaveStateCache.shared.compactLevels : []
displayLevels = cached.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: cached
}
}
}
@ -275,6 +285,8 @@ struct MitsuhaVisualizerView: View {
displayLevels[i] = displayLevels[i] + (targetLevels[i] - displayLevels[i]) * smoothFactor
}
}
// Cache compact levels so DI morph picks up current wave shape
if compact { WaveStateCache.shared.compactLevels = displayLevels }
}
// MARK: - Colors

View file

@ -8,6 +8,11 @@ options:
groupSortPosition: top
createIntermediateGroups: true
packages:
ZIPFoundation:
url: https://github.com/weichsel/ZIPFoundation
from: "0.9.19"
settings:
base:
SWIFT_VERSION: "5.9"
@ -40,6 +45,7 @@ targets:
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: true
dependencies:
- target: NavidromeWatch
- package: ZIPFoundation
# ─────────────────────────────────────
# watchOS App