Move tvOS sources actions to top bar with first-row focus

Sidebar buttons in TVSidebarDetailContainer were hard to focus from
the content list. Move the Add Source (and sort/group menu for Media
Sources) to a top HStack wrapped in focusSection(), matching the
pattern used in MediaBrowserView. Default focus lands on the first
source row via @FocusState + FirstRowFocusModifier.
This commit is contained in:
Arkadiusz Fal
2026-04-18 12:18:38 +02:00
parent f60a6e3eec
commit d0b4d0e64e
2 changed files with 117 additions and 33 deletions

View File

@@ -15,6 +15,10 @@ struct MediaSourcesView: View {
@State private var pendingDeleteInstance: Instance?
@State private var pendingDeleteSource: MediaSource?
#if os(tvOS)
@FocusState private var firstSourceFocused: Bool
#endif
private var instancesManager: InstancesManager? {
appEnvironment?.instancesManager
}
@@ -41,16 +45,28 @@ struct MediaSourcesView: View {
#if os(tvOS)
TVSidebarDetailContainer(
systemImage: "server.rack",
title: String(localized: "sources.title"),
bottomAction: {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
}
title: String(localized: "sources.title")
) {
mediaSourcesInner
VStack(spacing: 0) {
HStack(spacing: 24) {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
if let settings = sourcesSettings {
sortAndGroupMenu(settings)
}
Spacer()
}
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 8)
.focusSection()
mediaSourcesInner
.focusSection()
}
}
#else
mediaSourcesInner
@@ -179,11 +195,19 @@ struct MediaSourcesView: View {
}
}
)
#if os(tvOS)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
firstSourceFocused = true
}
}
#endif
}
@ViewBuilder
private var groupedSourcesSections: some View {
let settings = sourcesSettings
let hasInstances = !(instancesManager?.enabledInstances.isEmpty ?? true)
// Instances section
if let manager = instancesManager, !manager.enabledInstances.isEmpty {
@@ -191,7 +215,7 @@ struct MediaSourcesView: View {
let sortedInstances = settings?.sorted(manager.enabledInstances) ?? manager.enabledInstances
sectionCard {
instancesSectionContent(sortedInstances)
instancesSectionContent(sortedInstances, firstIsGlobalFirst: true)
}
}
@@ -201,7 +225,7 @@ struct MediaSourcesView: View {
let sortedSources = settings?.sorted(manager.enabledSources) ?? manager.enabledSources
sectionCard {
fileSourcesSectionContent(sortedSources)
fileSourcesSectionContent(sortedSources, firstIsGlobalFirst: !hasInstances)
}
}
}
@@ -216,12 +240,13 @@ struct MediaSourcesView: View {
sectionCard {
ForEach(Array(sortedSources.enumerated()), id: \.element.id) { index, item in
let isLast = index == sortedSources.count - 1
let isFirst = index == 0
switch item {
case .instance(let instance):
instanceRowView(instance, isLast: isLast)
instanceRowView(instance, isLast: isLast, isFirst: isFirst)
case .mediaSource(let source):
fileSourceRowView(source, isLast: isLast)
fileSourceRowView(source, isLast: isLast, isFirst: isFirst)
}
}
}
@@ -325,22 +350,25 @@ struct MediaSourcesView: View {
}
@ViewBuilder
private func instancesSectionContent(_ instances: [Instance]) -> some View {
private func instancesSectionContent(_ instances: [Instance], firstIsGlobalFirst: Bool = false) -> some View {
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
let isLastInSection = index == instances.count - 1
instanceRowView(instance, isLast: isLastInSection)
instanceRowView(instance, isLast: isLastInSection, isFirst: firstIsGlobalFirst && index == 0)
}
}
@ViewBuilder
private func instanceRowView(_ instance: Instance, isLast: Bool) -> some View {
private func instanceRowView(_ instance: Instance, isLast: Bool, isFirst: Bool = false) -> some View {
SourceListRow(isLast: isLast, listStyle: listStyle) {
NavigationLink(value: NavigationDestination.instanceBrowse(instance)) {
instanceRow(instance)
}
.foregroundStyle(.primary)
}
#if os(tvOS)
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
#endif
.swipeActions {
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
sourceToEdit = .remoteServer(instance)
@@ -369,16 +397,16 @@ struct MediaSourcesView: View {
}
@ViewBuilder
private func fileSourcesSectionContent(_ sources: [MediaSource]) -> some View {
private func fileSourcesSectionContent(_ sources: [MediaSource], firstIsGlobalFirst: Bool = false) -> some View {
ForEach(Array(sources.enumerated()), id: \.element.id) { index, source in
let isLastInSection = index == sources.count - 1
fileSourceRowView(source, isLast: isLastInSection)
fileSourceRowView(source, isLast: isLastInSection, isFirst: firstIsGlobalFirst && index == 0)
}
}
@ViewBuilder
private func fileSourceRowView(_ source: MediaSource, isLast: Bool) -> some View {
private func fileSourceRowView(_ source: MediaSource, isLast: Bool, isFirst: Bool = false) -> some View {
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
SourceListRow(isLast: isLast, listStyle: listStyle) {
@@ -396,6 +424,9 @@ struct MediaSourcesView: View {
.foregroundStyle(.primary)
}
}
#if os(tvOS)
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
#endif
.swipeActions {
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
sourceToEdit = .fileSource(source)
@@ -534,6 +565,21 @@ private enum UnifiedSourceItem: Identifiable {
}
}
#if os(tvOS)
private struct FirstRowFocusModifier: ViewModifier {
let isFirst: Bool
var focus: FocusState<Bool>.Binding
func body(content: Content) -> some View {
if isFirst {
content.focused(focus)
} else {
content
}
}
}
#endif
// MARK: - Preview
#Preview {

View File

@@ -17,6 +17,10 @@ struct SourcesListView: View {
@State private var pendingDeleteInstance: Instance?
@State private var pendingDeleteSource: MediaSource?
#if os(tvOS)
@FocusState private var firstSourceFocused: Bool
#endif
private var instancesManager: InstancesManager? {
appEnvironment?.instancesManager
}
@@ -39,17 +43,26 @@ struct SourcesListView: View {
#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")
}
title: String(localized: "sources.title")
) {
sourcesInner
VStack(spacing: 0) {
HStack(spacing: 24) {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "sources.addSource"), systemImage: "plus")
}
.accessibilityIdentifier("sources.addButton")
Spacer()
}
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 8)
.focusSection()
sourcesInner
.focusSection()
}
}
#else
sourcesInner
@@ -148,6 +161,13 @@ struct SourcesListView: View {
}
}
)
#if os(tvOS)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
firstSourceFocused = true
}
}
#endif
}
// MARK: - Section Header
@@ -199,14 +219,14 @@ struct SourcesListView: View {
sectionCard {
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
let isLast = index == instances.count - 1
instanceRowView(instance, isLast: isLast)
instanceRowView(instance, isLast: isLast, isFirst: index == 0)
}
}
}
}
@ViewBuilder
private func instanceRowView(_ instance: Instance, isLast: Bool) -> some View {
private func instanceRowView(_ instance: Instance, isLast: Bool, isFirst: Bool = false) -> some View {
#if os(tvOS)
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
@@ -216,6 +236,7 @@ struct SourcesListView: View {
}
.foregroundStyle(.primary)
}
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
#else
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
@@ -314,13 +335,14 @@ struct SourcesListView: View {
@ViewBuilder
private var fileSourcesSection: some View {
let allFileSources = allMediaSources
let noRemoteServers = instancesManager?.instances.isEmpty ?? true
if !allFileSources.isEmpty {
sectionHeader(String(localized: "sources.section.fileSources"))
sectionCard {
ForEach(Array(allFileSources.enumerated()), id: \.element.id) { index, source in
let isLast = index == allFileSources.count - 1
fileSourceRowView(source, isLast: isLast)
fileSourceRowView(source, isLast: isLast, isFirst: noRemoteServers && index == 0)
}
}
}
@@ -338,7 +360,7 @@ struct SourcesListView: View {
}
@ViewBuilder
private func fileSourceRowView(_ source: MediaSource, isLast: Bool) -> some View {
private func fileSourceRowView(_ source: MediaSource, isLast: Bool, isFirst: Bool = false) -> some View {
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
#if os(tvOS)
@@ -350,6 +372,7 @@ struct SourcesListView: View {
}
.foregroundStyle(.primary)
}
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
#else
SourceListRow(isLast: isLast, listStyle: listStyle) {
Button {
@@ -465,6 +488,21 @@ struct SourcesListView: View {
}
}
#if os(tvOS)
private struct FirstRowFocusModifier: ViewModifier {
let isFirst: Bool
var focus: FocusState<Bool>.Binding
func body(content: Content) -> some View {
if isFirst {
content.focused(focus)
} else {
content
}
}
}
#endif
// MARK: - Preview
#Preview {