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) } }