Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
392 lines
15 KiB
Swift
392 lines
15 KiB
Swift
import SwiftUI
|
|
|
|
struct LoginView: View {
|
|
@EnvironmentObject var serverManager: ServerManager
|
|
|
|
@State private var showingAddServer = false
|
|
@State private var editingServer: ServerConfig?
|
|
@State private var isConnecting = false
|
|
@State private var errorMessage: String?
|
|
|
|
// iOS 8 Music app accent
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
// Gradient background
|
|
LinearGradient(
|
|
gradient: Gradient(colors: [
|
|
Color(white: 0.12),
|
|
Color(white: 0.06)
|
|
]),
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
// Header
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "music.note.house.fill")
|
|
.font(.system(size: 56))
|
|
.foregroundColor(accentPink)
|
|
|
|
Text("Navidrome")
|
|
.font(.system(size: 34, weight: .bold))
|
|
.foregroundColor(.white)
|
|
|
|
Text("Connect to your music server")
|
|
.font(.system(size: 15))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(.top, 60)
|
|
.padding(.bottom, 40)
|
|
|
|
// Server list
|
|
if !serverManager.servers.isEmpty {
|
|
VStack(spacing: 1) {
|
|
ForEach(serverManager.servers) { server in
|
|
ServerRow(
|
|
server: server,
|
|
isActive: serverManager.activeServer?.id == server.id,
|
|
isConnecting: isConnecting && serverManager.activeServer?.id == server.id,
|
|
onConnect: { connectTo(server) },
|
|
onEdit: { editingServer = server },
|
|
onDelete: { delete(server) }
|
|
)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
// Error message
|
|
if let error = errorMessage {
|
|
Text(error)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.red)
|
|
.padding(.top, 12)
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Add Server Button
|
|
Button(action: { showingAddServer = true }) {
|
|
HStack {
|
|
Image(systemName: "plus.circle.fill")
|
|
Text("Add Server")
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(accentPink)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(12)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 40)
|
|
}
|
|
}
|
|
.navigationBarHidden(true)
|
|
.sheet(isPresented: $showingAddServer) {
|
|
AddServerSheet(isPresented: $showingAddServer)
|
|
}
|
|
.sheet(item: $editingServer) { server in
|
|
AddServerSheet(isPresented: Binding(
|
|
get: { editingServer != nil },
|
|
set: { if !$0 { editingServer = nil } }
|
|
), existingServer: server)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func connectTo(_ server: ServerConfig) {
|
|
isConnecting = true
|
|
errorMessage = nil
|
|
Task {
|
|
let success = await serverManager.connect(to: server)
|
|
await MainActor.run {
|
|
isConnecting = false
|
|
if !success {
|
|
if case .error(let msg) = serverManager.connectionState {
|
|
errorMessage = msg
|
|
} else {
|
|
errorMessage = "Connection failed"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func delete(_ server: ServerConfig) {
|
|
serverManager.removeServer(server)
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Row
|
|
struct ServerRow: View {
|
|
let server: ServerConfig
|
|
let isActive: Bool
|
|
let isConnecting: Bool
|
|
let onConnect: () -> Void
|
|
let onEdit: () -> Void
|
|
let onDelete: () -> Void
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
Button(action: onConnect) {
|
|
HStack(spacing: 14) {
|
|
// Server icon
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(
|
|
isActive
|
|
? accentPink.opacity(0.2)
|
|
: Color.white.opacity(0.08)
|
|
)
|
|
.frame(width: 44, height: 44)
|
|
|
|
Image(systemName: "server.rack")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(isActive ? accentPink : .gray)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(server.name)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundColor(.white)
|
|
|
|
Text(server.url)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
|
|
Text(server.username)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.gray.opacity(0.7))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isConnecting {
|
|
ProgressView()
|
|
.tint(accentPink)
|
|
} else if isActive {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.background(Color(white: 0.15))
|
|
}
|
|
.contextMenu {
|
|
Button("Edit", action: onEdit)
|
|
Button("Delete", role: .destructive, action: onDelete)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Add/Edit Server Sheet
|
|
struct AddServerSheet: View {
|
|
@EnvironmentObject var serverManager: ServerManager
|
|
@Binding var isPresented: Bool
|
|
var existingServer: ServerConfig?
|
|
|
|
@State private var name: String = ""
|
|
@State private var url: String = ""
|
|
@State private var username: String = ""
|
|
@State private var password: String = ""
|
|
@State private var isTesting = false
|
|
@State private var testResult: String?
|
|
@State private var testSuccess = false
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color(white: 0.1).ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
// Server Name
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("SERVER NAME")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.gray)
|
|
|
|
TextField("My Server", text: $name)
|
|
.textFieldStyle(DarkFieldStyle())
|
|
}
|
|
|
|
// URL
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("SERVER URL")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.gray)
|
|
|
|
TextField("https://music.example.com", text: $url)
|
|
.textFieldStyle(DarkFieldStyle())
|
|
.keyboardType(.URL)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
}
|
|
|
|
// Username
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("USERNAME")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.gray)
|
|
|
|
TextField("username", text: $username)
|
|
.textFieldStyle(DarkFieldStyle())
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
}
|
|
|
|
// Password
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("PASSWORD")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.gray)
|
|
|
|
SecureField("password", text: $password)
|
|
.textFieldStyle(DarkFieldStyle())
|
|
}
|
|
|
|
// Test Connection
|
|
Button(action: testConnection) {
|
|
HStack {
|
|
if isTesting {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.scaleEffect(0.8)
|
|
} else {
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
}
|
|
Text("Test Connection")
|
|
.fontWeight(.medium)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(Color.white.opacity(0.15))
|
|
.foregroundColor(.white)
|
|
.cornerRadius(10)
|
|
}
|
|
.disabled(url.isEmpty || username.isEmpty)
|
|
|
|
if let result = testResult {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: testSuccess ? "checkmark.circle.fill" : "xmark.circle.fill")
|
|
Text(result)
|
|
}
|
|
.font(.system(size: 13))
|
|
.foregroundColor(testSuccess ? .green : .red)
|
|
}
|
|
|
|
// Tip about multiple servers
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(.gray)
|
|
Text("You can add multiple servers. For example, a public and private URL to the same Navidrome instance.")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(12)
|
|
.background(Color.white.opacity(0.05))
|
|
.cornerRadius(8)
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
.navigationTitle(existingServer == nil ? "Add Server" : "Edit Server")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") { isPresented = false }
|
|
.foregroundColor(accentPink)
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Save") { save() }
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(accentPink)
|
|
.disabled(name.isEmpty || url.isEmpty || username.isEmpty)
|
|
}
|
|
}
|
|
.onAppear {
|
|
if let s = existingServer {
|
|
name = s.name
|
|
url = s.url
|
|
username = s.username
|
|
password = s.password
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func testConnection() {
|
|
isTesting = true
|
|
testResult = nil
|
|
let testServer = ServerConfig(
|
|
name: name, url: url, username: username, password: password
|
|
)
|
|
Task {
|
|
do {
|
|
let success = try await serverManager.client.ping(server: testServer)
|
|
await MainActor.run {
|
|
isTesting = false
|
|
testSuccess = success
|
|
testResult = success ? "Connection successful!" : "Server returned error"
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isTesting = false
|
|
testSuccess = false
|
|
testResult = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func save() {
|
|
if var existing = existingServer {
|
|
existing.name = name
|
|
existing.url = url
|
|
existing.username = username
|
|
existing.password = password
|
|
serverManager.updateServer(existing)
|
|
} else {
|
|
let server = ServerConfig(
|
|
name: name, url: url, username: username, password: password
|
|
)
|
|
serverManager.addServer(server)
|
|
}
|
|
isPresented = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Dark Text Field Style
|
|
struct DarkFieldStyle: TextFieldStyle {
|
|
func _body(configuration: TextField<Self._Label>) -> some View {
|
|
configuration
|
|
.padding(12)
|
|
.background(Color.white.opacity(0.08))
|
|
.cornerRadius(10)
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
#if DEBUG
|
|
struct LoginView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
LoginView()
|
|
.environmentObject(ServerManager.shared)
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
}
|
|
#endif
|