// // 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(@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) }