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