| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- import SwiftData
- import SwiftUI
- /// Global search across all playlists — find any track by artist, title, or album.
- struct GlobalSearchSheet: View {
- let playlists: [Playlist]
- @Environment(PlayerViewModel.self) private var playerVM
- @Environment(PlaylistViewModel.self) private var playlistVM
- @EnvironmentObject private var theme: AppTheme
- @Environment(\.modelContext) private var modelContext
- @Environment(\.dismiss) private var dismiss
- @State private var searchText = ""
- private var results: [(playlist: Playlist, entry: PlaylistEntry, track: Track)] {
- guard searchText.count >= 2 else { return [] }
- let query = searchText.lowercased()
- var matches: [(Playlist, PlaylistEntry, Track)] = []
- for playlist in playlists {
- for entry in playlist.sortedEntries {
- guard let track = entry.track else { continue }
- if track.title.lowercased().contains(query) ||
- track.artist.lowercased().contains(query) ||
- track.album.lowercased().contains(query) {
- matches.append((playlist, entry, track))
- }
- }
- }
- return matches
- }
- var body: some View {
- VStack(spacing: 0) {
- // Header
- HStack {
- Image(systemName: "magnifyingglass")
- .foregroundStyle(theme.secondaryText)
- TextField("Search all playlists...", text: $searchText)
- .textFieldStyle(.plain)
- .font(.system(size: 14))
- if !searchText.isEmpty {
- Button {
- searchText = ""
- } label: {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(theme.tertiaryText)
- }
- .buttonStyle(.plain)
- }
- Button("Done") { dismiss() }
- .keyboardShortcut(.cancelAction)
- }
- .padding(12)
- Divider()
- // Results
- if searchText.count < 2 {
- VStack {
- Spacer()
- Text("Type at least 2 characters to search")
- .font(.system(size: 12))
- .foregroundStyle(theme.tertiaryText)
- Spacer()
- }
- } else if results.isEmpty {
- VStack {
- Spacer()
- Text("No results for \"\(searchText)\"")
- .font(.system(size: 12))
- .foregroundStyle(theme.tertiaryText)
- Spacer()
- }
- } else {
- List {
- ForEach(results, id: \.entry.id) { item in
- SearchResultRow(
- track: item.track,
- playlistName: item.playlist.name,
- onPlay: {
- playerVM.loadAndPlay(item.track, entryID: item.entry.id, playlist: item.playlist)
- dismiss()
- },
- onAddToMix: { slot in
- _ = playlistVM.quickAddToMix(slot: slot, track: item.track, context: modelContext)
- }
- )
- }
- }
- .listStyle(.inset)
- }
- // Footer
- HStack {
- Text("\(results.count) results")
- .font(.system(size: 11))
- .foregroundStyle(theme.tertiaryText)
- Spacer()
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 6)
- }
- .frame(width: 600, height: 450)
- }
- }
- // MARK: - Search Result Row
- private struct SearchResultRow: View {
- let track: Track
- let playlistName: String
- let onPlay: () -> Void
- let onAddToMix: (Int) -> Void
- @EnvironmentObject private var theme: AppTheme
- @Environment(PlaylistViewModel.self) private var playlistVM
- var body: some View {
- HStack(spacing: 8) {
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 4) {
- if !track.artist.isEmpty {
- Text(track.artist)
- .font(.system(size: 12))
- .foregroundStyle(theme.secondaryText)
- Text("–")
- .font(.system(size: 12))
- .foregroundStyle(theme.tertiaryText)
- }
- Text(track.title)
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(theme.primaryText)
- }
- HStack(spacing: 8) {
- Text("in \(playlistName)")
- .font(.system(size: 10))
- .foregroundStyle(theme.tertiaryText)
- if !track.album.isEmpty {
- Text("· \(track.album)")
- .font(.system(size: 10))
- .foregroundStyle(theme.tertiaryText)
- }
- Text("· \(track.formattedDuration)")
- .font(.system(size: 10, design: .monospaced))
- .foregroundStyle(theme.tertiaryText)
- }
- }
- Spacer()
- Button("Play") { onPlay() }
- .controlSize(.small)
- // Mix target buttons
- HStack(spacing: 3) {
- ForEach(0..<3, id: \.self) { slot in
- let hasTarget = playlistVM.mixTargets[slot] != nil
- Button {
- onAddToMix(slot)
- } label: {
- Text("\(slot + 1)")
- .font(.system(size: 10, weight: .bold, design: .rounded))
- .frame(width: 20, height: 20)
- .foregroundStyle(hasTarget ? mixTargetColors[slot] : theme.tertiaryText)
- .background(hasTarget ? mixTargetColors[slot].opacity(0.15) : theme.tertiaryText.opacity(0.08))
- .clipShape(RoundedRectangle(cornerRadius: 4))
- }
- .buttonStyle(.plain)
- .help("Add to \(playlistVM.mixTargetName(slot))")
- }
- }
- }
- .padding(.vertical, 2)
- }
- }
|