import SwiftData import SwiftUI /// Folder browser — drill down into the Documents directory tree. /// Supports recursive add-to-playlist at any folder level. struct FolderBrowserView: View { let folderURL: URL let title: String @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var theme: AppTheme @EnvironmentObject private var libraryManager: LibraryManager @Environment(\.modelContext) private var modelContext @Query private var allTracks: [Track] @State private var subfolders: [URL] = [] @State private var audioFiles: [URL] = [] @State private var showAddGroupToPlaylist: [Track]? @State private var cachedAllTracks: [Track]? 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), ] var body: some View { List { // Action row — show if there are tracks in this folder tree if let allTrx = cachedAllTracks, !allTrx.isEmpty { folderActionRow(allTrx) } // Subfolders if !subfolders.isEmpty { Section("Folders") { ForEach(subfolders, id: \.path) { folder in NavigationLink { FolderBrowserView(folderURL: folder, title: folder.lastPathComponent) } label: { HStack(spacing: 12) { folderArtwork(folder) .frame(width: 40, height: 40) Text(folder.lastPathComponent) .foregroundStyle(theme.primaryText) .lineLimit(2) } } .contextMenu { let tracks = allTracksRecursive(in: folder) if !tracks.isEmpty { Button { if let first = tracks.first { playerVM.loadAndPlay(first) } } label: { Label("Play All (\(tracks.count))", systemImage: "play.fill") } Divider() 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: { Label("Add all to \(playlistVM.mixTargetName(slot))", systemImage: "\(slot + 1).circle.fill") } } } Divider() Button { showAddGroupToPlaylist = tracks } label: { Label("Add all to Playlist...", systemImage: "plus.circle") } } } } } } // Audio files in this folder if !audioFiles.isEmpty { Section("Audio files") { ForEach(audioFiles, id: \.path) { fileURL in if let track = trackForFile(fileURL) { Button { playerVM.loadAndPlay(track) playerVM.showNowPlaying = true } label: { TrackRow(track: track) .contentShape(Rectangle()) } .buttonStyle(.plain) .contextMenu { Button { playerVM.loadAndPlay(track) playerVM.showNowPlaying = true } label: { Label("Play", systemImage: "play.fill") } Divider() ForEach(0..<3, id: \.self) { slot in if playlistVM.mixTargets[slot] != nil { Button { _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext) } label: { Label("Add to \(playlistVM.mixTargetName(slot))", systemImage: "\(slot + 1).circle.fill") } } } Divider() Button { showAddGroupToPlaylist = [track] } label: { Label("Add to Playlist...", systemImage: "plus.circle") } } } else { // File not yet imported HStack { Image(systemName: "music.note") .foregroundStyle(theme.tertiaryText) .frame(width: 40, height: 40) VStack(alignment: .leading) { Text(fileURL.lastPathComponent) .foregroundStyle(theme.primaryText) .lineLimit(1) Text("Not imported") .font(.caption) .foregroundStyle(theme.tertiaryText) } Spacer() let size = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int64) ?? 0 Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file)) .font(.caption) .foregroundStyle(theme.tertiaryText) } } } } } if subfolders.isEmpty && audioFiles.isEmpty { Text("Empty folder") .foregroundStyle(theme.tertiaryText) .frame(maxWidth: .infinity, alignment: .center) } } .listStyle(.plain) .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .onAppear { scanFolder() // Compute recursive tracks once, not on every render cachedAllTracks = allTracksRecursive(in: folderURL) } .sheet(isPresented: Binding( get: { showAddGroupToPlaylist != nil }, set: { if !$0 { showAddGroupToPlaylist = nil } } )) { if let tracks = showAddGroupToPlaylist { AddGroupToPlaylistSheet(tracks: tracks) .environmentObject(theme) } } } // MARK: - Folder scanning private func scanFolder() { let fm = FileManager.default guard let contents = try? fm.contentsOfDirectory( at: folderURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) else { return } var folders: [URL] = [] var files: [URL] = [] for url in contents.sorted(by: { $0.lastPathComponent.compare($1.lastPathComponent, options: [.numeric, .caseInsensitive]) == .orderedAscending }) { var isDir: ObjCBool = false if fm.fileExists(atPath: url.path, isDirectory: &isDir) { if isDir.boolValue { folders.append(url) } else if MetadataService.isSupportedAudioFile(url) { files.append(url) } } } subfolders = folders audioFiles = files } // MARK: - Track matching /// Find the Track model for a file URL by matching the relative path. private func trackForFile(_ url: URL) -> Track? { let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let standardized = url.standardizedFileURL.path let docsPath = docsDir.standardizedFileURL.path let relativePath = standardized.hasPrefix(docsPath + "/") ? String(standardized.dropFirst(docsPath.count + 1)) : url.lastPathComponent // Try matching by full path first, fallback to fileName return allTracks.first { $0.filePath == relativePath } ?? allTracks.first { $0.fileName == url.lastPathComponent } } /// All tracks in this folder (not recursive). private var tracksInThisFolder: [Track] { audioFiles.compactMap { trackForFile($0) } } /// All tracks recursively in a folder and its subfolders. private func allTracksRecursive(in folder: URL) -> [Track] { let fm = FileManager.default var result: [Track] = [] var notFound: [String] = [] guard let enumerator = fm.enumerator( at: folder, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) else { return result } for case let fileURL as URL in enumerator { if MetadataService.isSupportedAudioFile(fileURL) { if let track = trackForFile(fileURL) { result.append(track) } else { notFound.append(fileURL.lastPathComponent) } } } if !notFound.isEmpty { print("allTracksRecursive: \(notFound.count) files not in DB") } // Sort by full path with numeric sorting so "12.1.1" < "12.1.2" < "12.1.10" result.sort { $0.filePath.compare($1.filePath, options: [.numeric, .caseInsensitive]) == .orderedAscending } return result } // MARK: - Folder artwork (first image in folder or first track's art) private func folderArtwork(_ folder: URL) -> some View { let fm = FileManager.default let imageExts: Set = ["jpg", "jpeg", "png"] // Look for cover image in the folder var imageData: Data? if let contents = try? fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) { for file in contents { if imageExts.contains(file.pathExtension.lowercased()) { imageData = try? Data(contentsOf: file) break } } } return Group { if let data = imageData, let img = UIImage(data: data) { Image(uiImage: img) .resizable() .aspectRatio(contentMode: .fill) .clipShape(RoundedRectangle(cornerRadius: 6)) } else { ZStack { RoundedRectangle(cornerRadius: 6) .fill(theme.cardBackground) Image(systemName: "folder.fill") .foregroundStyle(theme.tertiaryText) } } } } // MARK: - Folder action row private func folderActionRow(_ tracks: [Track]) -> some View { HStack(spacing: 12) { 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) 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) Button { showAddGroupToPlaylist = tracks } label: { Image(systemName: "plus.circle") .font(.system(size: 16)) .foregroundStyle(theme.secondaryText) } .buttonStyle(.plain) Spacer() Text("\(tracks.count) tracks") .font(.caption) .foregroundStyle(theme.tertiaryText) } .padding(.vertical, 4) .listRowBackground(theme.cardBackground.opacity(0.3)) } }