LibraryView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. import SwiftData
  2. import SwiftUI
  3. import UniformTypeIdentifiers
  4. /// Library tab — browse and manage imported audio files.
  5. /// Five browse modes: Songs (flat), Artists, Albums, Genres, Folders.
  6. struct LibraryView: View {
  7. @Environment(PlayerViewModel.self) private var playerVM
  8. @Environment(PlaylistViewModel.self) private var playlistVM
  9. @EnvironmentObject private var libraryManager: LibraryManager
  10. @EnvironmentObject private var theme: AppTheme
  11. @Environment(\.modelContext) private var modelContext
  12. @Query(sort: \Track.dateAdded, order: .reverse) private var tracks: [Track]
  13. @State private var showImporter = false
  14. @State private var showFolderImporter = false
  15. @State private var searchText = ""
  16. @State private var showAddToPlaylist: Track?
  17. @State private var browseMode: BrowseMode = .folders
  18. @State private var sortOrder: SortOrder = .dateAdded
  19. @State private var hasScanned = false
  20. enum BrowseMode: String, CaseIterable {
  21. case folders = "Folders"
  22. case songs = "Songs"
  23. case artists = "Artists"
  24. case albums = "Albums"
  25. case genres = "Genres"
  26. }
  27. enum SortOrder: String, CaseIterable {
  28. case dateAdded = "Date Added"
  29. case title = "Title"
  30. case artist = "Artist"
  31. case bpm = "BPM"
  32. case duration = "Duration"
  33. }
  34. // MARK: - Filtered & Sorted Tracks
  35. private var filteredTracks: [Track] {
  36. let base: [Track]
  37. switch sortOrder {
  38. case .dateAdded: base = tracks
  39. case .title: base = tracks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
  40. case .artist: base = tracks.sorted { $0.artist.localizedCaseInsensitiveCompare($1.artist) == .orderedAscending }
  41. case .bpm: base = tracks.sorted { ($0.bpm ?? 0) < ($1.bpm ?? 0) }
  42. case .duration: base = tracks.sorted { $0.duration < $1.duration }
  43. }
  44. if searchText.isEmpty { return base }
  45. let query = searchText.lowercased()
  46. return base.filter {
  47. $0.title.lowercased().contains(query) ||
  48. $0.artist.lowercased().contains(query) ||
  49. $0.album.lowercased().contains(query)
  50. }
  51. }
  52. // MARK: - Grouped Data
  53. private var artistGroups: [(String, [Track])] {
  54. let grouped = Dictionary(grouping: filteredTracks) { $0.artist.isEmpty ? "Unknown Artist" : $0.artist }
  55. return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
  56. }
  57. private var albumGroups: [(String, [Track])] {
  58. let grouped = Dictionary(grouping: filteredTracks) {
  59. let artist = $0.artist.isEmpty ? "Unknown" : $0.artist
  60. let album = $0.album.isEmpty ? "Unknown Album" : $0.album
  61. return "\(artist) — \(album)"
  62. }
  63. return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
  64. }
  65. private var genreGroups: [(String, [Track])] {
  66. let grouped = Dictionary(grouping: filteredTracks) { $0.genre.isEmpty ? "Unknown Genre" : $0.genre }
  67. return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
  68. }
  69. private var folderGroups: [(String, [Track])] {
  70. let grouped = Dictionary(grouping: filteredTracks) { track -> String in
  71. let components = track.filePath.split(separator: "/").dropLast()
  72. return components.isEmpty ? "/" : components.joined(separator: "/")
  73. }
  74. return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
  75. }
  76. // MARK: - Body
  77. var body: some View {
  78. NavigationStack {
  79. VStack(spacing: 0) {
  80. if !tracks.isEmpty {
  81. // Browse mode picker
  82. ScrollView(.horizontal, showsIndicators: false) {
  83. HStack(spacing: 8) {
  84. ForEach(BrowseMode.allCases, id: \.self) { mode in
  85. Button {
  86. withAnimation(.easeInOut(duration: 0.2)) {
  87. browseMode = mode
  88. }
  89. } label: {
  90. Text(mode.rawValue)
  91. .font(.subheadline.weight(browseMode == mode ? .semibold : .regular))
  92. .padding(.horizontal, 14)
  93. .padding(.vertical, 6)
  94. .background(browseMode == mode ? theme.accent.opacity(0.2) : Color.clear)
  95. .foregroundStyle(browseMode == mode ? theme.accent : theme.secondaryText)
  96. .clipShape(Capsule())
  97. .overlay(
  98. Capsule()
  99. .stroke(browseMode == mode ? theme.accent.opacity(0.5) : theme.separatorColor, lineWidth: 1)
  100. )
  101. }
  102. .buttonStyle(.plain)
  103. }
  104. }
  105. .padding(.horizontal, 16)
  106. .padding(.vertical, 8)
  107. }
  108. Divider()
  109. }
  110. Group {
  111. if tracks.isEmpty {
  112. emptyState
  113. } else {
  114. mainList
  115. }
  116. }
  117. }
  118. .navigationTitle("Library")
  119. .accessibilityIdentifier("libraryView")
  120. .searchable(text: $searchText, prompt: "Search tracks")
  121. .toolbar {
  122. ToolbarItem(placement: .topBarLeading) {
  123. Menu {
  124. ForEach(SortOrder.allCases, id: \.self) { order in
  125. Button {
  126. sortOrder = order
  127. } label: {
  128. HStack {
  129. Text(order.rawValue)
  130. if sortOrder == order {
  131. Image(systemName: "checkmark")
  132. }
  133. }
  134. }
  135. }
  136. } label: {
  137. Image(systemName: "arrow.up.arrow.down")
  138. }
  139. }
  140. ToolbarItem(placement: .topBarTrailing) {
  141. Menu {
  142. Button {
  143. showImporter = true
  144. } label: {
  145. Label("Import Files", systemImage: "doc.badge.plus")
  146. }
  147. Button {
  148. showFolderImporter = true
  149. } label: {
  150. Label("Import Folder", systemImage: "folder.badge.plus")
  151. }
  152. Button {
  153. Task { await libraryManager.scanMusicDirectory() }
  154. } label: {
  155. Label("Scan Music Folder", systemImage: "arrow.clockwise")
  156. }
  157. } label: {
  158. Image(systemName: "plus")
  159. }
  160. }
  161. if !tracks.filter({ !$0.isAnalyzed }).isEmpty {
  162. ToolbarItem(placement: .topBarTrailing) {
  163. Button {
  164. Task { await libraryManager.analyzeAllTracks(tracks: tracks) }
  165. } label: {
  166. Image(systemName: "waveform.badge.magnifyingglass")
  167. }
  168. }
  169. }
  170. }
  171. .fileImporter(
  172. isPresented: $showImporter,
  173. allowedContentTypes: [
  174. .mp3, .wav, .aiff,
  175. UTType(filenameExtension: "flac") ?? .audio,
  176. UTType(filenameExtension: "m4a") ?? .audio,
  177. UTType(filenameExtension: "ogg") ?? .audio,
  178. .audio
  179. ],
  180. allowsMultipleSelection: true
  181. ) { result in
  182. if case .success(let urls) = result {
  183. Task { await libraryManager.importFiles(urls) }
  184. }
  185. }
  186. .fileImporter(
  187. isPresented: $showFolderImporter,
  188. allowedContentTypes: [.folder],
  189. allowsMultipleSelection: true
  190. ) { result in
  191. if case .success(let urls) = result {
  192. Task { await libraryManager.importFiles(urls) }
  193. }
  194. }
  195. .sheet(item: $showAddToPlaylist) { track in
  196. AddToPlaylistSheet(track: track)
  197. .environmentObject(theme)
  198. }
  199. .sheet(isPresented: Binding(
  200. get: { showAddGroupToPlaylist != nil },
  201. set: { if !$0 { showAddGroupToPlaylist = nil } }
  202. )) {
  203. if let tracks = showAddGroupToPlaylist {
  204. AddGroupToPlaylistSheet(tracks: tracks)
  205. .environmentObject(theme)
  206. }
  207. }
  208. .task(id: "initialScan") {
  209. // Only scan once when the view first appears, not on every tab switch
  210. guard !hasScanned else { return }
  211. hasScanned = true
  212. await libraryManager.scanMusicDirectory()
  213. }
  214. }
  215. }
  216. // MARK: - Main List
  217. @ViewBuilder
  218. private var mainList: some View {
  219. if browseMode == .folders {
  220. // Folders mode: show drill-down browser directly
  221. FolderBrowserView(
  222. folderURL: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!,
  223. title: "Music"
  224. )
  225. } else {
  226. List {
  227. if libraryManager.isScanning {
  228. scanningRow
  229. }
  230. switch browseMode {
  231. case .songs:
  232. songsList
  233. case .artists:
  234. groupedList(groups: artistGroups, icon: "person.fill")
  235. case .albums:
  236. groupedList(groups: albumGroups, icon: "square.stack.fill")
  237. case .genres:
  238. groupedList(groups: genreGroups, icon: "guitars.fill")
  239. case .folders:
  240. EmptyView() // handled above
  241. }
  242. footer
  243. }
  244. .listStyle(.plain)
  245. }
  246. }
  247. // MARK: - Songs (flat list)
  248. private var songsList: some View {
  249. ForEach(filteredTracks) { track in
  250. trackRow(track)
  251. }
  252. }
  253. // MARK: - Grouped list
  254. @State private var showAddGroupToPlaylist: [Track]?
  255. private func groupedList(groups: [(String, [Track])], icon: String) -> some View {
  256. ForEach(groups, id: \.0) { groupName, groupTracks in
  257. Section {
  258. // Action row for the whole group
  259. groupActionRow(groupTracks)
  260. ForEach(groupTracks) { track in
  261. trackRow(track)
  262. }
  263. } header: {
  264. HStack(spacing: 8) {
  265. Image(systemName: icon)
  266. .font(.caption)
  267. .foregroundStyle(theme.accent)
  268. Text(groupName)
  269. .font(.subheadline.weight(.semibold))
  270. .foregroundStyle(theme.groupHeaderText)
  271. Spacer()
  272. Text("\(groupTracks.count)")
  273. .font(.caption)
  274. .foregroundStyle(theme.tertiaryText)
  275. let totalDuration = groupTracks.reduce(0) { $0 + $1.duration }
  276. Text(formatDuration(totalDuration))
  277. .font(.caption.monospacedDigit())
  278. .foregroundStyle(theme.tertiaryText)
  279. }
  280. }
  281. }
  282. }
  283. // MARK: - Group Action Row (play all, add all to mix/playlist)
  284. private func groupActionRow(_ tracks: [Track]) -> some View {
  285. HStack(spacing: 12) {
  286. // Play all
  287. Button {
  288. if let first = tracks.first {
  289. playerVM.loadAndPlay(first)
  290. }
  291. } label: {
  292. Label("Play", systemImage: "play.fill")
  293. .font(.caption)
  294. .foregroundStyle(theme.accent)
  295. }
  296. .buttonStyle(.plain)
  297. Divider().frame(height: 16)
  298. // Mix buttons
  299. ForEach(0..<3, id: \.self) { slot in
  300. if playlistVM.mixTargets[slot] != nil {
  301. Button {
  302. for track in tracks {
  303. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  304. }
  305. playlistVM.showStatus("Added \(tracks.count) to \(playlistVM.mixTargetName(slot))")
  306. } label: {
  307. Text("\(slot + 1)")
  308. .font(.system(size: 11, weight: .bold, design: .rounded))
  309. .frame(width: 24, height: 24)
  310. .foregroundStyle(Self.mixColors[slot])
  311. .background(Self.mixColors[slot].opacity(0.15))
  312. .clipShape(RoundedRectangle(cornerRadius: 5))
  313. }
  314. .buttonStyle(.plain)
  315. }
  316. }
  317. Divider().frame(height: 16)
  318. // Add to playlist
  319. Button {
  320. showAddGroupToPlaylist = tracks
  321. } label: {
  322. Image(systemName: "plus.circle")
  323. .font(.system(size: 16))
  324. .foregroundStyle(theme.secondaryText)
  325. }
  326. .buttonStyle(.plain)
  327. Spacer()
  328. }
  329. .padding(.vertical, 4)
  330. .listRowBackground(theme.cardBackground.opacity(0.3))
  331. }
  332. private static let mixColors: [Color] = [
  333. Color(red: 0.95, green: 0.3, blue: 0.3),
  334. Color(red: 0.3, green: 0.75, blue: 0.95),
  335. Color(red: 0.95, green: 0.75, blue: 0.2),
  336. ]
  337. // MARK: - Track Row with actions
  338. private func trackRow(_ track: Track) -> some View {
  339. Button {
  340. playerVM.loadAndPlay(track)
  341. playerVM.showNowPlaying = true
  342. } label: {
  343. TrackRow(track: track)
  344. .contentShape(Rectangle())
  345. }
  346. .buttonStyle(.plain)
  347. .swipeActions(edge: .trailing) {
  348. Button(role: .destructive) {
  349. libraryManager.removeTrack(track)
  350. try? modelContext.save()
  351. } label: {
  352. Label("Delete", systemImage: "trash")
  353. }
  354. Button {
  355. showAddToPlaylist = track
  356. } label: {
  357. Label("Add to...", systemImage: "plus.circle")
  358. }
  359. .tint(theme.accent)
  360. }
  361. .swipeActions(edge: .leading) {
  362. Button {
  363. _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
  364. } label: {
  365. Label("Quick Add", systemImage: "star.fill")
  366. }
  367. .tint(.orange)
  368. }
  369. .contextMenu {
  370. Button {
  371. _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
  372. } label: {
  373. Label("Add to Target Playlist", systemImage: "star.fill")
  374. }
  375. Button {
  376. showAddToPlaylist = track
  377. } label: {
  378. Label("Add to Playlist...", systemImage: "plus.circle")
  379. }
  380. if !track.isAnalyzed {
  381. Button {
  382. Task { await libraryManager.analyzeTrack(track) }
  383. } label: {
  384. Label("Analyze BPM & Key", systemImage: "waveform.badge.magnifyingglass")
  385. }
  386. }
  387. Divider()
  388. Button(role: .destructive) {
  389. libraryManager.removeTrack(track)
  390. try? modelContext.save()
  391. } label: {
  392. Label("Delete", systemImage: "trash")
  393. }
  394. }
  395. }
  396. // MARK: - Scanning Row
  397. private var scanningRow: some View {
  398. HStack(spacing: 12) {
  399. ProgressView()
  400. VStack(alignment: .leading, spacing: 2) {
  401. Text("Importing...")
  402. .foregroundStyle(theme.secondaryText)
  403. if !libraryManager.scanStatus.isEmpty {
  404. Text(libraryManager.scanStatus)
  405. .font(.caption)
  406. .foregroundStyle(theme.tertiaryText)
  407. .lineLimit(1)
  408. }
  409. }
  410. }
  411. }
  412. // MARK: - Footer
  413. @ViewBuilder
  414. private var footer: some View {
  415. if !filteredTracks.isEmpty {
  416. VStack(spacing: 2) {
  417. Text("\(filteredTracks.count) tracks")
  418. .font(.caption)
  419. .foregroundStyle(theme.tertiaryText)
  420. let totalDuration = filteredTracks.reduce(0) { $0 + $1.duration }
  421. Text(formatDuration(totalDuration))
  422. .font(.caption.monospacedDigit())
  423. .foregroundStyle(theme.tertiaryText)
  424. }
  425. .frame(maxWidth: .infinity, alignment: .center)
  426. .listRowBackground(Color.clear)
  427. }
  428. }
  429. // MARK: - Empty State
  430. private var emptyState: some View {
  431. VStack(spacing: 20) {
  432. Spacer()
  433. Image(systemName: "music.note.house")
  434. .font(.system(size: 60))
  435. .foregroundStyle(theme.tertiaryText)
  436. Text("No tracks yet")
  437. .font(.title2)
  438. .foregroundStyle(theme.secondaryText)
  439. Text("Import your MP3, FLAC, OGG, and other audio files")
  440. .font(.subheadline)
  441. .foregroundStyle(theme.tertiaryText)
  442. .multilineTextAlignment(.center)
  443. Button {
  444. showImporter = true
  445. } label: {
  446. Label("Import Files", systemImage: "plus.circle.fill")
  447. .font(.headline)
  448. .padding(.horizontal, 24)
  449. .padding(.vertical, 12)
  450. }
  451. .buttonStyle(.borderedProminent)
  452. .tint(theme.accent)
  453. Spacer()
  454. }
  455. .padding()
  456. }
  457. // MARK: - Helpers
  458. private func formatDuration(_ total: TimeInterval) -> String {
  459. let t = Int(total)
  460. let hours = t / 3600
  461. let minutes = (t % 3600) / 60
  462. let seconds = t % 60
  463. if hours > 0 {
  464. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  465. }
  466. return String(format: "%d:%02d", minutes, seconds)
  467. }
  468. }