From cc109043b32ab76b1d5d10402c35cdf0a362a529 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 6 May 2026 23:01:02 +0200 Subject: [PATCH] Unstick more tvOS focus dead-ends in channel views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Notifications → Manage Channels: wrap the tvOS NavigationLink destination in TVSidebarDetailContainer(showsDismissButton: true) so the no-subscriptions, error, and loading states all have a focusable Done. Channels sidebar tab: lift the tvOS search field + View Options button out of the loaded-channels branch and render it above every state. The empty state previously had zero focusable elements, leaving the right pane blank when swiping in from the sidebar. --- .../Settings/NotificationSettingsView.swift | 8 +- .../Subscriptions/ManageChannelsView.swift | 103 ++++++++++-------- 2 files changed, 66 insertions(+), 45 deletions(-) diff --git a/Yattee/Views/Settings/NotificationSettingsView.swift b/Yattee/Views/Settings/NotificationSettingsView.swift index f5cd7cde..11b362a5 100644 --- a/Yattee/Views/Settings/NotificationSettingsView.swift +++ b/Yattee/Views/Settings/NotificationSettingsView.swift @@ -137,7 +137,13 @@ private struct ManageChannelsSection: View { SettingsFormSection { #if os(tvOS) NavigationLink { - ManageChannelNotificationsView() + TVSidebarDetailContainer( + systemImage: "bell.badge", + title: String(localized: "settings.notifications.manageChannels"), + showsDismissButton: true + ) { + ManageChannelNotificationsView() + } } label: { Label( String(localized: "settings.notifications.manageChannels"), diff --git a/Yattee/Views/Subscriptions/ManageChannelsView.swift b/Yattee/Views/Subscriptions/ManageChannelsView.swift index 57b6aa2c..309811a0 100644 --- a/Yattee/Views/Subscriptions/ManageChannelsView.swift +++ b/Yattee/Views/Subscriptions/ManageChannelsView.swift @@ -81,24 +81,20 @@ struct ManageChannelsView: View { var body: some View { GeometryReader { geometry in - Group { - if isLoading && channels.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if channels.isEmpty { - ContentUnavailableView { - Label(String(localized: "subscriptions.channels.title"), systemImage: "person.2") - } description: { - Text(String(localized: "subscriptions.channels.empty")) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - channelsView - } + #if os(tvOS) + VStack(spacing: 0) { + tvOSHeader + stateContent } .onChange(of: geometry.size.width, initial: true) { _, newWidth in viewWidth = newWidth } + #else + stateContent + .onChange(of: geometry.size.width, initial: true) { _, newWidth in + viewWidth = newWidth + } + #endif } #if !os(tvOS) .navigationTitle(String(localized: "subscriptions.channels.title")) @@ -226,42 +222,61 @@ struct ManageChannelsView: View { // MARK: - Content Views + @ViewBuilder + private var stateContent: some View { + if isLoading && channels.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if channels.isEmpty { + ContentUnavailableView { + Label(String(localized: "subscriptions.channels.title"), systemImage: "person.2") + } description: { + Text(String(localized: "subscriptions.channels.empty")) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + channelsView + } + } + + #if os(tvOS) + /// Header rendered above every state on tvOS so focus always has a target. + @ViewBuilder + private var tvOSHeader: some View { + HStack(spacing: 24) { + TextField("search.channels.placeholder", text: $searchText) + .textFieldStyle(.plain) + + Button { + showViewOptions = true + } label: { + Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") + } + } + .focusSection() + .padding(.horizontal, 48) + .padding(.top, 20) + .padding(.bottom, 20) + } + #endif + @ViewBuilder private var channelsView: some View { #if os(tvOS) - VStack(spacing: 0) { - // tvOS: Inline search field and action button for better focus navigation - HStack(spacing: 24) { - TextField("search.channels.placeholder", text: $searchText) - .textFieldStyle(.plain) - - Button { - showViewOptions = true - } label: { - Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") + Group { + if filteredChannels.isEmpty { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + switch layout { + case .list: + listContent + case .grid: + gridContent } } - .focusSection() - .padding(.horizontal, 48) - .padding(.top, 20) - .padding(.bottom, 20) - - // Content - Group { - if filteredChannels.isEmpty { - ContentUnavailableView.search(text: searchText) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - switch layout { - case .list: - listContent - case .grid: - gridContent - } - } - } - .focusSection() } + .focusSection() #else Group { if filteredChannels.isEmpty {