mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 10:55: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 pendingDeleteInstance: Instance?
|
||||||
@State private var pendingDeleteSource: MediaSource?
|
@State private var pendingDeleteSource: MediaSource?
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
@FocusState private var firstSourceFocused: Bool
|
||||||
|
#endif
|
||||||
|
|
||||||
private var instancesManager: InstancesManager? {
|
private var instancesManager: InstancesManager? {
|
||||||
appEnvironment?.instancesManager
|
appEnvironment?.instancesManager
|
||||||
}
|
}
|
||||||
@@ -41,16 +45,28 @@ struct MediaSourcesView: View {
|
|||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
TVSidebarDetailContainer(
|
TVSidebarDetailContainer(
|
||||||
systemImage: "server.rack",
|
systemImage: "server.rack",
|
||||||
title: String(localized: "sources.title"),
|
title: String(localized: "sources.title")
|
||||||
bottomAction: {
|
|
||||||
Button {
|
|
||||||
showingAddSheet = true
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "sources.addSource"), systemImage: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
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
|
#else
|
||||||
mediaSourcesInner
|
mediaSourcesInner
|
||||||
@@ -179,11 +195,19 @@ struct MediaSourcesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
#if os(tvOS)
|
||||||
|
.onAppear {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
|
firstSourceFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var groupedSourcesSections: some View {
|
private var groupedSourcesSections: some View {
|
||||||
let settings = sourcesSettings
|
let settings = sourcesSettings
|
||||||
|
let hasInstances = !(instancesManager?.enabledInstances.isEmpty ?? true)
|
||||||
|
|
||||||
// Instances section
|
// Instances section
|
||||||
if let manager = instancesManager, !manager.enabledInstances.isEmpty {
|
if let manager = instancesManager, !manager.enabledInstances.isEmpty {
|
||||||
@@ -191,7 +215,7 @@ struct MediaSourcesView: View {
|
|||||||
|
|
||||||
let sortedInstances = settings?.sorted(manager.enabledInstances) ?? manager.enabledInstances
|
let sortedInstances = settings?.sorted(manager.enabledInstances) ?? manager.enabledInstances
|
||||||
sectionCard {
|
sectionCard {
|
||||||
instancesSectionContent(sortedInstances)
|
instancesSectionContent(sortedInstances, firstIsGlobalFirst: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +225,7 @@ struct MediaSourcesView: View {
|
|||||||
|
|
||||||
let sortedSources = settings?.sorted(manager.enabledSources) ?? manager.enabledSources
|
let sortedSources = settings?.sorted(manager.enabledSources) ?? manager.enabledSources
|
||||||
sectionCard {
|
sectionCard {
|
||||||
fileSourcesSectionContent(sortedSources)
|
fileSourcesSectionContent(sortedSources, firstIsGlobalFirst: !hasInstances)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,12 +240,13 @@ struct MediaSourcesView: View {
|
|||||||
sectionCard {
|
sectionCard {
|
||||||
ForEach(Array(sortedSources.enumerated()), id: \.element.id) { index, item in
|
ForEach(Array(sortedSources.enumerated()), id: \.element.id) { index, item in
|
||||||
let isLast = index == sortedSources.count - 1
|
let isLast = index == sortedSources.count - 1
|
||||||
|
let isFirst = index == 0
|
||||||
|
|
||||||
switch item {
|
switch item {
|
||||||
case .instance(let instance):
|
case .instance(let instance):
|
||||||
instanceRowView(instance, isLast: isLast)
|
instanceRowView(instance, isLast: isLast, isFirst: isFirst)
|
||||||
case .mediaSource(let source):
|
case .mediaSource(let source):
|
||||||
fileSourceRowView(source, isLast: isLast)
|
fileSourceRowView(source, isLast: isLast, isFirst: isFirst)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,22 +350,25 @@ struct MediaSourcesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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
|
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
|
||||||
let isLastInSection = index == instances.count - 1
|
let isLastInSection = index == instances.count - 1
|
||||||
|
|
||||||
instanceRowView(instance, isLast: isLastInSection)
|
instanceRowView(instance, isLast: isLastInSection, isFirst: firstIsGlobalFirst && index == 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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) {
|
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||||
NavigationLink(value: NavigationDestination.instanceBrowse(instance)) {
|
NavigationLink(value: NavigationDestination.instanceBrowse(instance)) {
|
||||||
instanceRow(instance)
|
instanceRow(instance)
|
||||||
}
|
}
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
|
||||||
|
#endif
|
||||||
.swipeActions {
|
.swipeActions {
|
||||||
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
|
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
|
||||||
sourceToEdit = .remoteServer(instance)
|
sourceToEdit = .remoteServer(instance)
|
||||||
@@ -369,16 +397,16 @@ struct MediaSourcesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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
|
ForEach(Array(sources.enumerated()), id: \.element.id) { index, source in
|
||||||
let isLastInSection = index == sources.count - 1
|
let isLastInSection = index == sources.count - 1
|
||||||
|
|
||||||
fileSourceRowView(source, isLast: isLastInSection)
|
fileSourceRowView(source, isLast: isLastInSection, isFirst: firstIsGlobalFirst && index == 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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
|
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
|
||||||
|
|
||||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||||
@@ -396,6 +424,9 @@ struct MediaSourcesView: View {
|
|||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
|
||||||
|
#endif
|
||||||
.swipeActions {
|
.swipeActions {
|
||||||
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
|
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
|
||||||
sourceToEdit = .fileSource(source)
|
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
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ struct SourcesListView: View {
|
|||||||
@State private var pendingDeleteInstance: Instance?
|
@State private var pendingDeleteInstance: Instance?
|
||||||
@State private var pendingDeleteSource: MediaSource?
|
@State private var pendingDeleteSource: MediaSource?
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
@FocusState private var firstSourceFocused: Bool
|
||||||
|
#endif
|
||||||
|
|
||||||
private var instancesManager: InstancesManager? {
|
private var instancesManager: InstancesManager? {
|
||||||
appEnvironment?.instancesManager
|
appEnvironment?.instancesManager
|
||||||
}
|
}
|
||||||
@@ -39,17 +43,26 @@ struct SourcesListView: View {
|
|||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
TVSidebarDetailContainer(
|
TVSidebarDetailContainer(
|
||||||
systemImage: "server.rack",
|
systemImage: "server.rack",
|
||||||
title: String(localized: "sources.title"),
|
title: String(localized: "sources.title")
|
||||||
bottomAction: {
|
|
||||||
Button {
|
|
||||||
showingAddSheet = true
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "sources.addSource"), systemImage: "plus")
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier("sources.addButton")
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
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
|
#else
|
||||||
sourcesInner
|
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
|
// MARK: - Section Header
|
||||||
@@ -199,14 +219,14 @@ struct SourcesListView: View {
|
|||||||
sectionCard {
|
sectionCard {
|
||||||
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
|
ForEach(Array(instances.enumerated()), id: \.element.id) { index, instance in
|
||||||
let isLast = index == instances.count - 1
|
let isLast = index == instances.count - 1
|
||||||
instanceRowView(instance, isLast: isLast)
|
instanceRowView(instance, isLast: isLast, isFirst: index == 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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)
|
#if os(tvOS)
|
||||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||||
Button {
|
Button {
|
||||||
@@ -216,6 +236,7 @@ struct SourcesListView: View {
|
|||||||
}
|
}
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
}
|
}
|
||||||
|
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
|
||||||
#else
|
#else
|
||||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||||
Button {
|
Button {
|
||||||
@@ -314,13 +335,14 @@ struct SourcesListView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var fileSourcesSection: some View {
|
private var fileSourcesSection: some View {
|
||||||
let allFileSources = allMediaSources
|
let allFileSources = allMediaSources
|
||||||
|
let noRemoteServers = instancesManager?.instances.isEmpty ?? true
|
||||||
if !allFileSources.isEmpty {
|
if !allFileSources.isEmpty {
|
||||||
sectionHeader(String(localized: "sources.section.fileSources"))
|
sectionHeader(String(localized: "sources.section.fileSources"))
|
||||||
|
|
||||||
sectionCard {
|
sectionCard {
|
||||||
ForEach(Array(allFileSources.enumerated()), id: \.element.id) { index, source in
|
ForEach(Array(allFileSources.enumerated()), id: \.element.id) { index, source in
|
||||||
let isLast = index == allFileSources.count - 1
|
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
|
@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
|
let needsPassword = mediaSourcesManager?.needsPassword(for: source) ?? false
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@@ -350,6 +372,7 @@ struct SourcesListView: View {
|
|||||||
}
|
}
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
}
|
}
|
||||||
|
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
|
||||||
#else
|
#else
|
||||||
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
SourceListRow(isLast: isLast, listStyle: listStyle) {
|
||||||
Button {
|
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
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
Reference in New Issue
Block a user