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
- }
-}