Improve tvOS settings layout: use navigation instead of sheets, fix focus clipping

- Replace sheets with navigationDestination for Add/Edit Source on tvOS
  (tvOS sheets have fixed size that doesn't fit the content)
- Fix focused cell clipping by replacing TVSettingsContainer's frame-based
  layout with safeAreaInset, matching the main settings view pattern
- Use standard List with .listStyle(.grouped) for Sources on tvOS
- Add sidebar icons and titles to TVSettingsContainer for all settings
  subviews, utilizing the left column space
- Remove redundant large navigation titles on tvOS (shown in sidebar)
- Move Edit Source Save button from toolbar into form above Delete button
  for better tvOS focus navigation
This commit is contained in:
Arkadiusz Fal
2026-04-13 18:33:44 +02:00
parent b9a6d76ab3
commit 4b245ec176
12 changed files with 269 additions and 84 deletions

View File

@@ -51,7 +51,9 @@ struct AboutView: View {
versionInfoSection
mpvInfoSection
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.about.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif

View File

@@ -25,43 +25,28 @@ struct AddSourceView: View {
@State private var discoveredAllowInvalidCerts = false
var body: some View {
NavigationStack {
#if os(tvOS)
VStack(spacing: 0) {
HStack {
Button(String(localized: "common.cancel")) {
dismiss()
}
.buttonStyle(TVToolbarButtonStyle())
Spacer()
Text(String(localized: "sources.newSource"))
.font(.title2)
.fontWeight(.semibold)
Spacer()
Text(String(localized: "common.cancel"))
.opacity(0)
}
.padding(.horizontal, 48)
.padding(.vertical, 24)
listContent
}
#if os(tvOS)
listContent
.navigationDestination(isPresented: $navigateToWebDAV) {
AddWebDAVView(
prefillURL: discoveredWebDAVURL,
prefillName: discoveredName,
prefillAllowInvalidCertificates: discoveredAllowInvalidCerts,
dismissSheet: dismiss
prefillAllowInvalidCertificates: discoveredAllowInvalidCerts
)
}
.navigationDestination(isPresented: $navigateToSMB) {
AddSMBView(
prefillServer: discoveredSMBServer,
prefillName: discoveredName,
dismissSheet: dismiss
prefillName: discoveredName
)
}
#else
.sheet(isPresented: $showingNetworkDiscovery) {
NetworkShareDiscoverySheet(filterType: selectedShareType) { share in
handleSelectedShare(share)
}
}
#else
NavigationStack {
listContent
.navigationTitle(String(localized: "sources.newSource"))
#if os(iOS)
@@ -92,13 +77,13 @@ struct AddSourceView: View {
dismissSheet: dismiss
)
}
#endif
}
.sheet(isPresented: $showingNetworkDiscovery) {
NetworkShareDiscoverySheet(filterType: selectedShareType) { share in
handleSelectedShare(share)
}
}
#endif
}
private var listContent: some View {
@@ -113,19 +98,31 @@ struct AddSourceView: View {
#endif
NavigationLink {
#if os(tvOS)
AddWebDAVView()
#else
AddWebDAVView(dismissSheet: dismiss)
#endif
} label: {
Label(String(localized: "sources.addWebDAV"), systemImage: "externaldrive.connected.to.line.below")
}
NavigationLink {
#if os(tvOS)
AddSMBView()
#else
AddSMBView(dismissSheet: dismiss)
#endif
} label: {
Label(String(localized: "sources.addSMB"), systemImage: "server.rack")
}
NavigationLink {
#if os(tvOS)
AddRemoteServerView()
#else
AddRemoteServerView(dismissSheet: dismiss)
#endif
} label: {
Label(String(localized: "sources.addRemoteServer"), systemImage: "globe")
}

View File

@@ -35,7 +35,9 @@ struct AdvancedSettingsView: View {
#endif
developerSection
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.advanced.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif

View File

@@ -35,7 +35,9 @@ struct AppearanceSettingsView: View {
ThumbnailSection(settings: settings)
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.appearance.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif

View File

@@ -77,31 +77,11 @@ private struct EditRemoteServerContent: View {
}
var body: some View {
#if os(tvOS)
formContent
.accessibilityIdentifier("editSource.view")
#else
NavigationStack {
#if os(tvOS)
VStack(spacing: 0) {
HStack {
Button(String(localized: "common.cancel")) {
dismiss()
}
.buttonStyle(TVToolbarButtonStyle())
Spacer()
Text(String(localized: "sources.editSource"))
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(String(localized: "common.save")) {
saveChanges()
}
.buttonStyle(TVToolbarButtonStyle())
}
.padding(.horizontal, 48)
.padding(.vertical, 24)
formContent
.accessibilityIdentifier("editSource.view")
}
#else
formContent
.navigationTitle(String(localized: "sources.editSource"))
#if os(iOS)
@@ -120,8 +100,8 @@ private struct EditRemoteServerContent: View {
}
}
.accessibilityIdentifier("editSource.view")
#endif
}
#endif
}
private var formContent: some View {
@@ -313,6 +293,17 @@ private struct EditRemoteServerContent: View {
testResultSection(result)
}
#if os(tvOS)
Section {
Button {
saveChanges()
} label: {
Label(String(localized: "common.save"), systemImage: "checkmark.circle")
}
.buttonStyle(TVSettingsButtonStyle())
}
#endif
Section {
Button(role: .destructive) {
showingDeleteConfirmation = true
@@ -529,35 +520,14 @@ private struct EditFileSourceContent: View {
}
var body: some View {
NavigationStack {
#if os(tvOS)
VStack(spacing: 0) {
HStack {
Button(String(localized: "common.cancel")) {
dismiss()
}
.buttonStyle(TVToolbarButtonStyle())
Spacer()
Text(String(localized: "sources.editSource"))
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(String(localized: "common.save")) {
saveChanges()
}
.disabled(name.isEmpty)
.buttonStyle(TVToolbarButtonStyle())
}
.padding(.horizontal, 48)
.padding(.vertical, 24)
formContent
}
#if os(tvOS)
formContent
.onAppear {
hasExistingPassword = appEnvironment?.mediaSourcesManager.password(for: source) != nil
smbProtocolVersion = source.smbProtocolVersion ?? .auto
}
#else
#else
NavigationStack {
formContent
.navigationTitle(String(localized: "sources.editSource"))
#if os(iOS)
@@ -581,8 +551,8 @@ private struct EditFileSourceContent: View {
hasExistingPassword = appEnvironment?.mediaSourcesManager.password(for: source) != nil
smbProtocolVersion = source.smbProtocolVersion ?? .auto
}
#endif
}
#endif
}
private var formContent: some View {
@@ -718,6 +688,18 @@ private struct EditFileSourceContent: View {
}
}
#if os(tvOS)
Section {
Button {
saveChanges()
} label: {
Label(String(localized: "common.save"), systemImage: "checkmark.circle")
}
.disabled(name.isEmpty)
.buttonStyle(TVSettingsButtonStyle())
}
#endif
Section {
Button(role: .destructive) {
showingDeleteConfirmation = true

View File

@@ -30,7 +30,9 @@ struct LayoutNavigationSettingsView: View {
HandoffSection(settings: settings)
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.layoutNavigation.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif

View File

@@ -26,7 +26,9 @@ struct PlaybackSettingsView: View {
#endif
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.playback.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif

View File

@@ -19,7 +19,9 @@ struct PrivacySettingsView: View {
historySection
searchSection
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.privacy.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif

View File

@@ -21,6 +21,8 @@ struct SettingsView: View {
#if os(macOS)
macOSSettings
.frame(minWidth: 600, minHeight: 400)
#elseif os(tvOS)
tvOSSettings
#else
iOSSettings
#endif
@@ -83,9 +85,107 @@ struct SettingsView: View {
}
#endif
// MARK: - iOS/tvOS Settings
// MARK: - tvOS Settings
#if os(iOS) || os(tvOS)
#if os(tvOS)
private var tvOSSettings: some View {
NavigationStack {
List {
if let appEnvironment {
NavigationLink {
TVSettingsContainer(systemImage: "server.rack", title: String(localized: "sources.title")) { SourcesListView() }
} label: {
HStack {
Label(String(localized: "sources.title"), systemImage: "server.rack")
Spacer()
if appEnvironment.mediaSourcesManager.hasSourcesNeedingPassword {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
}
}
}
.accessibilityIdentifier("settings.row.sources")
NavigationLink {
TVSettingsContainer(systemImage: "icloud", title: String(localized: "settings.icloud.title")) { iCloudSettingsView() }
} label: {
HStack {
Label(String(localized: "settings.icloud.title"), systemImage: "icloud")
#if DEBUG
Spacer()
Text(String(localized: "settings.icloud.dev.badge"))
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.orange, in: Capsule())
#endif
}
}
NavigationLink { TVSettingsContainer(systemImage: "paintbrush", title: String(localized: "settings.appearance.sectionTitle")) { AppearanceSettingsView() } } label: {
Label(String(localized: "settings.appearance.sectionTitle"), systemImage: "paintbrush")
}
NavigationLink { TVSettingsContainer(systemImage: "hand.tap", title: String(localized: "settings.layoutNavigation.title")) { LayoutNavigationSettingsView() } } label: {
Label(String(localized: "settings.layoutNavigation.title"), systemImage: "hand.tap")
}
NavigationLink { TVSettingsContainer(systemImage: "play.circle", title: String(localized: "settings.playback.sectionTitle")) { PlaybackSettingsView() } } label: {
Label(String(localized: "settings.playback.sectionTitle"), systemImage: "play.circle")
}
NavigationLink { TVSettingsContainer(systemImage: "hand.raised", title: String(localized: "settings.privacy.title")) { PrivacySettingsView() } } label: {
Label(String(localized: "settings.privacy.title"), systemImage: "hand.raised")
}
NavigationLink { TVSettingsContainer(systemImage: "gearshape.2", title: String(localized: "settings.advanced.title")) { AdvancedSettingsView() } } label: {
Label(String(localized: "settings.advanced.title"), systemImage: "gearshape.2")
}
if appEnvironment.instancesManager.enabledInstances.contains(where: \.isYouTubeInstance) {
NavigationLink { TVSettingsContainer(systemImage: "play.rectangle", title: String(localized: "settings.youtubeEnhancements.title")) { YouTubeEnhancementsSettingsView() } } label: {
Label(String(localized: "settings.youtubeEnhancements.title"), systemImage: "play.rectangle")
}
}
NavigationLink { TVSettingsContainer(systemImage: "info.circle", title: String(localized: "settings.about.title")) { AboutView() } } label: {
Label(String(localized: "settings.about.title"), systemImage: "info.circle")
}
}
}
.listStyle(.grouped)
.safeAreaInset(edge: .leading) {
VStack(spacing: 20) {
Spacer()
Image("AppIconPreview")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 46))
Text(verbatim: "Yattee")
.font(.title2)
.fontWeight(.semibold)
Text("\(appVersion) (\(buildNumber))")
.font(.callout)
.foregroundStyle(.secondary)
Spacer()
}
.frame(width: 400)
.allowsHitTesting(false)
}
.accessibilityIdentifier("settings.view")
}
}
#endif
// MARK: - iOS Settings
#if os(iOS)
private var iOSSettings: some View {
NavigationStack {
List {
@@ -206,12 +306,8 @@ struct SettingsView: View {
}
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
if showCloseButton {
ToolbarItem(placement: .confirmationAction) {
@@ -288,6 +384,50 @@ enum SettingsSection: String, CaseIterable, Identifiable {
}
}
// MARK: - tvOS Settings Container
#if os(tvOS)
struct TVSettingsContainer<Content: View>: View {
let content: Content
var systemImage: String?
var title: String?
init(systemImage: String? = nil, title: String? = nil, @ViewBuilder content: () -> Content) {
self.content = content()
self.systemImage = systemImage
self.title = title
}
var body: some View {
content
.safeAreaInset(edge: .leading) {
if let systemImage {
VStack(spacing: 16) {
Spacer()
Image(systemName: systemImage)
.font(.system(size: 80))
.foregroundStyle(.secondary)
if let title {
Text(title)
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
}
.frame(width: 400)
.allowsHitTesting(false)
} else {
Spacer()
.frame(width: 400)
.allowsHitTesting(false)
}
}
}
}
#endif
#Preview {
SettingsView()
.appEnvironment(.preview)

View File

@@ -42,7 +42,9 @@ struct SourcesListView: View {
sourcesList
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "sources.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -56,12 +58,24 @@ struct SourcesListView: View {
.accessibilityIdentifier("sources.addButton")
}
}
#if os(tvOS)
.navigationDestination(isPresented: $showingAddSheet) {
TVSettingsContainer(systemImage: "plus.circle", title: String(localized: "sources.newSource")) { AddSourceView() }
}
#else
.sheet(isPresented: $showingAddSheet) {
AddSourceView()
}
#endif
#if os(tvOS)
.navigationDestination(item: $sourceToEdit) { source in
TVSettingsContainer(systemImage: "pencil.circle", title: String(localized: "sources.editSource")) { EditSourceView(source: source) }
}
#else
.sheet(item: $sourceToEdit) { source in
EditSourceView(source: source)
}
#endif
.confirmationDialog(
deleteConfirmationMessage,
isPresented: $showingDeleteConfirmation,
@@ -98,6 +112,37 @@ 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(
@@ -108,6 +153,7 @@ struct SourcesListView: View {
}
}
)
#endif
}
// MARK: - Section Header
@@ -131,9 +177,13 @@ struct SourcesListView: View {
LazyVStack(spacing: 0) {
content()
}
#if os(tvOS)
.padding(.horizontal, 16)
#else
.background(ListBackgroundStyle.card.color)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
#endif
.padding(.bottom, 16)
} else {
LazyVStack(spacing: 0) {

View File

@@ -18,7 +18,9 @@ struct YouTubeEnhancementsSettingsView: View {
DeArrowSection(settings: settings)
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.youtubeEnhancements.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif

View File

@@ -82,7 +82,9 @@ struct iCloudSettingsView: View {
syncStatusSection
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.icloud.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif