mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 02:45:03 +00:00
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:
@@ -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: {
|
||||
title: String(localized: "sources.title")
|
||||
) {
|
||||
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 {
|
||||
|
||||
@@ -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: {
|
||||
title: String(localized: "sources.title")
|
||||
) {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user