FolderBrowserView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import SwiftData
  2. import SwiftUI
  3. /// Folder browser — drill down into the Documents directory tree.
  4. /// Supports recursive add-to-playlist at any folder level.
  5. struct FolderBrowserView: View {
  6. let folderURL: URL
  7. let title: String
  8. @Environment(PlayerViewModel.self) private var playerVM
  9. @Environment(PlaylistViewModel.self) private var playlistVM
  10. @EnvironmentObject private var theme: AppTheme
  11. @EnvironmentObject private var libraryManager: LibraryManager
  12. @Environment(\.modelContext) private var modelContext
  13. @Query private var allTracks: [Track]
  14. @State private var subfolders: [URL] = []
  15. @State private var audioFiles: [URL] = []
  16. @State private var showAddGroupToPlaylist: [Track]?
  17. @State private var cachedAllTracks: [Track]?
  18. private static let mixColors: [Color] = [
  19. Color(red: 0.95, green: 0.3, blue: 0.3),
  20. Color(red: 0.3, green: 0.75, blue: 0.95),
  21. Color(red: 0.95, green: 0.75, blue: 0.2),
  22. ]
  23. var body: some View {
  24. List {
  25. // Action row — show if there are tracks in this folder tree
  26. if let allTrx = cachedAllTracks, !allTrx.isEmpty {
  27. folderActionRow(allTrx)
  28. }
  29. // Subfolders
  30. if !subfolders.isEmpty {
  31. Section("Folders") {
  32. ForEach(subfolders, id: \.path) { folder in
  33. NavigationLink {
  34. FolderBrowserView(folderURL: folder, title: folder.lastPathComponent)
  35. } label: {
  36. HStack(spacing: 12) {
  37. folderArtwork(folder)
  38. .frame(width: 40, height: 40)
  39. Text(folder.lastPathComponent)
  40. .foregroundStyle(theme.primaryText)
  41. .lineLimit(2)
  42. }
  43. }
  44. .contextMenu {
  45. let tracks = allTracksRecursive(in: folder)
  46. if !tracks.isEmpty {
  47. Button {
  48. if let first = tracks.first {
  49. playerVM.loadAndPlay(first)
  50. }
  51. } label: {
  52. Label("Play All (\(tracks.count))", systemImage: "play.fill")
  53. }
  54. Divider()
  55. ForEach(0..<3, id: \.self) { slot in
  56. if playlistVM.mixTargets[slot] != nil {
  57. Button {
  58. for track in tracks {
  59. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  60. }
  61. playlistVM.showStatus("Added \(tracks.count) to \(playlistVM.mixTargetName(slot))")
  62. } label: {
  63. Label("Add all to \(playlistVM.mixTargetName(slot))", systemImage: "\(slot + 1).circle.fill")
  64. }
  65. }
  66. }
  67. Divider()
  68. Button {
  69. showAddGroupToPlaylist = tracks
  70. } label: {
  71. Label("Add all to Playlist...", systemImage: "plus.circle")
  72. }
  73. }
  74. }
  75. }
  76. }
  77. }
  78. // Audio files in this folder
  79. if !audioFiles.isEmpty {
  80. Section("Audio files") {
  81. ForEach(audioFiles, id: \.path) { fileURL in
  82. if let track = trackForFile(fileURL) {
  83. Button {
  84. playerVM.loadAndPlay(track)
  85. playerVM.showNowPlaying = true
  86. } label: {
  87. TrackRow(track: track)
  88. .contentShape(Rectangle())
  89. }
  90. .buttonStyle(.plain)
  91. .contextMenu {
  92. Button {
  93. playerVM.loadAndPlay(track)
  94. playerVM.showNowPlaying = true
  95. } label: {
  96. Label("Play", systemImage: "play.fill")
  97. }
  98. Divider()
  99. ForEach(0..<3, id: \.self) { slot in
  100. if playlistVM.mixTargets[slot] != nil {
  101. Button {
  102. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  103. } label: {
  104. Label("Add to \(playlistVM.mixTargetName(slot))", systemImage: "\(slot + 1).circle.fill")
  105. }
  106. }
  107. }
  108. Divider()
  109. Button {
  110. showAddGroupToPlaylist = [track]
  111. } label: {
  112. Label("Add to Playlist...", systemImage: "plus.circle")
  113. }
  114. }
  115. } else {
  116. // File not yet imported
  117. HStack {
  118. Image(systemName: "music.note")
  119. .foregroundStyle(theme.tertiaryText)
  120. .frame(width: 40, height: 40)
  121. VStack(alignment: .leading) {
  122. Text(fileURL.lastPathComponent)
  123. .foregroundStyle(theme.primaryText)
  124. .lineLimit(1)
  125. Text("Not imported")
  126. .font(.caption)
  127. .foregroundStyle(theme.tertiaryText)
  128. }
  129. Spacer()
  130. let size = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int64) ?? 0
  131. Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))
  132. .font(.caption)
  133. .foregroundStyle(theme.tertiaryText)
  134. }
  135. }
  136. }
  137. }
  138. }
  139. if subfolders.isEmpty && audioFiles.isEmpty {
  140. Text("Empty folder")
  141. .foregroundStyle(theme.tertiaryText)
  142. .frame(maxWidth: .infinity, alignment: .center)
  143. }
  144. }
  145. .listStyle(.plain)
  146. .navigationTitle(title)
  147. .navigationBarTitleDisplayMode(.inline)
  148. .onAppear {
  149. scanFolder()
  150. // Compute recursive tracks once, not on every render
  151. cachedAllTracks = allTracksRecursive(in: folderURL)
  152. }
  153. .sheet(isPresented: Binding(
  154. get: { showAddGroupToPlaylist != nil },
  155. set: { if !$0 { showAddGroupToPlaylist = nil } }
  156. )) {
  157. if let tracks = showAddGroupToPlaylist {
  158. AddGroupToPlaylistSheet(tracks: tracks)
  159. .environmentObject(theme)
  160. }
  161. }
  162. }
  163. // MARK: - Folder scanning
  164. private func scanFolder() {
  165. let fm = FileManager.default
  166. guard let contents = try? fm.contentsOfDirectory(
  167. at: folderURL,
  168. includingPropertiesForKeys: [.isDirectoryKey],
  169. options: [.skipsHiddenFiles]
  170. ) else { return }
  171. var folders: [URL] = []
  172. var files: [URL] = []
  173. for url in contents.sorted(by: { $0.lastPathComponent.compare($1.lastPathComponent, options: [.numeric, .caseInsensitive]) == .orderedAscending }) {
  174. var isDir: ObjCBool = false
  175. if fm.fileExists(atPath: url.path, isDirectory: &isDir) {
  176. if isDir.boolValue {
  177. folders.append(url)
  178. } else if MetadataService.isSupportedAudioFile(url) {
  179. files.append(url)
  180. }
  181. }
  182. }
  183. subfolders = folders
  184. audioFiles = files
  185. }
  186. // MARK: - Track matching
  187. /// Find the Track model for a file URL by matching the relative path.
  188. private func trackForFile(_ url: URL) -> Track? {
  189. let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
  190. let standardized = url.standardizedFileURL.path
  191. let docsPath = docsDir.standardizedFileURL.path
  192. let relativePath = standardized.hasPrefix(docsPath + "/")
  193. ? String(standardized.dropFirst(docsPath.count + 1))
  194. : url.lastPathComponent
  195. // Try matching by full path first, fallback to fileName
  196. return allTracks.first { $0.filePath == relativePath }
  197. ?? allTracks.first { $0.fileName == url.lastPathComponent }
  198. }
  199. /// All tracks in this folder (not recursive).
  200. private var tracksInThisFolder: [Track] {
  201. audioFiles.compactMap { trackForFile($0) }
  202. }
  203. /// All tracks recursively in a folder and its subfolders.
  204. private func allTracksRecursive(in folder: URL) -> [Track] {
  205. let fm = FileManager.default
  206. var result: [Track] = []
  207. var notFound: [String] = []
  208. guard let enumerator = fm.enumerator(
  209. at: folder,
  210. includingPropertiesForKeys: [.isRegularFileKey],
  211. options: [.skipsHiddenFiles]
  212. ) else { return result }
  213. for case let fileURL as URL in enumerator {
  214. if MetadataService.isSupportedAudioFile(fileURL) {
  215. if let track = trackForFile(fileURL) {
  216. result.append(track)
  217. } else {
  218. notFound.append(fileURL.lastPathComponent)
  219. }
  220. }
  221. }
  222. if !notFound.isEmpty {
  223. print("allTracksRecursive: \(notFound.count) files not in DB")
  224. }
  225. // Sort by full path with numeric sorting so "12.1.1" < "12.1.2" < "12.1.10"
  226. result.sort { $0.filePath.compare($1.filePath, options: [.numeric, .caseInsensitive]) == .orderedAscending }
  227. return result
  228. }
  229. // MARK: - Folder artwork (first image in folder or first track's art)
  230. private func folderArtwork(_ folder: URL) -> some View {
  231. let fm = FileManager.default
  232. let imageExts: Set<String> = ["jpg", "jpeg", "png"]
  233. // Look for cover image in the folder
  234. var imageData: Data?
  235. if let contents = try? fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
  236. for file in contents {
  237. if imageExts.contains(file.pathExtension.lowercased()) {
  238. imageData = try? Data(contentsOf: file)
  239. break
  240. }
  241. }
  242. }
  243. return Group {
  244. if let data = imageData, let img = UIImage(data: data) {
  245. Image(uiImage: img)
  246. .resizable()
  247. .aspectRatio(contentMode: .fill)
  248. .clipShape(RoundedRectangle(cornerRadius: 6))
  249. } else {
  250. ZStack {
  251. RoundedRectangle(cornerRadius: 6)
  252. .fill(theme.cardBackground)
  253. Image(systemName: "folder.fill")
  254. .foregroundStyle(theme.tertiaryText)
  255. }
  256. }
  257. }
  258. }
  259. // MARK: - Folder action row
  260. private func folderActionRow(_ tracks: [Track]) -> some View {
  261. HStack(spacing: 12) {
  262. Button {
  263. if let first = tracks.first {
  264. playerVM.loadAndPlay(first)
  265. }
  266. } label: {
  267. Label("Play", systemImage: "play.fill")
  268. .font(.caption)
  269. .foregroundStyle(theme.accent)
  270. }
  271. .buttonStyle(.plain)
  272. Divider().frame(height: 16)
  273. ForEach(0..<3, id: \.self) { slot in
  274. if playlistVM.mixTargets[slot] != nil {
  275. Button {
  276. for track in tracks {
  277. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  278. }
  279. playlistVM.showStatus("Added \(tracks.count) to \(playlistVM.mixTargetName(slot))")
  280. } label: {
  281. Text("\(slot + 1)")
  282. .font(.system(size: 11, weight: .bold, design: .rounded))
  283. .frame(width: 24, height: 24)
  284. .foregroundStyle(Self.mixColors[slot])
  285. .background(Self.mixColors[slot].opacity(0.15))
  286. .clipShape(RoundedRectangle(cornerRadius: 5))
  287. }
  288. .buttonStyle(.plain)
  289. }
  290. }
  291. Divider().frame(height: 16)
  292. Button {
  293. showAddGroupToPlaylist = tracks
  294. } label: {
  295. Image(systemName: "plus.circle")
  296. .font(.system(size: 16))
  297. .foregroundStyle(theme.secondaryText)
  298. }
  299. .buttonStyle(.plain)
  300. Spacer()
  301. Text("\(tracks.count) tracks")
  302. .font(.caption)
  303. .foregroundStyle(theme.tertiaryText)
  304. }
  305. .padding(.vertical, 4)
  306. .listRowBackground(theme.cardBackground.opacity(0.3))
  307. }
  308. }