|
@@ -9,6 +9,8 @@ struct UnifiedSearchResultsView: View {
|
|
|
@Binding var navStack: [CloudNavDestination]
|
|
@Binding var navStack: [CloudNavDestination]
|
|
|
|
|
|
|
|
@State private var coordinator = UnifiedSearchCoordinator()
|
|
@State private var coordinator = UnifiedSearchCoordinator()
|
|
|
|
|
+ @State private var editableQuery: String = ""
|
|
|
|
|
+ @FocusState private var isSearchFieldFocused: Bool
|
|
|
@EnvironmentObject private var theme: AppTheme
|
|
@EnvironmentObject private var theme: AppTheme
|
|
|
|
|
|
|
|
var body: some View {
|
|
var body: some View {
|
|
@@ -22,7 +24,17 @@ struct UnifiedSearchResultsView: View {
|
|
|
Group {
|
|
Group {
|
|
|
switch coordinator.phase {
|
|
switch coordinator.phase {
|
|
|
case .idle:
|
|
case .idle:
|
|
|
- EmptyView()
|
|
|
|
|
|
|
+ VStack(spacing: 8) {
|
|
|
|
|
+ Spacer()
|
|
|
|
|
+ Image(systemName: "magnifyingglass")
|
|
|
|
|
+ .font(.system(size: 32))
|
|
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
|
|
+ Text("Type a query and press Enter")
|
|
|
|
|
+ .font(.system(size: 13))
|
|
|
|
|
+ .foregroundStyle(theme.secondaryText)
|
|
|
|
|
+ Spacer()
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
|
|
|
|
case .searchingCloud:
|
|
case .searchingCloud:
|
|
|
searchingView("Searching your library...")
|
|
searchingView("Searching your library...")
|
|
@@ -48,7 +60,12 @@ struct UnifiedSearchResultsView: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
.task {
|
|
.task {
|
|
|
- coordinator.search(query: query)
|
|
|
|
|
|
|
+ editableQuery = query
|
|
|
|
|
+ if query.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 {
|
|
|
|
|
+ coordinator.search(query: query)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ isSearchFieldFocused = true
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -58,10 +75,16 @@ struct UnifiedSearchResultsView: View {
|
|
|
HStack(spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
|
Image(systemName: "magnifyingglass")
|
|
Image(systemName: "magnifyingglass")
|
|
|
.foregroundStyle(theme.secondaryText)
|
|
.foregroundStyle(theme.secondaryText)
|
|
|
- Text("\"\(query)\"")
|
|
|
|
|
|
|
+ TextField("Search library & Soulseek...", text: $editableQuery)
|
|
|
|
|
+ .textFieldStyle(.plain)
|
|
|
.font(.system(size: 13, weight: .medium))
|
|
.font(.system(size: 13, weight: .medium))
|
|
|
.foregroundStyle(theme.primaryText)
|
|
.foregroundStyle(theme.primaryText)
|
|
|
- .lineLimit(1)
|
|
|
|
|
|
|
+ .focused($isSearchFieldFocused)
|
|
|
|
|
+ .onSubmit {
|
|
|
|
|
+ let q = editableQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard q.count >= 2 else { return }
|
|
|
|
|
+ coordinator.search(query: q)
|
|
|
|
|
+ }
|
|
|
Spacer()
|
|
Spacer()
|
|
|
|
|
|
|
|
if coordinator.phase == .searchingCloud || coordinator.phase == .searchingSoulseek {
|
|
if coordinator.phase == .searchingCloud || coordinator.phase == .searchingSoulseek {
|
|
@@ -99,7 +122,7 @@ struct UnifiedSearchResultsView: View {
|
|
|
.multilineTextAlignment(.center)
|
|
.multilineTextAlignment(.center)
|
|
|
.padding(.horizontal, 20)
|
|
.padding(.horizontal, 20)
|
|
|
Button("Retry") {
|
|
Button("Retry") {
|
|
|
- coordinator.search(query: query)
|
|
|
|
|
|
|
+ coordinator.search(query: editableQuery)
|
|
|
}
|
|
}
|
|
|
.controlSize(.small)
|
|
.controlSize(.small)
|
|
|
Spacer()
|
|
Spacer()
|
|
@@ -113,7 +136,7 @@ struct UnifiedSearchResultsView: View {
|
|
|
Image(systemName: "magnifyingglass")
|
|
Image(systemName: "magnifyingglass")
|
|
|
.font(.system(size: 32))
|
|
.font(.system(size: 32))
|
|
|
.foregroundStyle(theme.tertiaryText)
|
|
.foregroundStyle(theme.tertiaryText)
|
|
|
- Text("No results for \"\(query)\"")
|
|
|
|
|
|
|
+ Text("No results for \"\(editableQuery)\"")
|
|
|
.font(.system(size: 13))
|
|
.font(.system(size: 13))
|
|
|
.foregroundStyle(theme.secondaryText)
|
|
.foregroundStyle(theme.secondaryText)
|
|
|
if !SlskdAPIClient.shared.isConfigured {
|
|
if !SlskdAPIClient.shared.isConfigured {
|
|
@@ -177,11 +200,12 @@ struct UnifiedSearchResultsView: View {
|
|
|
SoulseekSourceRow(
|
|
SoulseekSourceRow(
|
|
|
source: source,
|
|
source: source,
|
|
|
isDownloading: coordinator.downloadPhase.isActive,
|
|
isDownloading: coordinator.downloadPhase.isActive,
|
|
|
|
|
+ transfers: coordinator.activeTransfers,
|
|
|
onDownload: {
|
|
onDownload: {
|
|
|
coordinator.downloadSource(
|
|
coordinator.downloadSource(
|
|
|
- source.response,
|
|
|
|
|
- artist: guessArtist(from: query),
|
|
|
|
|
- albumName: query
|
|
|
|
|
|
|
+ source.albumSource,
|
|
|
|
|
+ artist: source.artistGuess ?? guessArtist(from: editableQuery),
|
|
|
|
|
+ albumName: source.albumName
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
@@ -234,7 +258,7 @@ struct UnifiedSearchResultsView: View {
|
|
|
|
|
|
|
|
if coordinator.downloadPhase.isActive {
|
|
if coordinator.downloadPhase.isActive {
|
|
|
Button("Cancel") {
|
|
Button("Cancel") {
|
|
|
- coordinator.cancel()
|
|
|
|
|
|
|
+ coordinator.cancelDownload()
|
|
|
}
|
|
}
|
|
|
.controlSize(.small)
|
|
.controlSize(.small)
|
|
|
} else if coordinator.downloadPhase != .idle {
|
|
} else if coordinator.downloadPhase != .idle {
|
|
@@ -299,10 +323,11 @@ struct ScoreBadge: View {
|
|
|
|
|
|
|
|
// MARK: - Soulseek Source Row
|
|
// MARK: - Soulseek Source Row
|
|
|
|
|
|
|
|
-/// A single Soulseek search result showing quality score, format, file count, and download button.
|
|
|
|
|
|
|
+/// A single Soulseek search result — one directory (album) from a user.
|
|
|
struct SoulseekSourceRow: View {
|
|
struct SoulseekSourceRow: View {
|
|
|
let source: ScoredSoulseekSource
|
|
let source: ScoredSoulseekSource
|
|
|
var isDownloading: Bool = false
|
|
var isDownloading: Bool = false
|
|
|
|
|
+ var transfers: [String: SlskdTransfer] = [:]
|
|
|
let onDownload: () -> Void
|
|
let onDownload: () -> Void
|
|
|
|
|
|
|
|
@EnvironmentObject private var theme: AppTheme
|
|
@EnvironmentObject private var theme: AppTheme
|
|
@@ -314,32 +339,55 @@ struct SoulseekSourceRow: View {
|
|
|
// Quality score badge
|
|
// Quality score badge
|
|
|
ScoreBadge(score: source.score)
|
|
ScoreBadge(score: source.score)
|
|
|
|
|
|
|
|
- // Source info
|
|
|
|
|
|
|
+ // Album info
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
+ // Line 1: Artist — Album
|
|
|
|
|
+ HStack(spacing: 0) {
|
|
|
|
|
+ if let artist = source.artistGuess {
|
|
|
|
|
+ Text(artist)
|
|
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(theme.secondaryText)
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ Text(" — ")
|
|
|
|
|
+ .font(.system(size: 12))
|
|
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
|
|
+ }
|
|
|
|
|
+ Text(source.albumName)
|
|
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(theme.primaryText)
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Line 2: Format · files · size · ⚡
|
|
|
HStack(spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
|
Text(source.formatDisplay)
|
|
Text(source.formatDisplay)
|
|
|
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
|
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
|
|
.foregroundStyle(formatColor)
|
|
.foregroundStyle(formatColor)
|
|
|
|
|
+ Text("·")
|
|
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
Text("\(source.audioFileCount) files")
|
|
Text("\(source.audioFileCount) files")
|
|
|
.font(.system(size: 11))
|
|
.font(.system(size: 11))
|
|
|
.foregroundStyle(theme.secondaryText)
|
|
.foregroundStyle(theme.secondaryText)
|
|
|
|
|
+ Text("·")
|
|
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
Text(source.formattedTotalSize)
|
|
Text(source.formattedTotalSize)
|
|
|
.font(.system(size: 11))
|
|
.font(.system(size: 11))
|
|
|
.foregroundStyle(theme.secondaryText)
|
|
.foregroundStyle(theme.secondaryText)
|
|
|
- if source.response.hasFreeUploadSlot {
|
|
|
|
|
|
|
+ if source.albumSource.hasFreeUploadSlot {
|
|
|
Image(systemName: "bolt.fill")
|
|
Image(systemName: "bolt.fill")
|
|
|
.font(.system(size: 9))
|
|
.font(.system(size: 9))
|
|
|
.foregroundStyle(Color(red: 0.2, green: 0.9, blue: 0.4))
|
|
.foregroundStyle(Color(red: 0.2, green: 0.9, blue: 0.4))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Line 3: Username · Queue
|
|
|
HStack(spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
|
- Text(source.response.username)
|
|
|
|
|
|
|
+ Text(source.username)
|
|
|
.font(.system(size: 10))
|
|
.font(.system(size: 10))
|
|
|
.foregroundStyle(theme.tertiaryText)
|
|
.foregroundStyle(theme.tertiaryText)
|
|
|
.lineLimit(1)
|
|
.lineLimit(1)
|
|
|
- if source.response.queueLength > 0 {
|
|
|
|
|
- Text("Queue: \(source.response.queueLength)")
|
|
|
|
|
|
|
+ if source.albumSource.queueLength > 0 {
|
|
|
|
|
+ Text("Queue: \(source.albumSource.queueLength)")
|
|
|
.font(.system(size: 10))
|
|
.font(.system(size: 10))
|
|
|
.foregroundStyle(theme.tertiaryText)
|
|
.foregroundStyle(theme.tertiaryText)
|
|
|
}
|
|
}
|
|
@@ -369,7 +417,7 @@ struct SoulseekSourceRow: View {
|
|
|
.buttonStyle(.plain)
|
|
.buttonStyle(.plain)
|
|
|
.disabled(isDownloading || source.score < 30)
|
|
.disabled(isDownloading || source.score < 30)
|
|
|
.help(source.score >= 80
|
|
.help(source.score >= 80
|
|
|
- ? "Download this source"
|
|
|
|
|
|
|
+ ? "Download \(source.albumName)"
|
|
|
: "Quality too low (score: \(source.score))")
|
|
: "Quality too low (score: \(source.score))")
|
|
|
}
|
|
}
|
|
|
.padding(.vertical, 3)
|
|
.padding(.vertical, 3)
|
|
@@ -379,6 +427,11 @@ struct SoulseekSourceRow: View {
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
|
ForEach(source.audioFiles, id: \.filename) { file in
|
|
ForEach(source.audioFiles, id: \.filename) { file in
|
|
|
HStack(spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
|
|
|
+ // Transfer status icon
|
|
|
|
|
+ if let transfer = transfers[file.filename] {
|
|
|
|
|
+ transferIcon(transfer)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
let name = file.filename
|
|
let name = file.filename
|
|
|
.replacingOccurrences(of: "\\", with: "/")
|
|
.replacingOccurrences(of: "\\", with: "/")
|
|
|
.split(separator: "/").last.map(String.init) ?? file.filename
|
|
.split(separator: "/").last.map(String.init) ?? file.filename
|
|
@@ -387,9 +440,18 @@ struct SoulseekSourceRow: View {
|
|
|
.foregroundStyle(theme.secondaryText)
|
|
.foregroundStyle(theme.secondaryText)
|
|
|
.lineLimit(1)
|
|
.lineLimit(1)
|
|
|
Spacer()
|
|
Spacer()
|
|
|
- Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file))
|
|
|
|
|
- .font(.system(size: 10, design: .monospaced))
|
|
|
|
|
- .foregroundStyle(theme.tertiaryText)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Per-file progress or size
|
|
|
|
|
+ if let transfer = transfers[file.filename], !transfer.isComplete, !transfer.isFailed {
|
|
|
|
|
+ Text("\(Int(transfer.percentComplete))%")
|
|
|
|
|
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
|
|
|
|
|
+ .foregroundStyle(theme.accent)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file))
|
|
|
|
|
+ .font(.system(size: 10, design: .monospaced))
|
|
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if let br = file.bitRate, br > 0 {
|
|
if let br = file.bitRate, br > 0 {
|
|
|
Text("\(br)k")
|
|
Text("\(br)k")
|
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.font(.system(size: 10, design: .monospaced))
|
|
@@ -413,4 +475,20 @@ struct SoulseekSourceRow: View {
|
|
|
default: theme.tertiaryText
|
|
default: theme.tertiaryText
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ @ViewBuilder
|
|
|
|
|
+ private func transferIcon(_ transfer: SlskdTransfer) -> some View {
|
|
|
|
|
+ if transfer.isComplete {
|
|
|
|
|
+ Image(systemName: "checkmark.circle.fill")
|
|
|
|
|
+ .font(.system(size: 9))
|
|
|
|
|
+ .foregroundStyle(.green)
|
|
|
|
|
+ } else if transfer.isFailed {
|
|
|
|
|
+ Image(systemName: "xmark.circle.fill")
|
|
|
|
|
+ .font(.system(size: 9))
|
|
|
|
|
+ .foregroundStyle(.red)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ProgressView()
|
|
|
|
|
+ .controlSize(.mini)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|