diff --git a/Shared/API/SubsonicClient.swift b/Shared/API/SubsonicClient.swift index 32dde85..d4e0840 100644 --- a/Shared/API/SubsonicClient.swift +++ b/Shared/API/SubsonicClient.swift @@ -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.. 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() diff --git a/iOS/Views/Library/PlaylistsView.swift b/iOS/Views/Library/PlaylistsView.swift index 477cd4b..ef1808a 100644 --- a/iOS/Views/Library/PlaylistsView.swift +++ b/iOS/Views/Library/PlaylistsView.swift @@ -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 } diff --git a/iOS/Views/Library/SearchView.swift b/iOS/Views/Library/SearchView.swift index 0696364..5d0c6d1 100644 --- a/iOS/Views/Library/SearchView.swift +++ b/iOS/Views/Library/SearchView.swift @@ -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() diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index 37e5c02..9325e4b 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -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") diff --git a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift index 3cb4371..4535b15 100644 --- a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift +++ b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift @@ -128,6 +128,16 @@ class VisualizerSettings: ObservableObject { private var saveTask: Task? } +// 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 diff --git a/project.yml b/project.yml index 16f2865..df2cb27 100644 --- a/project.yml +++ b/project.yml @@ -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