// // MediaBrowserView.swift // Yattee // // View for browsing files in a media source. // import SwiftUI struct MediaBrowserView: View { @Environment(\.appEnvironment) private var appEnvironment let source: MediaSource let initialPath: String @Namespace private var sheetTransition @State private var currentPath: String @State private var files: [MediaFile] = [] @State private var isLoading = false @State private var error: MediaSourceError? @State private var showOnlyPlayable: Bool @State private var sortOrder: MediaBrowserSortOrder @State private var sortAscending: Bool @State private var showViewOptions = false private var listStyle: VideoListStyle { appEnvironment?.settingsManager.listStyle ?? .inset } init(source: MediaSource, path: String = "/", showOnlyPlayable: Bool = false) { self.source = source self.initialPath = path _currentPath = State(initialValue: path) let defaults = UserDefaults.standard let key = "mediaBrowser.\(source.id.uuidString)" if let raw = defaults.string(forKey: "\(key).sortOrder"), let saved = MediaBrowserSortOrder(rawValue: raw) { _sortOrder = State(initialValue: saved) } else { _sortOrder = State(initialValue: .name) } _sortAscending = State(initialValue: defaults.object(forKey: "\(key).sortAscending") as? Bool ?? true) _showOnlyPlayable = State(initialValue: defaults.object(forKey: "\(key).showOnlyPlayable") as? Bool ?? showOnlyPlayable) } /// Files filtered and sorted based on current settings. private var displayedFiles: [MediaFile] { var result = files if showOnlyPlayable { result = result.filter { $0.isDirectory || $0.isPlayable } } return sortedFiles(result) } var body: some View { Group { if isLoading && files.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error { ContentUnavailableView { Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle") } description: { Text(error.localizedDescription) } actions: { Button(String(localized: "common.retry")) { Task { await loadFiles() } } .buttonStyle(.borderedProminent) } } else if files.isEmpty { ContentUnavailableView { Label(String(localized: "mediaBrowser.emptyFolder"), systemImage: "folder") } description: { Text(String(localized: "mediaBrowser.emptyFolder.description")) } } else { fileList } } .navigationTitle(navigationTitle) #if !os(tvOS) .toolbarTitleDisplayMode(.inlineLarge) #endif .toolbar { #if !os(tvOS) ToolbarItem(placement: .primaryAction) { if isLoading { ProgressView() .controlSize(.small) } else { Button { Task { await loadFiles() } } label: { Label(String(localized: "common.refresh"), systemImage: "arrow.clockwise") } } } ToolbarItem(placement: .primaryAction) { Button { showViewOptions = true } label: { Label( String(localized: "viewOptions.title"), systemImage: showOnlyPlayable ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle" ) } .liquidGlassTransitionSource(id: "mediaBrowserViewOptions", in: sheetTransition) } #endif } .sheet(isPresented: $showViewOptions) { MediaBrowserViewOptionsSheet( sourceType: source.type, sortOrder: $sortOrder, sortAscending: $sortAscending, showOnlyPlayable: $showOnlyPlayable ) .liquidGlassSheetContent(sourceID: "mediaBrowserViewOptions", in: sheetTransition) } .task { await loadFiles() } .onChange(of: sortOrder) { _, newValue in savePreference(newValue.rawValue, forKey: "sortOrder") } .onChange(of: sortAscending) { _, newValue in savePreference(newValue, forKey: "sortAscending") } .onChange(of: showOnlyPlayable) { _, newValue in savePreference(newValue, forKey: "showOnlyPlayable") } } private var navigationTitle: String { if currentPath == "/" || currentPath.isEmpty { return source.name } return (currentPath as NSString).lastPathComponent } private var fileList: some View { (listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color) .ignoresSafeArea() .overlay( ScrollView { LazyVStack(spacing: 0) { sectionCard { ForEach(Array(displayedFiles.enumerated()), id: \.element.id) { index, file in let isLast = index == displayedFiles.count - 1 SourceListRow(isLast: isLast, listStyle: listStyle) { if file.isDirectory { NavigationLink(value: NavigationDestination.mediaBrowser(source, path: file.path, showOnlyPlayable: showOnlyPlayable)) { MediaFileRow(file: file, sortOrder: sortOrder) } .foregroundStyle(.primary) } else { MediaFileRow(file: file, sortOrder: sortOrder) { if file.isPlayable { playFile(file) } } } } } } } .padding(.top, 16) } ) } @ViewBuilder private func sectionCard(@ViewBuilder content: () -> Content) -> some View { if listStyle == .inset { LazyVStack(spacing: 0) { content() } .background(ListBackgroundStyle.card.color) .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal, 16) .padding(.bottom, 16) } else { LazyVStack(spacing: 0) { content() } .padding(.bottom, 16) } } // MARK: - Preferences private func savePreference(_ value: Any, forKey suffix: String) { UserDefaults.standard.set(value, forKey: "mediaBrowser.\(source.id.uuidString).\(suffix)") } // MARK: - Loading @MainActor private func loadFiles() async { guard let appEnvironment else { return } isLoading = true error = nil do { let loadedFiles: [MediaFile] switch source.type { case .webdav: let password = appEnvironment.mediaSourcesManager.password(for: source) loadedFiles = try await appEnvironment.webDAVClient.listFiles( at: currentPath, source: source, password: password ) case .smb: let password = appEnvironment.mediaSourcesManager.password(for: source) loadedFiles = try await appEnvironment.smbClient.listFiles( at: currentPath, source: source, password: password ) case .localFolder: loadedFiles = try await appEnvironment.localFileClient.listFiles( at: currentPath, source: source ) } files = loadedFiles isLoading = false } catch let err as MediaSourceError { error = err isLoading = false } catch { self.error = .unknown(error.localizedDescription) isLoading = false } } private func sortedFiles(_ files: [MediaFile]) -> [MediaFile] { files.sorted { lhs, rhs in // Directories always first if lhs.isDirectory != rhs.isDirectory { return lhs.isDirectory } let comparison: ComparisonResult switch sortOrder { case .name: comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name) case .dateModified: let lhsDate = lhs.modifiedDate ?? .distantPast let rhsDate = rhs.modifiedDate ?? .distantPast comparison = lhsDate.compare(rhsDate) case .dateCreated: let lhsDate = lhs.createdDate ?? .distantPast let rhsDate = rhs.createdDate ?? .distantPast comparison = lhsDate.compare(rhsDate) } return sortAscending ? comparison == .orderedAscending : comparison == .orderedDescending } } // MARK: - Playback private func playFile(_ file: MediaFile) { guard let appEnvironment else { return } // Get all playable files in current sort order let playableFiles = displayedFiles.filter { $0.isPlayable } // Find the index of the tapped file in the playable files list guard let playableIndex = playableFiles.firstIndex(where: { $0.id == file.id }) else { return } // Use queue-based playback with all files in the folder // Stream and captions are resolved on-demand when each video plays appEnvironment.queueManager.playFromMediaBrowser( files: playableFiles, index: playableIndex, source: source, allFilesInFolder: files // All files including subtitles for discovery ) } } // MARK: - Preview #Preview { NavigationStack { MediaBrowserView( source: .webdav(name: "My NAS", url: URL(string: "https://nas.local:5006")!) ) } .appEnvironment(.preview) }