import SwiftData import SwiftUI import UniformTypeIdentifiers /// Library tab — browse and manage imported audio files. /// Five browse modes: Songs (flat), Artists, Albums, Genres, Folders. struct LibraryView: View { @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var theme: AppTheme @Environment(\.modelContext) private var modelContext @Query(sort: \Track.dateAdded, order: .reverse) private var tracks: [Track] @State private var showImporter = false @State private var showFolderImporter = false @State private var searchText = "" @State private var showAddToPlaylist: Track? @State private var browseMode: BrowseMode = .folders @State private var sortOrder: SortOrder = .dateAdded @State private var hasScanned = false enum BrowseMode: String, CaseIterable { case folders = "Folders" case songs = "Songs" case artists = "Artists" case albums = "Albums" case genres = "Genres" } enum SortOrder: String, CaseIterable { case dateAdded = "Date Added" case title = "Title" case artist = "Artist" case bpm = "BPM" case duration = "Duration" } // MARK: - Filtered & Sorted Tracks private var filteredTracks: [Track] { let base: [Track] switch sortOrder { case .dateAdded: base = tracks case .title: base = tracks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } case .artist: base = tracks.sorted { $0.artist.localizedCaseInsensitiveCompare($1.artist) == .orderedAscending } case .bpm: base = tracks.sorted { ($0.bpm ?? 0) < ($1.bpm ?? 0) } case .duration: base = tracks.sorted { $0.duration < $1.duration } } if searchText.isEmpty { return base } let query = searchText.lowercased() return base.filter { $0.title.lowercased().contains(query) || $0.artist.lowercased().contains(query) || $0.album.lowercased().contains(query) } } // MARK: - Grouped Data private var artistGroups: [(String, [Track])] { let grouped = Dictionary(grouping: filteredTracks) { $0.artist.isEmpty ? "Unknown Artist" : $0.artist } return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } } private var albumGroups: [(String, [Track])] { let grouped = Dictionary(grouping: filteredTracks) { let artist = $0.artist.isEmpty ? "Unknown" : $0.artist let album = $0.album.isEmpty ? "Unknown Album" : $0.album return "\(artist) — \(album)" } return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } } private var genreGroups: [(String, [Track])] { let grouped = Dictionary(grouping: filteredTracks) { $0.genre.isEmpty ? "Unknown Genre" : $0.genre } return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } } private var folderGroups: [(String, [Track])] { let grouped = Dictionary(grouping: filteredTracks) { track -> String in let components = track.filePath.split(separator: "/").dropLast() return components.isEmpty ? "/" : components.joined(separator: "/") } return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } } // MARK: - Body var body: some View { NavigationStack { VStack(spacing: 0) { if !tracks.isEmpty { // Browse mode picker ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(BrowseMode.allCases, id: \.self) { mode in Button { withAnimation(.easeInOut(duration: 0.2)) { browseMode = mode } } label: { Text(mode.rawValue) .font(.subheadline.weight(browseMode == mode ? .semibold : .regular)) .padding(.horizontal, 14) .padding(.vertical, 6) .background(browseMode == mode ? theme.accent.opacity(0.2) : Color.clear) .foregroundStyle(browseMode == mode ? theme.accent : theme.secondaryText) .clipShape(Capsule()) .overlay( Capsule() .stroke(browseMode == mode ? theme.accent.opacity(0.5) : theme.separatorColor, lineWidth: 1) ) } .buttonStyle(.plain) } } .padding(.horizontal, 16) .padding(.vertical, 8) } Divider() } Group { if tracks.isEmpty { emptyState } else { mainList } } } .navigationTitle("Library") .accessibilityIdentifier("libraryView") .searchable(text: $searchText, prompt: "Search tracks") .toolbar { ToolbarItem(placement: .topBarLeading) { Menu { ForEach(SortOrder.allCases, id: \.self) { order in Button { sortOrder = order } label: { HStack { Text(order.rawValue) if sortOrder == order { Image(systemName: "checkmark") } } } } } label: { Image(systemName: "arrow.up.arrow.down") } } ToolbarItem(placement: .topBarTrailing) { Menu { Button { showImporter = true } label: { Label("Import Files", systemImage: "doc.badge.plus") } Button { showFolderImporter = true } label: { Label("Import Folder", systemImage: "folder.badge.plus") } Button { Task { await libraryManager.scanMusicDirectory() } } label: { Label("Scan Music Folder", systemImage: "arrow.clockwise") } } label: { Image(systemName: "plus") } } if !tracks.filter({ !$0.isAnalyzed }).isEmpty { ToolbarItem(placement: .topBarTrailing) { Button { Task { await libraryManager.analyzeAllTracks(tracks: tracks) } } label: { Image(systemName: "waveform.badge.magnifyingglass") } } } } .fileImporter( isPresented: $showImporter, allowedContentTypes: [ .mp3, .wav, .aiff, UTType(filenameExtension: "flac") ?? .audio, UTType(filenameExtension: "m4a") ?? .audio, UTType(filenameExtension: "ogg") ?? .audio, .audio ], allowsMultipleSelection: true ) { result in if case .success(let urls) = result { Task { await libraryManager.importFiles(urls) } } } .fileImporter( isPresented: $showFolderImporter, allowedContentTypes: [.folder], allowsMultipleSelection: true ) { result in if case .success(let urls) = result { Task { await libraryManager.importFiles(urls) } } } .sheet(item: $showAddToPlaylist) { track in AddToPlaylistSheet(track: track) .environmentObject(theme) } .sheet(isPresented: Binding( get: { showAddGroupToPlaylist != nil }, set: { if !$0 { showAddGroupToPlaylist = nil } } )) { if let tracks = showAddGroupToPlaylist { AddGroupToPlaylistSheet(tracks: tracks) .environmentObject(theme) } } .task(id: "initialScan") { // Only scan once when the view first appears, not on every tab switch guard !hasScanned else { return } hasScanned = true await libraryManager.scanMusicDirectory() } } } // MARK: - Main List @ViewBuilder private var mainList: some View { if browseMode == .folders { // Folders mode: show drill-down browser directly FolderBrowserView( folderURL: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!, title: "Music" ) } else { List { if libraryManager.isScanning { scanningRow } switch browseMode { case .songs: songsList case .artists: groupedList(groups: artistGroups, icon: "person.fill") case .albums: groupedList(groups: albumGroups, icon: "square.stack.fill") case .genres: groupedList(groups: genreGroups, icon: "guitars.fill") case .folders: EmptyView() // handled above } footer } .listStyle(.plain) } } // MARK: - Songs (flat list) private var songsList: some View { ForEach(filteredTracks) { track in trackRow(track) } } // MARK: - Grouped list @State private var showAddGroupToPlaylist: [Track]? private func groupedList(groups: [(String, [Track])], icon: String) -> some View { ForEach(groups, id: \.0) { groupName, groupTracks in Section { // Action row for the whole group groupActionRow(groupTracks) ForEach(groupTracks) { track in trackRow(track) } } header: { HStack(spacing: 8) { Image(systemName: icon) .font(.caption) .foregroundStyle(theme.accent) Text(groupName) .font(.subheadline.weight(.semibold)) .foregroundStyle(theme.groupHeaderText) Spacer() Text("\(groupTracks.count)") .font(.caption) .foregroundStyle(theme.tertiaryText) let totalDuration = groupTracks.reduce(0) { $0 + $1.duration } Text(formatDuration(totalDuration)) .font(.caption.monospacedDigit()) .foregroundStyle(theme.tertiaryText) } } } } // MARK: - Group Action Row (play all, add all to mix/playlist) private func groupActionRow(_ tracks: [Track]) -> some View { HStack(spacing: 12) { // Play all Button { if let first = tracks.first { playerVM.loadAndPlay(first) } } label: { Label("Play", systemImage: "play.fill") .font(.caption) .foregroundStyle(theme.accent) } .buttonStyle(.plain) Divider().frame(height: 16) // Mix buttons ForEach(0..<3, id: \.self) { slot in if playlistVM.mixTargets[slot] != nil { Button { for track in tracks { _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext) } playlistVM.showStatus("Added \(tracks.count) to \(playlistVM.mixTargetName(slot))") } label: { Text("\(slot + 1)") .font(.system(size: 11, weight: .bold, design: .rounded)) .frame(width: 24, height: 24) .foregroundStyle(Self.mixColors[slot]) .background(Self.mixColors[slot].opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 5)) } .buttonStyle(.plain) } } Divider().frame(height: 16) // Add to playlist Button { showAddGroupToPlaylist = tracks } label: { Image(systemName: "plus.circle") .font(.system(size: 16)) .foregroundStyle(theme.secondaryText) } .buttonStyle(.plain) Spacer() } .padding(.vertical, 4) .listRowBackground(theme.cardBackground.opacity(0.3)) } private static let mixColors: [Color] = [ Color(red: 0.95, green: 0.3, blue: 0.3), Color(red: 0.3, green: 0.75, blue: 0.95), Color(red: 0.95, green: 0.75, blue: 0.2), ] // MARK: - Track Row with actions private func trackRow(_ track: Track) -> some View { Button { playerVM.loadAndPlay(track) playerVM.showNowPlaying = true } label: { TrackRow(track: track) .contentShape(Rectangle()) } .buttonStyle(.plain) .swipeActions(edge: .trailing) { Button(role: .destructive) { libraryManager.removeTrack(track) try? modelContext.save() } label: { Label("Delete", systemImage: "trash") } Button { showAddToPlaylist = track } label: { Label("Add to...", systemImage: "plus.circle") } .tint(theme.accent) } .swipeActions(edge: .leading) { Button { _ = playlistVM.quickAddToTarget(track: track, context: modelContext) } label: { Label("Quick Add", systemImage: "star.fill") } .tint(.orange) } .contextMenu { Button { _ = playlistVM.quickAddToTarget(track: track, context: modelContext) } label: { Label("Add to Target Playlist", systemImage: "star.fill") } Button { showAddToPlaylist = track } label: { Label("Add to Playlist...", systemImage: "plus.circle") } if !track.isAnalyzed { Button { Task { await libraryManager.analyzeTrack(track) } } label: { Label("Analyze BPM & Key", systemImage: "waveform.badge.magnifyingglass") } } Divider() Button(role: .destructive) { libraryManager.removeTrack(track) try? modelContext.save() } label: { Label("Delete", systemImage: "trash") } } } // MARK: - Scanning Row private var scanningRow: some View { HStack(spacing: 12) { ProgressView() VStack(alignment: .leading, spacing: 2) { Text("Importing...") .foregroundStyle(theme.secondaryText) if !libraryManager.scanStatus.isEmpty { Text(libraryManager.scanStatus) .font(.caption) .foregroundStyle(theme.tertiaryText) .lineLimit(1) } } } } // MARK: - Footer @ViewBuilder private var footer: some View { if !filteredTracks.isEmpty { VStack(spacing: 2) { Text("\(filteredTracks.count) tracks") .font(.caption) .foregroundStyle(theme.tertiaryText) let totalDuration = filteredTracks.reduce(0) { $0 + $1.duration } Text(formatDuration(totalDuration)) .font(.caption.monospacedDigit()) .foregroundStyle(theme.tertiaryText) } .frame(maxWidth: .infinity, alignment: .center) .listRowBackground(Color.clear) } } // MARK: - Empty State private var emptyState: some View { VStack(spacing: 20) { Spacer() Image(systemName: "music.note.house") .font(.system(size: 60)) .foregroundStyle(theme.tertiaryText) Text("No tracks yet") .font(.title2) .foregroundStyle(theme.secondaryText) Text("Import your MP3, FLAC, OGG, and other audio files") .font(.subheadline) .foregroundStyle(theme.tertiaryText) .multilineTextAlignment(.center) Button { showImporter = true } label: { Label("Import Files", systemImage: "plus.circle.fill") .font(.headline) .padding(.horizontal, 24) .padding(.vertical, 12) } .buttonStyle(.borderedProminent) .tint(theme.accent) Spacer() } .padding() } // MARK: - Helpers private func formatDuration(_ total: TimeInterval) -> String { let t = Int(total) let hours = t / 3600 let minutes = (t % 3600) / 60 let seconds = t % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%d:%02d", minutes, seconds) } }