Rework tvOS sources list with sidebar and full-screen add flow

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.
This commit is contained in:
Arkadiusz Fal
2026-04-17 00:27:24 +02:00
parent 10a27a8105
commit 6d3bea7678
5 changed files with 182 additions and 78 deletions

View File

@@ -9,37 +9,54 @@
#if os(tvOS) #if os(tvOS)
import SwiftUI import SwiftUI
struct TVSidebarDetailContainer<Content: View>: View { struct TVSidebarDetailContainer<Content: View, BottomAction: View>: View {
let content: Content let content: Content
let bottomAction: BottomAction
var systemImage: String? var systemImage: String?
var title: String? var title: String?
init(systemImage: String? = nil, title: String? = nil, @ViewBuilder content: () -> Content) { init(
systemImage: String? = nil,
title: String? = nil,
@ViewBuilder bottomAction: () -> BottomAction = { EmptyView() },
@ViewBuilder content: () -> Content
) {
self.content = content() self.content = content()
self.bottomAction = bottomAction()
self.systemImage = systemImage self.systemImage = systemImage
self.title = title self.title = title
} }
var body: some View { var body: some View {
content content
.focusSection()
.safeAreaInset(edge: .leading) { .safeAreaInset(edge: .leading) {
if let systemImage { if let systemImage {
VStack(spacing: 16) { VStack(spacing: 0) {
Spacer() Spacer()
Image(systemName: systemImage) VStack(spacing: 16) {
.font(.system(size: 80)) Image(systemName: systemImage)
.foregroundStyle(.secondary) .font(.system(size: 80))
if let title {
Text(title)
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) if let title {
Text(title)
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
} }
.allowsHitTesting(false)
if BottomAction.self != EmptyView.self {
bottomAction
.padding(.top, 40)
.focusSection()
}
Spacer() Spacer()
} }
.frame(width: 400) .frame(width: 400)
.allowsHitTesting(false)
} else { } else {
Spacer() Spacer()
.frame(width: 400) .frame(width: 400)

View File

@@ -38,21 +38,23 @@ struct MediaSourcesView: View {
var body: some View { var body: some View {
Group { Group {
if isEmpty { #if os(tvOS)
ContentUnavailableView { TVSidebarDetailContainer(
Label(String(localized: "sources.empty.title"), systemImage: "server.rack") systemImage: "server.rack",
} description: { title: String(localized: "sources.title"),
Text(String(localized: "sources.empty.description")) bottomAction: {
} actions: { Button {
Button(String(localized: "sources.addSource")) {
showingAddSheet = true showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
} }
.buttonStyle(.borderedProminent)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) ) {
} else { mediaSourcesInner
sourcesList
} }
#else
mediaSourcesInner
#endif
} }
#if !os(tvOS) #if !os(tvOS)
.navigationTitle(String(localized: "sources.title")) .navigationTitle(String(localized: "sources.title"))
@@ -72,12 +74,25 @@ struct MediaSourcesView: View {
} }
} }
#endif #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 .sheet(item: $sourceToEdit) { source in
EditSourceView(source: source) EditSourceView(source: source)
} }
.sheet(isPresented: $showingAddSheet) { .sheet(isPresented: $showingAddSheet) {
AddSourceView() AddSourceView()
} }
#endif
.confirmationDialog( .confirmationDialog(
deleteConfirmationMessage, deleteConfirmationMessage,
isPresented: $showingDeleteConfirmation, isPresented: $showingDeleteConfirmation,
@@ -91,7 +106,28 @@ struct MediaSourcesView: View {
pendingDeleteSource = nil pendingDeleteSource = nil
} }
} }
#if !os(tvOS)
.presentationCompactAdaptation(.sheet) .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 // MARK: - Private
@@ -388,7 +424,12 @@ struct MediaSourcesView: View {
} }
private func instanceRow(_ instance: Instance) -> some View { private func instanceRow(_ instance: Instance) -> some View {
HStack(spacing: 12) { #if os(tvOS)
let rowSpacing: CGFloat = 24
#else
let rowSpacing: CGFloat = 12
#endif
return HStack(spacing: rowSpacing) {
Image(systemName: instance.type.systemImage) Image(systemName: instance.type.systemImage)
.font(.title2) .font(.title2)
.foregroundStyle(.tint) .foregroundStyle(.tint)
@@ -413,7 +454,12 @@ struct MediaSourcesView: View {
} }
private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View { private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View {
HStack(spacing: 12) { #if os(tvOS)
let rowSpacing: CGFloat = 24
#else
let rowSpacing: CGFloat = 12
#endif
return HStack(spacing: rowSpacing) {
Image(systemName: source.type.systemImage) Image(systemName: source.type.systemImage)
.font(.title2) .font(.title2)
.foregroundStyle(.tint) .foregroundStyle(.tint)

View File

@@ -28,17 +28,27 @@ struct AddSourceView: View {
#if os(tvOS) #if os(tvOS)
listContent listContent
.navigationDestination(isPresented: $navigateToWebDAV) { .navigationDestination(isPresented: $navigateToWebDAV) {
AddWebDAVView( TVSidebarDetailContainer(
prefillURL: discoveredWebDAVURL, systemImage: "externaldrive.connected.to.line.below",
prefillName: discoveredName, title: String(localized: "sources.addWebDAV")
prefillAllowInvalidCertificates: discoveredAllowInvalidCerts ) {
) AddWebDAVView(
prefillURL: discoveredWebDAVURL,
prefillName: discoveredName,
prefillAllowInvalidCertificates: discoveredAllowInvalidCerts
)
}
} }
.navigationDestination(isPresented: $navigateToSMB) { .navigationDestination(isPresented: $navigateToSMB) {
AddSMBView( TVSidebarDetailContainer(
prefillServer: discoveredSMBServer, systemImage: "server.rack",
prefillName: discoveredName title: String(localized: "sources.addSMB")
) ) {
AddSMBView(
prefillServer: discoveredSMBServer,
prefillName: discoveredName
)
}
} }
.sheet(isPresented: $showingNetworkDiscovery) { .sheet(isPresented: $showingNetworkDiscovery) {
NetworkShareDiscoverySheet(filterType: selectedShareType) { share in NetworkShareDiscoverySheet(filterType: selectedShareType) { share in
@@ -99,7 +109,12 @@ struct AddSourceView: View {
NavigationLink { NavigationLink {
#if os(tvOS) #if os(tvOS)
AddWebDAVView() TVSidebarDetailContainer(
systemImage: "externaldrive.connected.to.line.below",
title: String(localized: "sources.addWebDAV")
) {
AddWebDAVView()
}
#else #else
AddWebDAVView(dismissSheet: dismiss) AddWebDAVView(dismissSheet: dismiss)
#endif #endif
@@ -109,7 +124,12 @@ struct AddSourceView: View {
NavigationLink { NavigationLink {
#if os(tvOS) #if os(tvOS)
AddSMBView() TVSidebarDetailContainer(
systemImage: "server.rack",
title: String(localized: "sources.addSMB")
) {
AddSMBView()
}
#else #else
AddSMBView(dismissSheet: dismiss) AddSMBView(dismissSheet: dismiss)
#endif #endif
@@ -119,7 +139,12 @@ struct AddSourceView: View {
NavigationLink { NavigationLink {
#if os(tvOS) #if os(tvOS)
AddRemoteServerView() TVSidebarDetailContainer(
systemImage: "globe",
title: String(localized: "sources.addRemoteServer")
) {
AddRemoteServerView()
}
#else #else
AddRemoteServerView(dismissSheet: dismiss) AddRemoteServerView(dismissSheet: dismiss)
#endif #endif
@@ -128,10 +153,22 @@ struct AddSourceView: View {
} }
NavigationLink { NavigationLink {
#if os(tvOS)
TVSidebarDetailContainer(
systemImage: "globe",
title: String(localized: "sources.browsePeerTube")
) {
if let appEnvironment {
PeerTubeInstancesExploreView()
.appEnvironment(appEnvironment)
}
}
#else
if let appEnvironment { if let appEnvironment {
PeerTubeInstancesExploreView() PeerTubeInstancesExploreView()
.appEnvironment(appEnvironment) .appEnvironment(appEnvironment)
} }
#endif
} label: { } label: {
Label { Label {
Text(String(localized: "sources.browsePeerTube")) Text(String(localized: "sources.browsePeerTube"))

View File

@@ -93,7 +93,7 @@ struct SettingsView: View {
List { List {
if let appEnvironment { if let appEnvironment {
NavigationLink { NavigationLink {
TVSidebarDetailContainer(systemImage: "server.rack", title: String(localized: "sources.title")) { SourcesListView() } SourcesListView()
} label: { } label: {
HStack { HStack {
Label(String(localized: "sources.title"), systemImage: "server.rack") Label(String(localized: "sources.title"), systemImage: "server.rack")

View File

@@ -36,11 +36,24 @@ struct SourcesListView: View {
var body: some View { var body: some View {
Group { Group {
if isEmpty { #if os(tvOS)
emptyState TVSidebarDetailContainer(
} else { systemImage: "server.rack",
sourcesList title: String(localized: "sources.title"),
bottomAction: {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
.accessibilityIdentifier("sources.addButton")
}
) {
sourcesInner
} }
#else
sourcesInner
#endif
} }
#if !os(tvOS) #if !os(tvOS)
.navigationTitle(String(localized: "sources.title")) .navigationTitle(String(localized: "sources.title"))
@@ -48,6 +61,7 @@ struct SourcesListView: View {
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
#if !os(tvOS)
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { Button {
@@ -58,6 +72,7 @@ struct SourcesListView: View {
.accessibilityIdentifier("sources.addButton") .accessibilityIdentifier("sources.addButton")
} }
} }
#endif
#if os(tvOS) #if os(tvOS)
.navigationDestination(isPresented: $showingAddSheet) { .navigationDestination(isPresented: $showingAddSheet) {
TVSidebarDetailContainer(systemImage: "plus.circle", title: String(localized: "sources.newSource")) { AddSourceView() } TVSidebarDetailContainer(systemImage: "plus.circle", title: String(localized: "sources.newSource")) { AddSourceView() }
@@ -89,7 +104,18 @@ struct SourcesListView: View {
pendingDeleteSource = nil pendingDeleteSource = nil
} }
} }
#if !os(tvOS)
.presentationCompactAdaptation(.sheet) .presentationCompactAdaptation(.sheet)
#endif
}
@ViewBuilder
private var sourcesInner: some View {
if isEmpty {
emptyState
} else {
sourcesList
}
} }
// MARK: - Empty State // MARK: - Empty State
@@ -112,37 +138,6 @@ struct SourcesListView: View {
// MARK: - Sources List // MARK: - Sources List
private var sourcesList: some View { private var sourcesList: some View {
#if os(tvOS)
List {
if let manager = instancesManager, !manager.instances.isEmpty {
Section(String(localized: "sources.section.remoteServers")) {
let instances = manager.instances.sorted { $0.dateAdded < $1.dateAdded }
ForEach(instances) { instance in
Button {
sourceToEdit = .remoteServer(instance)
} label: {
instanceRow(instance)
}
}
}
}
let allFileSources = allMediaSources
if !allFileSources.isEmpty {
Section(String(localized: "sources.section.fileSources")) {
ForEach(allFileSources) { source in
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
Button {
sourceToEdit = .fileSource(source)
} label: {
mediaSourceRow(source, needsPassword: needsPassword)
}
}
}
}
}
.listStyle(.grouped)
#else
(listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color) (listStyle == .inset ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color)
.ignoresSafeArea() .ignoresSafeArea()
.overlay( .overlay(
@@ -153,7 +148,6 @@ struct SourcesListView: View {
} }
} }
) )
#endif
} }
// MARK: - Section Header // MARK: - Section Header
@@ -220,7 +214,7 @@ struct SourcesListView: View {
} label: { } label: {
instanceRow(instance) instanceRow(instance)
} }
.buttonStyle(.card) .foregroundStyle(.primary)
} }
#else #else
SourceListRow(isLast: isLast, listStyle: listStyle) { SourceListRow(isLast: isLast, listStyle: listStyle) {
@@ -260,7 +254,12 @@ struct SourcesListView: View {
} }
private func instanceRow(_ instance: Instance) -> some View { private func instanceRow(_ instance: Instance) -> some View {
HStack(spacing: 12) { #if os(tvOS)
let rowSpacing: CGFloat = 24
#else
let rowSpacing: CGFloat = 12
#endif
return HStack(spacing: rowSpacing) {
Image(systemName: instance.type.systemImage) Image(systemName: instance.type.systemImage)
.font(.title2) .font(.title2)
.foregroundStyle(.tint) .foregroundStyle(.tint)
@@ -349,7 +348,7 @@ struct SourcesListView: View {
} label: { } label: {
mediaSourceRow(source, needsPassword: needsPassword) mediaSourceRow(source, needsPassword: needsPassword)
} }
.buttonStyle(.card) .foregroundStyle(.primary)
} }
#else #else
SourceListRow(isLast: isLast, listStyle: listStyle) { SourceListRow(isLast: isLast, listStyle: listStyle) {
@@ -389,7 +388,12 @@ struct SourcesListView: View {
} }
private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View { private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View {
HStack(spacing: 12) { #if os(tvOS)
let rowSpacing: CGFloat = 24
#else
let rowSpacing: CGFloat = 12
#endif
return HStack(spacing: rowSpacing) {
Image(systemName: source.type.systemImage) Image(systemName: source.type.systemImage)
.font(.title2) .font(.title2)
.foregroundStyle(.tint) .foregroundStyle(.tint)