| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- 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<String> = ["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))
- }
- }
|