diff --git a/iOS/App/NavidromePlayerApp.swift b/iOS/App/NavidromePlayerApp.swift
index 52b7a33..a931dd6 100644
--- a/iOS/App/NavidromePlayerApp.swift
+++ b/iOS/App/NavidromePlayerApp.swift
@@ -167,7 +167,7 @@ struct RootView: View {
SmartDJCache.shared.loadBulkCache() // disk → memory (instant)
Task.detached(priority: .utility) {
do {
- let api = try CompanionAPIService()
+ let api = CompanionAPIService.shared
let profiles = try await api.fetchAllProfiles()
SmartDJCache.shared.bulkImport(profiles)
} catch {
diff --git a/iOS/Data/BackupManager.swift b/iOS/Data/BackupManager.swift
index 0ece00c..d686422 100644
--- a/iOS/Data/BackupManager.swift
+++ b/iOS/Data/BackupManager.swift
@@ -84,12 +84,18 @@ class BackupManager {
)
try writeJSON(manifest, to: tempDir.appendingPathComponent("manifest.json"))
- // 2. Server configs (passwords stripped — already blanked in UserDefaults)
- if let serverData = UserDefaults.standard.data(forKey: "navidrome_servers") {
- try serverData.write(to: tempDir.appendingPathComponent("servers.json"))
+ // 2. Server configs — decode, strip passwords, re-encode
+ if let serverData = UserDefaults.standard.data(forKey: "navidrome_servers"),
+ var servers = try? JSONDecoder().decode([ServerConfig].self, from: serverData) {
+ for i in servers.indices { servers[i].password = "" }
+ let strippedData = try JSONEncoder().encode(servers)
+ try strippedData.write(to: tempDir.appendingPathComponent("servers.json"))
}
- if let activeData = UserDefaults.standard.data(forKey: "navidrome_active_server") {
- try activeData.write(to: tempDir.appendingPathComponent("active_server.json"))
+ if let activeData = UserDefaults.standard.data(forKey: "navidrome_active_server"),
+ var active = try? JSONDecoder().decode(ServerConfig.self, from: activeData) {
+ active.password = ""
+ let strippedData = try JSONEncoder().encode(active)
+ try strippedData.write(to: tempDir.appendingPathComponent("active_server.json"))
}
// 3. App settings
@@ -213,7 +219,9 @@ class BackupManager {
throw BackupError.invalidManifest
}
let manifestData = try Data(contentsOf: manifestURL)
- let manifest = try JSONDecoder().decode(BackupManifest.self, from: manifestData)
+ let manifestDecoder = JSONDecoder()
+ manifestDecoder.dateDecodingStrategy = .iso8601
+ let manifest = try manifestDecoder.decode(BackupManifest.self, from: manifestData)
guard manifest.version <= BackupManifest.currentVersion else {
throw BackupError.versionMismatch(manifest.version)
diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist
index 1fe5504..3b58ad9 100644
--- a/iOS/Resources/Info.plist
+++ b/iOS/Resources/Info.plist
@@ -78,6 +78,8 @@
Identify songs playing nearby using Shazam.
+ LSSupportsOpeningDocumentsInPlace
+
CFBundleDocumentTypes
diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift
index a3d7b40..e028201 100644
--- a/iOS/Views/Common/MainTabView.swift
+++ b/iOS/Views/Common/MainTabView.swift
@@ -636,10 +636,20 @@ struct MiniPlayerBar: View {
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
- Text(audioPlayer.currentSong?.artist ?? "")
- .font(.system(size: 12))
- .foregroundColor(.white.opacity(0.6))
- .lineLimit(1)
+ if let lyricLine = LyricsManager.shared.currentLineText,
+ LyricsManager.shared.currentLyrics != nil {
+ Text("♪ \(lyricLine)")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.8))
+ .lineLimit(1)
+ .transition(.push(from: .bottom))
+ .id("lyric_\(LyricsManager.shared.currentLineIndex)")
+ } else {
+ Text(audioPlayer.currentSong?.artist ?? "")
+ .font(.system(size: 12))
+ .foregroundColor(.white.opacity(0.6))
+ .lineLimit(1)
+ }
}
Spacer()
@@ -729,10 +739,19 @@ struct DynamicIslandView: View {
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
- Text(audioPlayer.currentSong?.artist ?? "")
- .font(.system(size: 9))
- .foregroundColor(.white.opacity(0.55))
- .lineLimit(1)
+ if let lyricLine = LyricsManager.shared.currentLineText,
+ LyricsManager.shared.currentLyrics != nil {
+ Text("♪ \(lyricLine)")
+ .font(.system(size: 9, weight: .medium))
+ .foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.8))
+ .lineLimit(1)
+ .id("lyric_compact_\(LyricsManager.shared.currentLineIndex)")
+ } else {
+ Text(audioPlayer.currentSong?.artist ?? "")
+ .font(.system(size: 9))
+ .foregroundColor(.white.opacity(0.55))
+ .lineLimit(1)
+ }
}
.frame(maxWidth: 140, alignment: .leading)
}
diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift
index f8cb685..1dcee1e 100644
--- a/iOS/Views/NowPlaying/NowPlayingView.swift
+++ b/iOS/Views/NowPlaying/NowPlayingView.swift
@@ -229,9 +229,12 @@ struct NowPlayingView: View {
@State private var shazamPreviewURL: URL?
@State private var isShazaming = false
@State private var showShazamResult = false
+ @State private var showLyricsSearch = false
+ @State private var showLyricsEditor = false
@StateObject private var albumColors = AlbumColorExtractor.shared
@StateObject private var radioBuffer = RadioStreamBuffer.shared
@ObservedObject private var visSettings = VisualizerSettings.shared
+ @ObservedObject private var lyricsManager = LyricsManager.shared
@State private var dragOffset: CGFloat = 0
@@ -341,6 +344,17 @@ struct NowPlayingView: View {
ellipsisActionsSheet
.presentationDetents([.medium, .large])
}
+ .sheet(isPresented: $showLyricsSearch) {
+ LyricsSearchSheet(initialQuery: "\(audioPlayer.currentSong?.artist ?? "") \(audioPlayer.currentSong?.title ?? "")")
+ }
+ .sheet(isPresented: $showLyricsEditor) {
+ if let lyrics = lyricsManager.currentLyrics {
+ LyricsEditorView(lyrics: lyrics, songPath: audioPlayer.currentSong?.path)
+ }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .openLyricsSearch)) { _ in
+ showLyricsSearch = true
+ }
.onAppear {
dragOffset = 0
isStarred = audioPlayer.currentSong?.starred != nil
@@ -365,7 +379,17 @@ struct NowPlayingView: View {
VStack(spacing: 0) {
topBar
Spacer()
- albumArt(size: 300)
+
+ // Album art OR lyrics overlay
+ if lyricsManager.isLyricsVisible {
+ lyricsOverlay
+ .frame(height: 340)
+ .transition(.opacity)
+ } else {
+ albumArt(size: 300)
+ .transition(.opacity)
+ }
+
Spacer()
songInfo
progressBar.padding(.top, 16)
@@ -725,6 +749,33 @@ struct NowPlayingView: View {
}
}
+ // MARK: - Lyrics Overlay
+
+ /// Replaces the cover art area when lyrics are active.
+ /// The visualizer continues behind — a blur panel fades in where lyrics text starts.
+ private var lyricsOverlay: some View {
+ ZStack {
+ // Blur panel: .ultraThinMaterial with a gradient mask so the top
+ // fades to transparent (visualizer shows through), bottom is opaque
+ Rectangle()
+ .fill(.ultraThinMaterial)
+ .mask(
+ VStack(spacing: 0) {
+ LinearGradient(
+ colors: [.clear, .white],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(height: 50)
+ Color.white
+ }
+ )
+
+ // Lyrics content on top of the blur
+ LyricsOverlayView()
+ }
+ }
+
// MARK: - Album Art
private func albumArt(size: CGFloat) -> some View {
@@ -1028,6 +1079,17 @@ struct NowPlayingView: View {
Image(systemName: "list.bullet").font(.system(size: isLandscape ? 14 : 16)).foregroundColor(.gray)
}.frame(width: 40, height: 40)
Spacer()
+ // Lyrics toggle
+ Button(action: {
+ withAnimation(.easeInOut(duration: 0.3)) {
+ lyricsManager.isLyricsVisible.toggle()
+ }
+ }) {
+ Image(systemName: "quote.bubble")
+ .font(.system(size: isLandscape ? 14 : 16))
+ .foregroundColor(lyricsManager.isLyricsVisible ? accentPink : .gray)
+ }.frame(width: 40, height: 40)
+ Spacer()
AirPlayButton().frame(width: 40, height: 40)
}
}