mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
280
Yattee/Views/MediaBrowser/MediaBrowserView.swift
Normal file
280
Yattee/Views/MediaBrowser/MediaBrowserView.swift
Normal file
@@ -0,0 +1,280 @@
|
||||
//
|
||||
// MediaBrowserView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View for browsing files in a media source.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MediaBrowserView: View {
|
||||
let source: MediaSource
|
||||
let initialPath: String
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@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 = .name
|
||||
@State private var sortAscending = true
|
||||
@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)
|
||||
_showOnlyPlayable = State(initialValue: 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(
|
||||
"View Options",
|
||||
systemImage: showOnlyPlayable
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle"
|
||||
)
|
||||
}
|
||||
.liquidGlassTransitionSource(id: "mediaBrowserViewOptions", in: sheetTransition)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $showViewOptions) {
|
||||
MediaBrowserViewOptionsSheet(
|
||||
sortOrder: $sortOrder,
|
||||
sortAscending: $sortAscending,
|
||||
showOnlyPlayable: $showOnlyPlayable,
|
||||
sourceType: source.type
|
||||
)
|
||||
.liquidGlassSheetContent(sourceID: "mediaBrowserViewOptions", in: sheetTransition)
|
||||
}
|
||||
.task {
|
||||
await loadFiles()
|
||||
}
|
||||
}
|
||||
|
||||
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: - 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)
|
||||
}
|
||||
110
Yattee/Views/MediaBrowser/MediaBrowserViewOptionsSheet.swift
Normal file
110
Yattee/Views/MediaBrowser/MediaBrowserViewOptionsSheet.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// MediaBrowserViewOptionsSheet.swift
|
||||
// Yattee
|
||||
//
|
||||
// Sheet for customizing media browser view options.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MediaBrowserViewOptionsSheet: View {
|
||||
@Binding var sortOrder: MediaBrowserSortOrder
|
||||
@Binding var sortAscending: Bool
|
||||
@Binding var showOnlyPlayable: Bool
|
||||
let sourceType: MediaSourceType
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var availableSortOptions: [MediaBrowserSortOrder] {
|
||||
MediaBrowserSortOrder.availableOptions(for: sourceType)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("mediaBrowser.viewOptions.sortBy", selection: $sortOrder) {
|
||||
ForEach(availableSortOptions) { order in
|
||||
Label(order.displayName, systemImage: order.systemImage)
|
||||
.tag(order)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("mediaBrowser.viewOptions.sortBy")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("mediaBrowser.viewOptions.order", selection: $sortAscending) {
|
||||
Label(String(localized: "mediaBrowser.viewOptions.ascending"), systemImage: "arrow.up")
|
||||
.tag(true)
|
||||
Label(String(localized: "mediaBrowser.viewOptions.descending"), systemImage: "arrow.down")
|
||||
.tag(false)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
|
||||
} header: {
|
||||
Text("mediaBrowser.viewOptions.order")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("mediaBrowser.viewOptions.showOnlyPlayable", isOn: $showOnlyPlayable)
|
||||
} header: {
|
||||
Text("mediaBrowser.viewOptions.filters")
|
||||
}
|
||||
}
|
||||
.navigationTitle("mediaBrowser.viewOptions.title")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Close", systemImage: "xmark")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
.onAppear {
|
||||
// Reset sort order if current selection is not available for this source type
|
||||
if !availableSortOptions.contains(sortOrder) {
|
||||
sortOrder = .name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Local Folder") {
|
||||
@Previewable @State var sortOrder: MediaBrowserSortOrder = .name
|
||||
@Previewable @State var sortAscending = true
|
||||
@Previewable @State var showOnlyPlayable = false
|
||||
|
||||
MediaBrowserViewOptionsSheet(
|
||||
sortOrder: $sortOrder,
|
||||
sortAscending: $sortAscending,
|
||||
showOnlyPlayable: $showOnlyPlayable,
|
||||
sourceType: .localFolder
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("WebDAV") {
|
||||
@Previewable @State var sortOrder: MediaBrowserSortOrder = .name
|
||||
@Previewable @State var sortAscending = true
|
||||
@Previewable @State var showOnlyPlayable = false
|
||||
|
||||
MediaBrowserViewOptionsSheet(
|
||||
sortOrder: $sortOrder,
|
||||
sortAscending: $sortAscending,
|
||||
showOnlyPlayable: $showOnlyPlayable,
|
||||
sourceType: .webdav
|
||||
)
|
||||
}
|
||||
128
Yattee/Views/MediaBrowser/MediaFileRow.swift
Normal file
128
Yattee/Views/MediaBrowser/MediaFileRow.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// MediaFileRow.swift
|
||||
// Yattee
|
||||
//
|
||||
// Row view for displaying a file or folder in the media browser.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MediaFileRow: View {
|
||||
let file: MediaFile
|
||||
let sortOrder: MediaBrowserSortOrder
|
||||
let action: (() -> Void)?
|
||||
|
||||
/// Initialize with an action (for playable files).
|
||||
init(file: MediaFile, sortOrder: MediaBrowserSortOrder = .name, action: @escaping () -> Void) {
|
||||
self.file = file
|
||||
self.sortOrder = sortOrder
|
||||
self.action = action
|
||||
}
|
||||
|
||||
/// Initialize without action (for use inside NavigationLink).
|
||||
init(file: MediaFile, sortOrder: MediaBrowserSortOrder = .name) {
|
||||
self.file = file
|
||||
self.sortOrder = sortOrder
|
||||
self.action = nil
|
||||
}
|
||||
|
||||
/// The date to display based on current sort order.
|
||||
private var displayDate: Date? {
|
||||
switch sortOrder {
|
||||
case .name, .dateModified:
|
||||
file.modifiedDate
|
||||
case .dateCreated:
|
||||
file.createdDate
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let action {
|
||||
Button(action: action) {
|
||||
rowContent
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.if(file.isPlayable) { view in
|
||||
view.videoContextMenu(
|
||||
video: file.toVideo(),
|
||||
context: .mediaBrowser
|
||||
)
|
||||
}
|
||||
} else {
|
||||
rowContent
|
||||
.if(file.isPlayable) { view in
|
||||
view.videoContextMenu(
|
||||
video: file.toVideo(),
|
||||
context: .mediaBrowser
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rowContent: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Icon
|
||||
Image(systemName: file.systemImage)
|
||||
.font(.title2)
|
||||
.foregroundStyle(iconColor)
|
||||
.frame(width: 32)
|
||||
|
||||
// File info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.name)
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if let size = file.formattedSize {
|
||||
Text(size)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let date = displayDate {
|
||||
Text(date, style: .date)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
if file.isDirectory {
|
||||
return .blue
|
||||
}
|
||||
if file.isVideo {
|
||||
return .purple
|
||||
}
|
||||
if file.isAudio {
|
||||
return .pink
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
MediaFileRow(file: .folderPreview) {}
|
||||
MediaFileRow(file: .preview) {}
|
||||
MediaFileRow(
|
||||
file: MediaFile(
|
||||
source: .webdav(name: "NAS", url: URL(string: "https://nas.local")!),
|
||||
path: "/Music/song.mp3",
|
||||
name: "song.mp3",
|
||||
isDirectory: false,
|
||||
size: 5_000_000,
|
||||
modifiedDate: Date()
|
||||
)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
496
Yattee/Views/MediaBrowser/MediaSourcesView.swift
Normal file
496
Yattee/Views/MediaBrowser/MediaSourcesView.swift
Normal file
@@ -0,0 +1,496 @@
|
||||
//
|
||||
// MediaSourcesView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View for browsing all configured sources (instances and media sources).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MediaSourcesView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@State private var sourceToEdit: UnifiedSource?
|
||||
@State private var showingAddSheet = false
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@State private var pendingDeleteInstance: Instance?
|
||||
@State private var pendingDeleteSource: MediaSource?
|
||||
|
||||
private var instancesManager: InstancesManager? {
|
||||
appEnvironment?.instancesManager
|
||||
}
|
||||
|
||||
private var mediaSourcesManager: MediaSourcesManager? {
|
||||
appEnvironment?.mediaSourcesManager
|
||||
}
|
||||
|
||||
private var sourcesSettings: SourcesSettings? {
|
||||
appEnvironment?.sourcesSettings
|
||||
}
|
||||
|
||||
private var isEmpty: Bool {
|
||||
(instancesManager?.enabledInstances.isEmpty ?? true) &&
|
||||
(mediaSourcesManager?.enabledSources.isEmpty ?? true)
|
||||
}
|
||||
|
||||
private var listStyle: VideoListStyle {
|
||||
appEnvironment?.settingsManager.listStyle ?? .inset
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "sources.empty.title"), systemImage: "server.rack")
|
||||
} description: {
|
||||
Text(String(localized: "sources.empty.description"))
|
||||
} actions: {
|
||||
Button(String(localized: "sources.addSource")) {
|
||||
showingAddSheet = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
sourcesList
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "sources.title"))
|
||||
#if !os(tvOS)
|
||||
.toolbarTitleDisplayMode(.inlineLarge)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
Label(String(localized: "sources.addSource"), systemImage: "plus")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if let settings = sourcesSettings {
|
||||
sortAndGroupMenu(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.sheet(item: $sourceToEdit) { source in
|
||||
EditSourceView(source: source)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
AddSourceView()
|
||||
}
|
||||
.confirmationDialog(
|
||||
deleteConfirmationMessage,
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "common.delete"), role: .destructive) {
|
||||
confirmDelete()
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {
|
||||
pendingDeleteInstance = nil
|
||||
pendingDeleteSource = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var deleteConfirmationMessage: String {
|
||||
if let instance = pendingDeleteInstance {
|
||||
return String(localized: "sources.delete.confirmation.single \(instance.displayName)")
|
||||
} else if let source = pendingDeleteSource {
|
||||
return String(localized: "sources.delete.confirmation.single \(source.name)")
|
||||
}
|
||||
return String(localized: "sources.delete.confirmation")
|
||||
}
|
||||
|
||||
private func confirmDelete() {
|
||||
if let instance = pendingDeleteInstance {
|
||||
instancesManager?.remove(instance)
|
||||
pendingDeleteInstance = nil
|
||||
}
|
||||
if let source = pendingDeleteSource {
|
||||
mediaSourcesManager?.remove(source)
|
||||
pendingDeleteSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, listStyle == .inset ? 32 : 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private var sourcesList: some View {
|
||||
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color)
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if let settings = sourcesSettings, !settings.groupByType {
|
||||
// Ungrouped: All sources in one section
|
||||
allSourcesSection(settings)
|
||||
} else {
|
||||
// Grouped by type (default)
|
||||
groupedSourcesSections
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var groupedSourcesSections: some View {
|
||||
let settings = sourcesSettings
|
||||
|
||||
// Instances section
|
||||
if let manager = instancesManager, !manager.enabledInstances.isEmpty {
|
||||
sectionHeader(String(localized: "sources.section.remoteServers"))
|
||||
|
||||
let sortedInstances = settings?.sorted(manager.enabledInstances) ?? manager.enabledInstances
|
||||
sectionCard {
|
||||
instancesSectionContent(sortedInstances)
|
||||
}
|
||||
}
|
||||
|
||||
// Media sources section
|
||||
if let manager = mediaSourcesManager, !manager.enabledSources.isEmpty {
|
||||
sectionHeader(String(localized: "sources.section.fileSources"))
|
||||
|
||||
let sortedSources = settings?.sorted(manager.enabledSources) ?? manager.enabledSources
|
||||
sectionCard {
|
||||
fileSourcesSectionContent(sortedSources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func allSourcesSection(_ settings: SourcesSettings) -> some View {
|
||||
let sortedSources = allUnifiedSources(settings: settings)
|
||||
|
||||
if !sortedSources.isEmpty {
|
||||
sectionHeader(String(localized: "sources.section.allSources"))
|
||||
|
||||
sectionCard {
|
||||
ForEach(Array(sortedSources.enumerated()), id: \.element.id) { index, item in
|
||||
let isLast = index == sortedSources.count - 1
|
||||
|
||||
switch item {
|
||||
case .instance(let instance):
|
||||
instanceRowView(instance, isLast: isLast)
|
||||
case .mediaSource(let source):
|
||||
fileSourceRowView(source, isLast: isLast)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func allUnifiedSources(settings: SourcesSettings) -> [UnifiedSourceItem] {
|
||||
let instances = instancesManager?.enabledInstances ?? []
|
||||
let mediaSources = mediaSourcesManager?.enabledSources ?? []
|
||||
|
||||
var allSources: [UnifiedSourceItem] = []
|
||||
allSources.append(contentsOf: instances.map { UnifiedSourceItem.instance($0) })
|
||||
allSources.append(contentsOf: mediaSources.map { UnifiedSourceItem.mediaSource($0) })
|
||||
|
||||
return sortUnifiedSources(allSources, settings: settings)
|
||||
}
|
||||
|
||||
private func sortUnifiedSources(_ sources: [UnifiedSourceItem], settings: SourcesSettings) -> [UnifiedSourceItem] {
|
||||
sources.sorted { first, second in
|
||||
let comparison: Bool
|
||||
switch settings.sortOption {
|
||||
case .name:
|
||||
comparison = first.displayName.localizedCaseInsensitiveCompare(second.displayName) == .orderedAscending
|
||||
case .type:
|
||||
comparison = first.typeDisplayName.localizedCaseInsensitiveCompare(second.typeDisplayName) == .orderedAscending
|
||||
case .dateAdded:
|
||||
comparison = first.dateAdded < second.dateAdded
|
||||
}
|
||||
return settings.sortDirection == .ascending ? comparison : !comparison
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sort and Group Menu
|
||||
|
||||
@ViewBuilder
|
||||
private func sortAndGroupMenu(_ settings: SourcesSettings) -> some View {
|
||||
Menu {
|
||||
// Sort options
|
||||
Section {
|
||||
Picker(selection: Binding(
|
||||
get: { settings.sortOption },
|
||||
set: { settings.sortOption = $0 }
|
||||
)) {
|
||||
ForEach(settings.availableSortOptions, id: \.self) { option in
|
||||
Label(option.displayName, systemImage: option.systemImage)
|
||||
.tag(option)
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "sources.sort.title"), systemImage: "arrow.up.arrow.down")
|
||||
}
|
||||
|
||||
// Sort direction
|
||||
Button {
|
||||
settings.sortDirection.toggle()
|
||||
} label: {
|
||||
Label(
|
||||
settings.sortDirection == .ascending
|
||||
? String(localized: "sources.sort.ascending")
|
||||
: String(localized: "sources.sort.descending"),
|
||||
systemImage: settings.sortDirection.systemImage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Grouping
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { settings.groupByType },
|
||||
set: {
|
||||
settings.groupByType = $0
|
||||
// Reset to name sort if type sort was selected and grouping is now enabled
|
||||
if $0 && settings.sortOption == .type {
|
||||
settings.sortOption = .name
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Label(String(localized: "sources.groupByType"), systemImage: "rectangle.3.group")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "sources.sortAndGroup"), systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func instancesSectionContent(_ instances: [Instance]) -> some View {
|
||||
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
|
||||
let isLastInSection = index == instances.count - 1
|
||||
|
||||
instanceRowView(instance, isLast: isLastInSection)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func instanceRowView(_ instance: Instance, isLast: Bool) -> some View {
|
||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||
NavigationLink(value: NavigationDestination.instanceBrowse(instance)) {
|
||||
instanceRow(instance)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.swipeActions {
|
||||
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
|
||||
sourceToEdit = .remoteServer(instance)
|
||||
reset()
|
||||
}
|
||||
SwipeAction(symbolImage: "trash", tint: .white, background: .red) { reset in
|
||||
pendingDeleteInstance = instance
|
||||
showingDeleteConfirmation = true
|
||||
reset()
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
sourceToEdit = .remoteServer(instance)
|
||||
} label: {
|
||||
Label(String(localized: "common.edit"), systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteInstance = instance
|
||||
showingDeleteConfirmation = true
|
||||
} label: {
|
||||
Label(String(localized: "common.delete"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func fileSourcesSectionContent(_ sources: [MediaSource]) -> some View {
|
||||
ForEach(Array(sources.enumerated()), id: \.element.id) { index, source in
|
||||
let isLastInSection = index == sources.count - 1
|
||||
|
||||
fileSourceRowView(source, isLast: isLastInSection)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func fileSourceRowView(_ source: MediaSource, isLast: Bool) -> some View {
|
||||
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
|
||||
|
||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||
if needsPassword {
|
||||
Button {
|
||||
sourceToEdit = .fileSource(source)
|
||||
} label: {
|
||||
mediaSourceRow(source, needsPassword: true)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
} else {
|
||||
NavigationLink(value: NavigationDestination.mediaBrowser(source, path: "/")) {
|
||||
mediaSourceRow(source, needsPassword: false)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
|
||||
sourceToEdit = .fileSource(source)
|
||||
reset()
|
||||
}
|
||||
SwipeAction(symbolImage: "trash", tint: .white, background: .red) { reset in
|
||||
pendingDeleteSource = source
|
||||
showingDeleteConfirmation = true
|
||||
reset()
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
sourceToEdit = .fileSource(source)
|
||||
} label: {
|
||||
Label(String(localized: "common.edit"), systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteSource = source
|
||||
showingDeleteConfirmation = true
|
||||
} label: {
|
||||
Label(String(localized: "common.delete"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func instanceRow(_ instance: Instance) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: instance.type.systemImage)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(instance.displayName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: source.type.systemImage)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(source.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("\(source.type.displayName) - \(source.urlDisplayString)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if needsPassword {
|
||||
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unified Source Item
|
||||
|
||||
/// Unified wrapper for sorting instances and media sources together.
|
||||
private enum UnifiedSourceItem: Identifiable {
|
||||
case instance(Instance)
|
||||
case mediaSource(MediaSource)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .instance(let instance):
|
||||
return "instance-\(instance.id.uuidString)"
|
||||
case .mediaSource(let source):
|
||||
return "source-\(source.id.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .instance(let instance):
|
||||
return instance.displayName
|
||||
case .mediaSource(let source):
|
||||
return source.name
|
||||
}
|
||||
}
|
||||
|
||||
var typeDisplayName: String {
|
||||
switch self {
|
||||
case .instance(let instance):
|
||||
return instance.type.displayName
|
||||
case .mediaSource(let source):
|
||||
return source.type.displayName
|
||||
}
|
||||
}
|
||||
|
||||
var dateAdded: Date {
|
||||
switch self {
|
||||
case .instance(let instance):
|
||||
return instance.dateAdded
|
||||
case .mediaSource(let source):
|
||||
return source.dateAdded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
MediaSourcesView()
|
||||
}
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
Reference in New Issue
Block a user