NavidromeApp/iOS/Views/Login/LoginView.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
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
2026-03-28 20:49:47 +00:00

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