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:
parent
34f9cb4232
commit
f39eaff281
12 changed files with 365 additions and 394 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue