mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
Give TVSidebarDetailContainer an optional bottom action slot and use it to show the Add Source button beside the sources list on tvOS. Switch the Settings > Sources list from a focus-capturing List to the same ScrollView+LazyVStack layout MediaSourcesView already uses, drop .buttonStyle(.card) so row icons no longer clip, and bump the row icon-to-title spacing to 24pt. Replace the sheet-based Add/Edit flow in MediaSourcesView with navigationDestinations wrapped in the sidebar container, and decorate each Add Source form (WebDAV, SMB, remote server, PeerTube browse) with its own sidebar icon and title.
545 lines
18 KiB
Swift
545 lines
18 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 os(tvOS)
|
|
TVSidebarDetailContainer(
|
|
systemImage: "server.rack",
|
|
title: String(localized: "sources.title"),
|
|
bottomAction: {
|
|
Button {
|
|
showingAddSheet = true
|
|
} label: {
|
|
Label(String(localized: "sources.addSource"), systemImage: "plus")
|
|
}
|
|
}
|
|
) {
|
|
mediaSourcesInner
|
|
}
|
|
#else
|
|
mediaSourcesInner
|
|
#endif
|
|
}
|
|
#if !os(tvOS)
|
|
.navigationTitle(String(localized: "sources.title"))
|
|
.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
|
|
#if os(tvOS)
|
|
.navigationDestination(item: $sourceToEdit) { source in
|
|
TVSidebarDetailContainer(systemImage: "pencil.circle", title: String(localized: "sources.editSource")) {
|
|
EditSourceView(source: source)
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: $showingAddSheet) {
|
|
TVSidebarDetailContainer(systemImage: "plus.circle", title: String(localized: "sources.newSource")) {
|
|
AddSourceView()
|
|
}
|
|
}
|
|
#else
|
|
.sheet(item: $sourceToEdit) { source in
|
|
EditSourceView(source: source)
|
|
}
|
|
.sheet(isPresented: $showingAddSheet) {
|
|
AddSourceView()
|
|
}
|
|
#endif
|
|
.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
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
.presentationCompactAdaptation(.sheet)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var mediaSourcesInner: some View {
|
|
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)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
sourcesList
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
#if os(tvOS)
|
|
let rowSpacing: CGFloat = 24
|
|
#else
|
|
let rowSpacing: CGFloat = 12
|
|
#endif
|
|
return HStack(spacing: rowSpacing) {
|
|
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 {
|
|
#if os(tvOS)
|
|
let rowSpacing: CGFloat = 24
|
|
#else
|
|
let rowSpacing: CGFloat = 12
|
|
#endif
|
|
return HStack(spacing: rowSpacing) {
|
|
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)
|
|
}
|