mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 09:19:46 +00:00
Save sort order, sort direction, and show-only-playable filter to UserDefaults keyed by source ID so preferences survive navigation.
309 lines
11 KiB
Swift
309 lines
11 KiB
Swift
//
|
|
// 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<Content: View>(@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)
|
|
}
|