mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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:
@@ -9,37 +9,54 @@
|
||||
#if os(tvOS)
|
||||
import SwiftUI
|
||||
|
||||
struct TVSidebarDetailContainer<Content: View>: View {
|
||||
struct TVSidebarDetailContainer<Content: View, BottomAction: View>: View {
|
||||
let content: Content
|
||||
let bottomAction: BottomAction
|
||||
var systemImage: 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.bottomAction = bottomAction()
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.focusSection()
|
||||
.safeAreaInset(edge: .leading) {
|
||||
if let systemImage {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(.secondary)
|
||||
if let title {
|
||||
Text(title)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 80))
|
||||
.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()
|
||||
}
|
||||
.frame(width: 400)
|
||||
.allowsHitTesting(false)
|
||||
} else {
|
||||
Spacer()
|
||||
.frame(width: 400)
|
||||
|
||||
@@ -38,21 +38,23 @@ struct MediaSourcesView: View {
|
||||
|
||||
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")) {
|
||||
#if os(tvOS)
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "server.rack",
|
||||
title: String(localized: "sources.title"),
|
||||
bottomAction: {
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
Label(String(localized: "sources.addSource"), systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
sourcesList
|
||||
) {
|
||||
mediaSourcesInner
|
||||
}
|
||||
#else
|
||||
mediaSourcesInner
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle(String(localized: "sources.title"))
|
||||
@@ -72,12 +74,25 @@ struct MediaSourcesView: View {
|
||||
}
|
||||
}
|
||||
#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,
|
||||
@@ -91,7 +106,28 @@ struct MediaSourcesView: View {
|
||||
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
|
||||
@@ -388,7 +424,12 @@ struct MediaSourcesView: 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)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
@@ -413,7 +454,12 @@ struct MediaSourcesView: 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)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
@@ -28,17 +28,27 @@ struct AddSourceView: View {
|
||||
#if os(tvOS)
|
||||
listContent
|
||||
.navigationDestination(isPresented: $navigateToWebDAV) {
|
||||
AddWebDAVView(
|
||||
prefillURL: discoveredWebDAVURL,
|
||||
prefillName: discoveredName,
|
||||
prefillAllowInvalidCertificates: discoveredAllowInvalidCerts
|
||||
)
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "externaldrive.connected.to.line.below",
|
||||
title: String(localized: "sources.addWebDAV")
|
||||
) {
|
||||
AddWebDAVView(
|
||||
prefillURL: discoveredWebDAVURL,
|
||||
prefillName: discoveredName,
|
||||
prefillAllowInvalidCertificates: discoveredAllowInvalidCerts
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToSMB) {
|
||||
AddSMBView(
|
||||
prefillServer: discoveredSMBServer,
|
||||
prefillName: discoveredName
|
||||
)
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "server.rack",
|
||||
title: String(localized: "sources.addSMB")
|
||||
) {
|
||||
AddSMBView(
|
||||
prefillServer: discoveredSMBServer,
|
||||
prefillName: discoveredName
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNetworkDiscovery) {
|
||||
NetworkShareDiscoverySheet(filterType: selectedShareType) { share in
|
||||
@@ -99,7 +109,12 @@ struct AddSourceView: View {
|
||||
|
||||
NavigationLink {
|
||||
#if os(tvOS)
|
||||
AddWebDAVView()
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "externaldrive.connected.to.line.below",
|
||||
title: String(localized: "sources.addWebDAV")
|
||||
) {
|
||||
AddWebDAVView()
|
||||
}
|
||||
#else
|
||||
AddWebDAVView(dismissSheet: dismiss)
|
||||
#endif
|
||||
@@ -109,7 +124,12 @@ struct AddSourceView: View {
|
||||
|
||||
NavigationLink {
|
||||
#if os(tvOS)
|
||||
AddSMBView()
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "server.rack",
|
||||
title: String(localized: "sources.addSMB")
|
||||
) {
|
||||
AddSMBView()
|
||||
}
|
||||
#else
|
||||
AddSMBView(dismissSheet: dismiss)
|
||||
#endif
|
||||
@@ -119,7 +139,12 @@ struct AddSourceView: View {
|
||||
|
||||
NavigationLink {
|
||||
#if os(tvOS)
|
||||
AddRemoteServerView()
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "globe",
|
||||
title: String(localized: "sources.addRemoteServer")
|
||||
) {
|
||||
AddRemoteServerView()
|
||||
}
|
||||
#else
|
||||
AddRemoteServerView(dismissSheet: dismiss)
|
||||
#endif
|
||||
@@ -128,10 +153,22 @@ struct AddSourceView: View {
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
#if os(tvOS)
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "globe",
|
||||
title: String(localized: "sources.browsePeerTube")
|
||||
) {
|
||||
if let appEnvironment {
|
||||
PeerTubeInstancesExploreView()
|
||||
.appEnvironment(appEnvironment)
|
||||
}
|
||||
}
|
||||
#else
|
||||
if let appEnvironment {
|
||||
PeerTubeInstancesExploreView()
|
||||
.appEnvironment(appEnvironment)
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
Label {
|
||||
Text(String(localized: "sources.browsePeerTube"))
|
||||
|
||||
@@ -93,7 +93,7 @@ struct SettingsView: View {
|
||||
List {
|
||||
if let appEnvironment {
|
||||
NavigationLink {
|
||||
TVSidebarDetailContainer(systemImage: "server.rack", title: String(localized: "sources.title")) { SourcesListView() }
|
||||
SourcesListView()
|
||||
} label: {
|
||||
HStack {
|
||||
Label(String(localized: "sources.title"), systemImage: "server.rack")
|
||||
|
||||
@@ -36,11 +36,24 @@ struct SourcesListView: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
sourcesList
|
||||
#if os(tvOS)
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "server.rack",
|
||||
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)
|
||||
.navigationTitle(String(localized: "sources.title"))
|
||||
@@ -48,6 +61,7 @@ struct SourcesListView: View {
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
@@ -58,6 +72,7 @@ struct SourcesListView: View {
|
||||
.accessibilityIdentifier("sources.addButton")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
.navigationDestination(isPresented: $showingAddSheet) {
|
||||
TVSidebarDetailContainer(systemImage: "plus.circle", title: String(localized: "sources.newSource")) { AddSourceView() }
|
||||
@@ -89,7 +104,18 @@ struct SourcesListView: View {
|
||||
pendingDeleteSource = nil
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.presentationCompactAdaptation(.sheet)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sourcesInner: some View {
|
||||
if isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
sourcesList
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
@@ -112,37 +138,6 @@ struct SourcesListView: View {
|
||||
// MARK: - Sources List
|
||||
|
||||
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)
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
@@ -153,7 +148,6 @@ struct SourcesListView: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Section Header
|
||||
@@ -220,7 +214,7 @@ struct SourcesListView: View {
|
||||
} label: {
|
||||
instanceRow(instance)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
#else
|
||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||
@@ -260,7 +254,12 @@ struct SourcesListView: 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)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
@@ -349,7 +348,7 @@ struct SourcesListView: View {
|
||||
} label: {
|
||||
mediaSourceRow(source, needsPassword: needsPassword)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
#else
|
||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||
@@ -389,7 +388,12 @@ struct SourcesListView: 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)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
Reference in New Issue
Block a user