3-Dot Menu Grid Layout — Replaced List-based sheet with a grid of button pairs: Instant Mix | Add to Playlist, Go to Album | Go to Artist, Download/Remove | Send to Watch, Get Info | Edit Tags.

This commit is contained in:
Dallas Groot 2026-04-03 15:48:37 -07:00
parent 34f9cb4232
commit f39eaff281
12 changed files with 365 additions and 394 deletions

View file

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

View file

@ -2,64 +2,62 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Navidrome</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>Identify songs playing on the radio using Shazam</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose cover images for your Album Art, Radio Stations and Artist Covers.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
<string>nearby-interaction</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Navidrome</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose cover images for your radio stations</string>
<key>NSMicrophoneUsageDescription</key>
<string>Identify songs playing on the radio using Shazam</string>
</dict>
</plist>

View file

@ -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)

View file

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

View file

@ -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<String>, keyboard: UIKeyboardType = .default) -> some View {
HStack {
private func checkField(_ label: String, text: Binding<String>, isEnabled: Binding<Bool>, 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))
}

View file

@ -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")
}

View file

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

View file

@ -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))

View file

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

View file

@ -2,46 +2,47 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Navidrome</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSHealthShareUsageDescription</key>
<string>Used to maintain background audio playback through the speaker.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Used to maintain background audio playback through the speaker.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>WKApplication</key>
<true/>
<key>WKBackgroundModes</key>
<array>
<string>workout-processing</string>
</array>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.navidromeplayer.app</string>
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Navidrome</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.navidromeplayer.app</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>WKBackgroundModes</key>
<array>
<string>self-care</string>
<string>workout-processing</string>
</array>
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSHealthShareUsageDescription</key>
<string>Used to maintain background audio playback through the speaker.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Used to maintain background audio playback through the speaker.</string>
</dict>
</plist>

View file

@ -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) {

View file

@ -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..<count {
let x = CGFloat(i) * spacing
let baseAmp: CGFloat = isPlaying ? CGFloat(levels[i]) : 0.03
let sinArg = time * 1.8 + offset * 10 + Double(i) * 0.4
let breath = CGFloat(sin(sinArg)) * 0.05
let amp = max(0.02, baseAmp + breath)
let y = h - (h * amp * 0.85)
points.append(CGPoint(x: x, y: y))
}
return points
}
// MARK: - Catmull-Rom spline
private func buildCurve(points: [CGPoint]) -> 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
}
}