From 6d3bea7678466cc0f2808232582575882dc8770b Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 17 Apr 2026 00:27:24 +0200 Subject: [PATCH] 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. --- .../Components/TVSidebarDetailContainer.swift | 41 ++++++--- .../Views/MediaBrowser/MediaSourcesView.swift | 72 +++++++++++++--- Yattee/Views/Settings/AddSourceView.swift | 61 +++++++++++--- Yattee/Views/Settings/SettingsView.swift | 2 +- Yattee/Views/Settings/SourcesListView.swift | 84 ++++++++++--------- 5 files changed, 182 insertions(+), 78 deletions(-) diff --git a/Yattee/Views/Components/TVSidebarDetailContainer.swift b/Yattee/Views/Components/TVSidebarDetailContainer.swift index 7c2b94d6..980d845f 100644 --- a/Yattee/Views/Components/TVSidebarDetailContainer.swift +++ b/Yattee/Views/Components/TVSidebarDetailContainer.swift @@ -9,37 +9,54 @@ #if os(tvOS) import SwiftUI -struct TVSidebarDetailContainer: View { +struct TVSidebarDetailContainer: 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) diff --git a/Yattee/Views/MediaBrowser/MediaSourcesView.swift b/Yattee/Views/MediaBrowser/MediaSourcesView.swift index 50963fa5..eab8903c 100644 --- a/Yattee/Views/MediaBrowser/MediaSourcesView.swift +++ b/Yattee/Views/MediaBrowser/MediaSourcesView.swift @@ -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) diff --git a/Yattee/Views/Settings/AddSourceView.swift b/Yattee/Views/Settings/AddSourceView.swift index e6315ed1..e6945d58 100644 --- a/Yattee/Views/Settings/AddSourceView.swift +++ b/Yattee/Views/Settings/AddSourceView.swift @@ -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")) diff --git a/Yattee/Views/Settings/SettingsView.swift b/Yattee/Views/Settings/SettingsView.swift index 1aec8bb7..41ca3250 100644 --- a/Yattee/Views/Settings/SettingsView.swift +++ b/Yattee/Views/Settings/SettingsView.swift @@ -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") diff --git a/Yattee/Views/Settings/SourcesListView.swift b/Yattee/Views/Settings/SourcesListView.swift index b2a5b3e5..069d5652 100644 --- a/Yattee/Views/Settings/SourcesListView.swift +++ b/Yattee/Views/Settings/SourcesListView.swift @@ -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)