diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 055b81e..1b00f9e 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -107,6 +107,17 @@ class AudioPlayer: NSObject, ObservableObject { super.init() configureAudioSession() setupRemoteControls() + + #if os(iOS) + // Refresh lock screen artwork when custom cover changes + AlbumCoverStore.shared.$updateTrigger + .sink { [weak self] _ in + guard let self, let id = self.currentSong?.coverArt else { return } + self.cachedArtworkCoverArtId = nil // Force refetch + self.fetchAndSetArtwork(coverArtId: id) + } + .store(in: &cancellables) + #endif } // MARK: - Audio Session diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index 01773c0..0cbaf63 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -2,64 +2,62 @@ - CFBundleDevelopmentRegion - en - CFBundleDisplayName - Navidrome - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSMicrophoneUsageDescription - Identify songs playing on the radio using Shazam - NSPhotoLibraryUsageDescription - Choose cover images for your Album Art, Radio Stations and Artist Covers. - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UIBackgroundModes - - audio - fetch - processing - nearby-interaction - - UILaunchStoryboardName - LaunchScreen - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Navidrome + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIBackgroundModes + + audio + fetch + + UILaunchStoryboardName + LaunchScreen + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + ITSAppUsesNonExemptEncryption + + NSPhotoLibraryUsageDescription + Choose cover images for your radio stations + NSMicrophoneUsageDescription + Identify songs playing on the radio using Shazam diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index 0a9dd9c..da34ca3 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -62,12 +62,19 @@ struct MainTabView: View { } .tag(3) + VisualizerSettingsView() + .tabItem { + Image(systemName: "waveform") + Text("Visualizer") + } + .tag(4) + SettingsView() .tabItem { Image(systemName: "gear") Text("Settings") } - .tag(4) + .tag(5) } .tint(accentPink) diff --git a/iOS/Views/Companion/SmartCrossfadeManager.swift b/iOS/Views/Companion/SmartCrossfadeManager.swift index 5ab4ff0..e360f4b 100644 --- a/iOS/Views/Companion/SmartCrossfadeManager.swift +++ b/iOS/Views/Companion/SmartCrossfadeManager.swift @@ -162,14 +162,13 @@ class SmartCrossfadeManager: ObservableObject { if let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 { let seekTarget = CMTime(seconds: silEnd, preferredTimescale: 1000) let standby = self.standbyPlayer - self.statusObservation = item.observe(\.status) { [weak self] observedItem, _ in + let observation = item.observe(\.status, options: [.new]) { observedItem, _ in guard observedItem.status == .readyToPlay else { return } - DispatchQueue.main.async { [weak self] in - self?.statusObservation?.invalidate() - self?.statusObservation = nil + DispatchQueue.main.async { standby.seek(to: seekTarget) } } + self.statusObservation = observation } } } diff --git a/iOS/Views/Companion/TrackEditorView.swift b/iOS/Views/Companion/TrackEditorView.swift index e40cfff..197bd06 100644 --- a/iOS/Views/Companion/TrackEditorView.swift +++ b/iOS/Views/Companion/TrackEditorView.swift @@ -4,6 +4,7 @@ struct TrackEditorView: View { let song: Song @Environment(\.dismiss) private var dismiss + // Field values @State private var title: String @State private var artist: String @State private var album: String @@ -11,8 +12,15 @@ struct TrackEditorView: View { @State private var genre: String @State private var year: String @State private var trackNumber: String - @State private var discNumber: String - @State private var comment: String + + // Checkmarks — only checked fields get sent to the API + @State private var editTitle = false + @State private var editArtist = false + @State private var editAlbum = false + @State private var editAlbumArtist = false + @State private var editGenre = false + @State private var editYear = false + @State private var editTrackNumber = false @State private var isSaving = false @State private var errorMessage: String? @@ -23,6 +31,10 @@ struct TrackEditorView: View { private let api = CompanionAPIService() private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + private var hasSelection: Bool { + editTitle || editArtist || editAlbum || editAlbumArtist || editGenre || editYear || editTrackNumber + } + init(song: Song) { self.song = song _title = State(initialValue: song.title) @@ -32,8 +44,6 @@ struct TrackEditorView: View { _genre = State(initialValue: song.genre ?? "") _year = State(initialValue: song.year != nil ? "\(song.year!)" : "") _trackNumber = State(initialValue: song.track != nil ? "\(song.track!)" : "") - _discNumber = State(initialValue: song.discNumber != nil ? "\(song.discNumber!)" : "") - _comment = State(initialValue: "") } var body: some View { @@ -47,7 +57,7 @@ struct TrackEditorView: View { .foregroundColor(.yellow) Text("Companion API Required") .font(.system(size: 15, weight: .medium)) - Text("Configure the Companion API in Settings to edit track metadata remotely.") + Text("Configure the Companion API in Settings to edit track metadata.") .font(.system(size: 13)) .foregroundColor(.gray) .multilineTextAlignment(.center) @@ -69,34 +79,26 @@ struct TrackEditorView: View { Text("File Info") } - // Editable fields + // Editable fields with checkmarks Section { - editField("Title", text: $title) - editField("Artist", text: $artist) - editField("Album", text: $album) - editField("Album Artist", text: $albumArtist) + checkField("Title", text: $title, isEnabled: $editTitle) + checkField("Artist", text: $artist, isEnabled: $editArtist) + checkField("Album", text: $album, isEnabled: $editAlbum) + checkField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist) } header: { Text("Basic Info") + } footer: { + Text("Check the fields you want to change. Only checked fields will be updated.") } Section { - editField("Genre", text: $genre) - editField("Year", text: $year, keyboard: .numberPad) - editField("Track #", text: $trackNumber, keyboard: .numberPad) - editField("Disc #", text: $discNumber, keyboard: .numberPad) + checkField("Genre", text: $genre, isEnabled: $editGenre) + checkField("Year", text: $year, isEnabled: $editYear, keyboard: .numberPad) + checkField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, keyboard: .numberPad) } header: { Text("Details") } - Section { - TextEditor(text: $comment) - .frame(minHeight: 60) - .font(.system(size: 14)) - } header: { - Text("Comment") - } - - // Error / Success if let error = errorMessage { Section { Text(error) @@ -117,15 +119,14 @@ struct TrackEditorView: View { if settings.isEnabled { Button(action: save) { if isSaving { - ProgressView() - .tint(accentPink) + ProgressView().tint(accentPink) } else { Text("Save") .fontWeight(.semibold) - .foregroundColor(accentPink) + .foregroundColor(hasSelection ? accentPink : .gray) } } - .disabled(isSaving || song.path == nil) + .disabled(isSaving || !hasSelection || song.path == nil) } } } @@ -159,9 +160,13 @@ struct TrackEditorView: View { do { let request = MetadataEditRequest( relativePath: path, - title: title.isEmpty ? nil : title, - artist: artist.isEmpty ? nil : artist, - album: album.isEmpty ? nil : album + title: editTitle ? (title.isEmpty ? nil : title) : nil, + artist: editArtist ? (artist.isEmpty ? nil : artist) : nil, + album: editAlbum ? (album.isEmpty ? nil : album) : nil, + albumArtist: editAlbumArtist ? (albumArtist.isEmpty ? nil : albumArtist) : nil, + genre: editGenre ? (genre.isEmpty ? nil : genre) : nil, + year: editYear ? Int(year) : nil, + trackNumber: editTrackNumber ? Int(trackNumber) : nil ) try await api.editMetadata(request) @@ -173,42 +178,46 @@ struct TrackEditorView: View { } try? await Task.sleep(for: .seconds(1.2)) - await MainActor.run { - dismiss() - } + await MainActor.run { dismiss() } } catch { await MainActor.run { isSaving = false errorMessage = error.localizedDescription - DebugLogger.shared.log("Tag edit failed: \(error.localizedDescription)", category: "Companion") } } } } - // MARK: - Helpers + // MARK: - Checkmark Field - private func editField(_ label: String, text: Binding, keyboard: UIKeyboardType = .default) -> some View { - HStack { + private func checkField(_ label: String, text: Binding, isEnabled: Binding, keyboard: UIKeyboardType = .default) -> some View { + HStack(spacing: 10) { + Button(action: { isEnabled.wrappedValue.toggle() }) { + Image(systemName: isEnabled.wrappedValue ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20)) + .foregroundColor(isEnabled.wrappedValue ? accentPink : .gray.opacity(0.4)) + } + .buttonStyle(.plain) + Text(label) - .foregroundColor(.gray) - .frame(width: 90, alignment: .leading) + .foregroundColor(isEnabled.wrappedValue ? .white : .gray) + .frame(width: 85, alignment: .leading) + TextField(label, text: text) .keyboardType(keyboard) .multilineTextAlignment(.trailing) + .foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5)) + .disabled(!isEnabled.wrappedValue) } .font(.system(size: 14)) } private func infoRow(_ label: String, _ value: String) -> some View { HStack { - Text(label) - .foregroundColor(.gray) + Text(label).foregroundColor(.gray) Spacer() - Text(value) - .foregroundColor(.white.opacity(0.7)) - .lineLimit(1) + Text(value).foregroundColor(.white.opacity(0.7)).lineLimit(1) } .font(.system(size: 13)) } diff --git a/iOS/Views/Library/AlbumDetailView.swift b/iOS/Views/Library/AlbumDetailView.swift index 5d3e44a..7ac5e4b 100644 --- a/iOS/Views/Library/AlbumDetailView.swift +++ b/iOS/Views/Library/AlbumDetailView.swift @@ -17,11 +17,9 @@ struct AlbumDetailView: View { @State private var showPlaylistPicker = false @State private var playlistPickerSongId: String? @State private var availablePlaylists: [Playlist] = [] - @State private var showGetInfo = false @State private var getInfoSong: Song? @State private var showCoverPicker = false @State private var selectedCoverPhoto: PhotosPickerItem? - @State private var showTrackEditor = false @State private var trackEditorSong: Song? @State private var showBatchEditor = false @State private var loadFailed = false @@ -67,15 +65,11 @@ struct AlbumDetailView: View { .sheet(isPresented: $showPlaylistPicker) { AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists) } - .sheet(isPresented: $showGetInfo) { - if let song = getInfoSong { - SongInfoSheet(song: song) - } + .sheet(item: $getInfoSong) { song in + SongInfoSheet(song: song) } - .sheet(isPresented: $showTrackEditor) { - if let song = trackEditorSong { - TrackEditorView(song: song) - } + .sheet(item: $trackEditorSong) { song in + TrackEditorView(song: song) } .sheet(isPresented: $showBatchEditor) { if let album = album { @@ -367,14 +361,13 @@ struct AlbumDetailView: View { } } }) { - Label(song.starred != nil ? "Remove" : "Add to", systemImage: song.starred != nil ? "heart.slash" : "heart") + Label(song.starred != nil ? "Unstar" : "Star", systemImage: song.starred != nil ? "heart.slash" : "heart") } Divider() Button(action: { getInfoSong = song - showGetInfo = true }) { Label("Get Info", systemImage: "info.circle") } @@ -382,7 +375,6 @@ struct AlbumDetailView: View { if CompanionSettings.shared.isEnabled { Button(action: { trackEditorSong = song - showTrackEditor = true }) { Label("Edit Tags", systemImage: "tag") } diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index 804e893..b296699 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -294,6 +294,12 @@ struct MyMusicView: View { Button(action: { playAlbum(album) }) { Label("Play", systemImage: "play.fill") } + Button(action: { playAlbumNext(album) }) { + Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") + } + Button(action: { playAlbumLater(album) }) { + Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward") + } if CompanionSettings.shared.isEnabled { Divider() @@ -455,7 +461,7 @@ struct MyMusicView: View { } if filtered.isEmpty && !isLoading { - emptyState(searchText.isEmpty ? "No favourites yet — Heart songs to see them here" : "No matches") + emptyState(searchText.isEmpty ? "No favourites yet — star songs to see them here" : "No matches") } } .padding(.top, 4) @@ -642,6 +648,12 @@ struct MyMusicView: View { Button(action: { playAlbum(album) }) { Label("Play", systemImage: "play.fill") } + Button(action: { playAlbumNext(album) }) { + Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") + } + Button(action: { playAlbumLater(album) }) { + Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward") + } if CompanionSettings.shared.isEnabled { Divider() @@ -738,6 +750,9 @@ struct MyMusicView: View { @ViewBuilder private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View { + let isDownloaded = offlineManager.isSongDownloaded(song.id) + let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) + Button(action: { audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index) }) { @@ -759,6 +774,20 @@ struct MyMusicView: View { Spacer() + // Status icons + HStack(spacing: 4) { + if isOnWatch { + Image(systemName: "applewatch") + .font(.system(size: 9)) + .foregroundColor(.blue.opacity(0.7)) + } + if isDownloaded { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 11)) + .foregroundColor(.green.opacity(0.6)) + } + } + Text(song.durationFormatted) .font(.system(size: 13)) .foregroundColor(.gray) @@ -881,8 +910,28 @@ struct MyMusicView: View { audioPlayer.play(song: songs[0], fromQueue: songs, at: 0) } } - } catch { - // Fallback: navigate to album detail + } catch { } + } + } + + private func playAlbumNext(_ album: Album) { + Task { + if let detail = try? await serverManager.client.getAlbum(id: album.id), + let songs = detail?.song { + await MainActor.run { + for song in songs.reversed() { audioPlayer.playNext(song) } + } + } + } + } + + private func playAlbumLater(_ album: Album) { + Task { + if let detail = try? await serverManager.client.getAlbum(id: album.id), + let songs = detail?.song { + await MainActor.run { + for song in songs { audioPlayer.playLater(song) } + } } } } diff --git a/iOS/Views/Library/SearchView.swift b/iOS/Views/Library/SearchView.swift index e53e966..0696364 100644 --- a/iOS/Views/Library/SearchView.swift +++ b/iOS/Views/Library/SearchView.swift @@ -141,6 +141,8 @@ struct SearchView: View { sectionHeader("Songs") ForEach(songs) { song in let available = offlineManager.isSongDownloaded(song.id) || !libraryCache.isOffline + let isDownloaded = offlineManager.isSongDownloaded(song.id) + let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) Button(action: { if available { audioPlayer.play(song: song, fromQueue: songs) @@ -169,6 +171,19 @@ struct SearchView: View { Spacer() + HStack(spacing: 4) { + if isOnWatch { + Image(systemName: "applewatch") + .font(.system(size: 9)) + .foregroundColor(.blue.opacity(0.7)) + } + if isDownloaded { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 11)) + .foregroundColor(.green.opacity(0.6)) + } + } + Text(song.durationFormatted) .font(.system(size: 13)) .foregroundColor(available ? .gray : .gray.opacity(0.3)) diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index da4b91b..113acdd 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -177,7 +177,6 @@ struct NowPlayingView: View { @State private var isDraggingSlider = false @State private var dragPosition: Double = 0 @State private var showQueue = false - @State private var showVisualizerSettings = false @State private var showPlaylistPicker = false @State private var showGetInfo = false @State private var showTrackEditor = false @@ -286,7 +285,6 @@ struct NowPlayingView: View { QueueView() } } - .sheet(isPresented: $showVisualizerSettings) { VisualizerSettingsView() } .sheet(isPresented: $showGetInfo) { if let song = audioPlayer.currentSong { SongInfoSheet(song: song) @@ -395,18 +393,20 @@ struct NowPlayingView: View { // Layer 1: Deep black base Color.black.ignoresSafeArea() - // Layer 2: Blurred album art mist — must be clipped to prevent layout overflow + // Layer 2: Blurred album art with crossfade on song change if let art = albumColors.currentImage { GeometryReader { geo in Image(uiImage: art) .resizable() - .aspectRatio(contentMode: .fill) + .scaledToFill() .frame(width: geo.size.width, height: geo.size.height) .clipped() - .blur(radius: 85, opaque: true) - .opacity(0.5) + .blur(radius: 60, opaque: true) + .overlay(Color.black.opacity(0.4)) } .ignoresSafeArea() + .id(audioPlayer.currentSong?.id ?? "none") + .transition(.opacity) } // Layer 3: Radial vignette from dominant color @@ -421,6 +421,7 @@ struct NowPlayingView: View { ) .ignoresSafeArea() } + .animation(.easeInOut(duration: 0.6), value: audioPlayer.currentSong?.id) } // MARK: - Top Bar (portrait only) @@ -483,107 +484,104 @@ struct NowPlayingView: View { /// so we can show icons and dividers. private var ellipsisActionsSheet: some View { NavigationStack { - List { - if !isRadio { - Section { - sheetButton("Instant Mix", icon: "wand.and.stars") { - showEllipsisMenu = false - if let song = audioPlayer.currentSong { - audioPlayer.playInstantMix(basedOn: song) - } - } - sheetButton("Add to Playlist...", icon: "text.badge.plus") { - showEllipsisMenu = false - Task { - availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? [] - showPlaylistPicker = true - } - } - } - - Section { - if audioPlayer.currentSong?.albumId != nil { - sheetButton("Go to Album", icon: "square.stack") { + ScrollView { + VStack(spacing: 16) { + if !isRadio { + // Row 1: Instant Mix | Add to Playlist + gridRow( + left: ("Instant Mix", "wand.and.stars", { + showEllipsisMenu = false + if let song = audioPlayer.currentSong { + audioPlayer.playInstantMix(basedOn: song) + } + }), + right: ("Add to Playlist", "text.badge.plus", { + showEllipsisMenu = false + Task { + availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? [] + showPlaylistPicker = true + } + }) + ) + + // Row 2: Go to Album | Go to Artist + gridRow( + left: ("Go to Album", "square.stack", { showEllipsisMenu = false if let albumId = audioPlayer.currentSong?.albumId { - NotificationCenter.default.post( - name: .navigateToAlbum, object: nil, - userInfo: ["albumId": albumId] - ) + NotificationCenter.default.post(name: .navigateToAlbum, object: nil, userInfo: ["albumId": albumId]) } - } - } - if audioPlayer.currentSong?.artistId != nil { - sheetButton("Go to Artist", icon: "music.mic") { + }), + right: ("Go to Artist", "music.mic", { showEllipsisMenu = false if let artistId = audioPlayer.currentSong?.artistId { - NotificationCenter.default.post( - name: .navigateToArtist, object: nil, - userInfo: ["artistId": artistId] - ) + NotificationCenter.default.post(name: .navigateToArtist, object: nil, userInfo: ["artistId": artistId]) } - } - } - if let pid = audioPlayer.sourcePlaylistId, - let pname = audioPlayer.sourcePlaylistName { - sheetButton("Show in \(pname)", icon: "music.note.list") { - showEllipsisMenu = false - withAnimation(.easeInOut(duration: 0.35)) { isPresented = false } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - NotificationCenter.default.post( - name: .navigateToPlaylist, object: nil, - userInfo: ["playlistId": pid] - ) - } - } - } - } - - if let song = audioPlayer.currentSong { - Section { - if offlineManager.isSongDownloaded(song.id) { - sheetButton("Remove Download", icon: "trash", role: .destructive) { - showEllipsisMenu = false - offlineManager.removeSong(song.id) - } - if WatchConnectivityManager.shared.isWatchAvailable { - sheetButton("Send to Watch", icon: "applewatch.and.arrow.forward") { + }) + ) + + // Row 3: Download/Remove | Send to Watch + if let song = audioPlayer.currentSong { + let isDownloaded = offlineManager.isSongDownloaded(song.id) + gridRow( + left: isDownloaded + ? ("Remove Download", "trash", { showEllipsisMenu = false; offlineManager.removeSong(song.id) }) + : ("Download", "arrow.down.circle", { + showEllipsisMenu = false + if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) } + }), + right: WatchConnectivityManager.shared.isWatchAvailable + ? ("Send to Watch", "applewatch.and.arrow.forward", { showEllipsisMenu = false _ = WatchConnectivityManager.shared.sendSongToWatch(song) - } - } - } else { - sheetButton("Download", icon: "arrow.down.circle") { - showEllipsisMenu = false - if let server = serverManager.activeServer { - offlineManager.downloadSong(song, server: server) - } - } - } + }) + : nil + ) } - } - } - - Section { - sheetButton("Get Info", icon: "info.circle") { - showEllipsisMenu = false - showGetInfo = true + + Divider().padding(.horizontal, 16) } - if CompanionSettings.shared.isEnabled { - sheetButton("Edit Tags", icon: "tag") { + // Row 4: Get Info | Edit Tags + gridRow( + left: ("Get Info", "info.circle", { showEllipsisMenu = false - showTrackEditor = true + showGetInfo = true + }), + right: CompanionSettings.shared.isEnabled + ? ("Edit Tags", "tag", { + showEllipsisMenu = false + showTrackEditor = true + }) + : nil + ) + + // Playlist link if playing from one + if let pid = audioPlayer.sourcePlaylistId, + let pname = audioPlayer.sourcePlaylistName { + Button(action: { + showEllipsisMenu = false + withAnimation(.easeInOut(duration: 0.35)) { isPresented = false } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .navigateToPlaylist, object: nil, userInfo: ["playlistId": pid]) + } + }) { + Label("Show in \(pname)", systemImage: "music.note.list") + .font(.system(size: 14)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.white.opacity(0.08)) + .cornerRadius(12) } + .padding(.horizontal, 16) } - sheetButton("Visualizer Settings", icon: "waveform") { - showEllipsisMenu = false - showVisualizerSettings = true - } + Spacer() } + .padding(.top, 20) } - .listStyle(.insetGrouped) + .background(Color(white: 0.1)) .navigationTitle("Options") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -595,9 +593,37 @@ struct NowPlayingView: View { } } - private func sheetButton(_ title: String, icon: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - Button(role: role, action: action) { - Label(title, systemImage: icon) + /// Two-button grid row for the options sheet + private func gridRow( + left: (String, String, () -> Void), + right: (String, String, () -> Void)? + ) -> some View { + HStack(spacing: 12) { + gridButton(title: left.0, icon: left.1, action: left.2) + if let r = right { + gridButton(title: r.0, icon: r.1, action: r.2) + } else { + Color.clear.frame(maxWidth: .infinity, minHeight: 70) + } + } + .padding(.horizontal, 16) + } + + private func gridButton(title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(accentPink) + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .lineLimit(2) + } + .frame(maxWidth: .infinity, minHeight: 70) + .background(Color.white.opacity(0.08)) + .cornerRadius(12) } } @@ -1138,8 +1164,6 @@ struct QueueView: View { } } - // 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 diff --git a/watchOS/Resources/Info.plist b/watchOS/Resources/Info.plist index 7a2f63e..3c48bf8 100644 --- a/watchOS/Resources/Info.plist +++ b/watchOS/Resources/Info.plist @@ -2,46 +2,47 @@ - CFBundleDevelopmentRegion - en - CFBundleDisplayName - Navidrome - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSHealthShareUsageDescription - Used to maintain background audio playback through the speaker. - NSHealthUpdateUsageDescription - Used to maintain background audio playback through the speaker. - UIBackgroundModes - - audio - - WKApplication - - WKBackgroundModes - - workout-processing - - WKCompanionAppBundleIdentifier - com.navidromeplayer.app - WKRunsIndependentlyOfCompanionApp - + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Navidrome + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + WKApplication + + WKCompanionAppBundleIdentifier + com.navidromeplayer.app + UIBackgroundModes + + audio + + WKBackgroundModes + + self-care + workout-processing + + WKRunsIndependentlyOfCompanionApp + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSHealthShareUsageDescription + Used to maintain background audio playback through the speaker. + NSHealthUpdateUsageDescription + Used to maintain background audio playback through the speaker. diff --git a/watchOS/Views/WatchNowPlayingView.swift b/watchOS/Views/WatchNowPlayingView.swift index 5c7e4ad..353a435 100644 --- a/watchOS/Views/WatchNowPlayingView.swift +++ b/watchOS/Views/WatchNowPlayingView.swift @@ -39,14 +39,6 @@ struct WatchNowPlayingView: View { } .padding(.top, 2) - // Visualizer - WatchVisualizerView( - levels: audioPlayer.audioLevels, - isPlaying: audioPlayer.isPlaying - ) - .frame(height: 28) - .padding(.horizontal, 4) - // Progress bar GeometryReader { geo in ZStack(alignment: .leading) { diff --git a/watchOS/Views/WatchVisualizerView.swift b/watchOS/Views/WatchVisualizerView.swift deleted file mode 100644 index fe47392..0000000 --- a/watchOS/Views/WatchVisualizerView.swift +++ /dev/null @@ -1,126 +0,0 @@ -import SwiftUI - -/// Compact Siri-style wave visualizer for watchOS -struct WatchVisualizerView: View { - let levels: [Float] - let isPlaying: Bool - - private let greenColor = Color(red: 0.3, green: 0.85, blue: 0.45) - private let blueColor = Color(red: 0.2, green: 0.55, blue: 1.0) - private let purpleColor = Color(red: 0.85, green: 0.25, blue: 0.85) - - var body: some View { - TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in - Canvas { context, size in - drawVisualizerContent(context: context, size: size, date: timeline.date) - } - } - } - - // MARK: - Main draw (broken out for type-checker) - - private func drawVisualizerContent(context: GraphicsContext, size: CGSize, date: Date) { - let w = size.width - let h = size.height - let count = levels.count - guard count >= 2 else { return } - - let time = date.timeIntervalSinceReferenceDate - let spacing = w / CGFloat(count - 1) - - let layerData: [(Color, Double)] = [ - (purpleColor, 0.3), - (blueColor, 0.15), - (greenColor, 0.0), - ] - - for (color, offset) in layerData { - drawWaveLayer( - context: context, - w: w, h: h, - count: count, - spacing: spacing, - time: time, - offset: offset, - color: color - ) - } - } - - // MARK: - Single wave layer - - private func drawWaveLayer( - context: GraphicsContext, - w: CGFloat, h: CGFloat, - count: Int, - spacing: CGFloat, - time: TimeInterval, - offset: Double, - color: Color - ) { - let points = buildPoints( - count: count, spacing: spacing, - h: h, time: time, offset: offset - ) - - let curvePath = buildCurve(points: points) - - var fillPath = Path() - fillPath.move(to: CGPoint(x: 0, y: h)) - fillPath.addLine(to: points[0]) - fillPath.addPath(curvePath) - fillPath.addLine(to: CGPoint(x: w, y: h)) - fillPath.closeSubpath() - - context.fill(fillPath, with: .color(color.opacity(0.55))) - var strokePath = Path() - strokePath.move(to: points[0]) - strokePath.addPath(curvePath) - context.stroke(strokePath, with: .color(color.opacity(0.6)), lineWidth: 1.2) - } - - // MARK: - Build points array - - private func buildPoints( - count: Int, spacing: CGFloat, - h: CGFloat, time: TimeInterval, offset: Double - ) -> [CGPoint] { - var points: [CGPoint] = [] - for i in 0.. Path { - var path = Path() - guard points.count >= 2 else { return path } - - for i in 0..<(points.count - 1) { - let p0 = i > 0 ? points[i - 1] : points[i] - let p1 = points[i] - let p2 = points[i + 1] - let p3 = (i + 2 < points.count) ? points[i + 2] : p2 - - let cp1x = p1.x + (p2.x - p0.x) / 6.0 - let cp1y = p1.y + (p2.y - p0.y) / 6.0 - let cp2x = p2.x - (p3.x - p1.x) / 6.0 - let cp2y = p2.y - (p3.y - p1.y) / 6.0 - - path.addCurve( - to: p2, - control1: CGPoint(x: cp1x, y: cp1y), - control2: CGPoint(x: cp2x, y: cp2y) - ) - } - return path - } -}