mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
497 lines
17 KiB
Swift
497 lines
17 KiB
Swift
//
|
|
// 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)
|
|
}
|