Make Subscriptions view focusable on tvOS

Move the channel-link button and View Options into a top safe-area inset
on tvOS so they are reachable with the remote, mirroring the Continue
Watching pattern. Wrap chrome and content in focus sections with a
default-focus namespace so initial focus lands on the first video. Hide
the duplicate in-content section header on tvOS, and add
scrollClipDisabled to VideoListContainer so focus scaling on rows is
not clipped at the scroll edges.
This commit is contained in:
Arkadiusz Fal
2026-04-16 07:50:27 +02:00
parent 70a5375b7e
commit 033c93e542
2 changed files with 124 additions and 78 deletions

View File

@@ -58,6 +58,7 @@ struct VideoListContainer<Header: View, Content: View, Footer: View>: View {
footer() footer()
} }
} }
.scrollClipDisabled()
#else #else
// iOS/macOS: Background overlay pattern // iOS/macOS: Background overlay pattern
backgroundStyle.color backgroundStyle.color

View File

@@ -10,6 +10,9 @@ import SwiftUI
struct SubscriptionsView: View { struct SubscriptionsView: View {
@Environment(\.appEnvironment) private var appEnvironment @Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition @Namespace private var sheetTransition
#if os(tvOS)
@Namespace private var defaultFocusNamespace
#endif
@State private var feedCache = SubscriptionFeedCache.shared @State private var feedCache = SubscriptionFeedCache.shared
@State private var subscriptions: [Subscription] = [] @State private var subscriptions: [Subscription] = []
@State private var subscriptionsLoaded = false @State private var subscriptionsLoaded = false
@@ -199,6 +202,35 @@ struct SubscriptionsView: View {
GeometryReader { geometry in GeometryReader { geometry in
ScrollViewReader { proxy in ScrollViewReader { proxy in
ZStack { ZStack {
#if os(tvOS)
Group {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
.focusSection()
.prefersDefaultFocus(in: defaultFocusNamespace)
.safeAreaInset(edge: .top, spacing: 0) {
HStack(spacing: 24) {
feedSectionHeaderLabel
Spacer()
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
}
.focusSection()
.padding(.horizontal, 48)
.padding(.top, 40)
.padding(.bottom, 40)
.frame(maxWidth: .infinity)
}
.focusScope(defaultFocusNamespace)
#else
Group { Group {
switch layout { switch layout {
case .list: case .list:
@@ -214,21 +246,35 @@ struct SubscriptionsView: View {
await feedCache.refresh(using: appEnvironment) await feedCache.refresh(using: appEnvironment)
LoggingService.shared.info("Pull-to-refresh completed", category: .general) LoggingService.shared.info("Pull-to-refresh completed", category: .general)
} }
#if !os(tvOS)
.navigationTitle(String(localized: "tabs.subscriptions"))
.toolbarTitleDisplayMode(.inlineLarge)
#endif #endif
.toolbar {
ToolbarItem(placement: .primaryAction) { // Bottom overlay for filter strip
Button { #if !os(tvOS)
showViewOptions = true VStack {
} label: { Spacer()
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
} if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError {
.liquidGlassTransitionSource(id: "subscriptionsViewOptions", in: sheetTransition) bottomFloatingFilterStrip
.transition(.move(edge: .bottom).combined(with: .opacity))
} }
} }
.sheet(isPresented: $showViewOptions) { #endif
}
#if !os(tvOS)
.navigationTitle(String(localized: "tabs.subscriptions"))
.toolbarTitleDisplayMode(.inlineLarge)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "subscriptionsViewOptions", in: sheetTransition)
}
}
#endif
.sheet(isPresented: $showViewOptions) {
NavigationStack { NavigationStack {
Form { Form {
Section { Section {
@@ -317,43 +363,30 @@ struct SubscriptionsView: View {
await feedCache.refresh(using: appEnvironment) await feedCache.refresh(using: appEnvironment)
} }
} }
.task(id: instanceConfigurationID) { .task(id: instanceConfigurationID) {
LoggingService.shared.debug("SubscriptionsView task triggered, instanceConfigurationID: \(instanceConfigurationID)", category: .general) LoggingService.shared.debug("SubscriptionsView task triggered, instanceConfigurationID: \(instanceConfigurationID)", category: .general)
await loadSubscriptionsAsync() await loadSubscriptionsAsync()
await feedCache.loadFromDiskIfNeeded() await feedCache.loadFromDiskIfNeeded()
let hasYatteeServer = appEnvironment?.instancesManager.instances.contains { let hasYatteeServer = appEnvironment?.instancesManager.instances.contains {
$0.type == .yatteeServer && $0.isEnabled $0.type == .yatteeServer && $0.isEnabled
} ?? false } ?? false
let cacheValid = feedCache.isCacheValid(using: appEnvironment?.settingsManager) let cacheValid = feedCache.isCacheValid(using: appEnvironment?.settingsManager)
LoggingService.shared.debug( LoggingService.shared.debug(
"hasYatteeServer: \(hasYatteeServer), cacheValid: \(cacheValid), isLoading: \(feedCache.isLoading)", "hasYatteeServer: \(hasYatteeServer), cacheValid: \(cacheValid), isLoading: \(feedCache.isLoading)",
category: .general category: .general
) )
if hasYatteeServer { if hasYatteeServer {
LoggingService.shared.info("Yattee Server detected, forcing feed refresh", category: .general) LoggingService.shared.info("Yattee Server detected, forcing feed refresh", category: .general)
await loadFeed(forceRefresh: true) await loadFeed(forceRefresh: true)
} else if !cacheValid && !feedCache.isLoading { } else if !cacheValid && !feedCache.isLoading {
LoggingService.shared.info("Cache invalid and not loading, refreshing feed", category: .general) LoggingService.shared.info("Cache invalid and not loading, refreshing feed", category: .general)
await loadFeed(forceRefresh: false) await loadFeed(forceRefresh: false)
} else { } else {
LoggingService.shared.debug("Using cached feed, no refresh needed", category: .general) LoggingService.shared.debug("Using cached feed, no refresh needed", category: .general)
}
}
// Bottom overlay for filter strip
VStack {
Spacer()
#if !os(tvOS)
if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError {
bottomFloatingFilterStrip
.transition(.move(edge: .bottom).combined(with: .opacity))
}
#endif
} }
} }
.onChange(of: selectedChannelID) { _, _ in .onChange(of: selectedChannelID) { _, _ in
@@ -376,8 +409,10 @@ struct SubscriptionsView: View {
feedStatusBanner feedStatusBanner
.id("top") .id("top")
// Section header // Section header (channel link is shown in the inline header on tvOS)
#if !os(tvOS)
sectionHeaderView sectionHeaderView
#endif
} content: { } content: {
feedContentRows feedContentRows
} footer: { } footer: {
@@ -478,7 +513,8 @@ struct SubscriptionsView: View {
feedStatusBanner feedStatusBanner
.id("top") .id("top")
// Section header // Section header (channel link is shown in the inline header on tvOS)
#if !os(tvOS)
HStack { HStack {
feedSectionHeader feedSectionHeader
Spacer() Spacer()
@@ -486,6 +522,7 @@ struct SubscriptionsView: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 8) .padding(.top, 8)
.padding(.bottom, 12) .padding(.bottom, 12)
#endif
// Content // Content
if case .error(let feedError) = feedCache.feedLoadState, feedCache.videos.isEmpty { if case .error(let feedError) = feedCache.feedLoadState, feedCache.videos.isEmpty {
@@ -516,6 +553,9 @@ struct SubscriptionsView: View {
} }
} }
} }
#if os(tvOS)
.scrollClipDisabled()
#endif
} }
// MARK: - Channel Filter Strip // MARK: - Channel Filter Strip
@@ -633,41 +673,46 @@ struct SubscriptionsView: View {
private var feedSectionHeader: some View { private var feedSectionHeader: some View {
HStack { HStack {
if feedCache.isLoading, let progress = feedCache.loadingProgress { feedSectionHeaderLabel
Text("subscriptions.updatingChannels \(progress.loaded) \(progress.total)")
.monospacedDigit()
.foregroundStyle(.secondary)
} else if let subscription = selectedSubscription {
Button {
appEnvironment?.navigationCoordinator.navigate(
to: .channel(subscription.channelID, subscription.contentSource)
)
} label: {
HStack(spacing: 4) {
Text(subscription.name)
.fontWeight(.semibold)
Image(systemName: "chevron.right")
.font(.caption)
}
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
} else {
NavigationLink(value: NavigationDestination.manageChannels) {
HStack(spacing: 4) {
Text(String(localized: "subscriptions.allChannels"))
.fontWeight(.semibold)
Image(systemName: "chevron.right")
.font(.caption)
}
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
}
Spacer() Spacer()
} }
} }
@ViewBuilder
private var feedSectionHeaderLabel: some View {
if feedCache.isLoading, let progress = feedCache.loadingProgress {
Text("subscriptions.updatingChannels \(progress.loaded) \(progress.total)")
.monospacedDigit()
.foregroundStyle(.secondary)
} else if let subscription = selectedSubscription {
Button {
appEnvironment?.navigationCoordinator.navigate(
to: .channel(subscription.channelID, subscription.contentSource)
)
} label: {
HStack(spacing: 4) {
Text(subscription.name)
.fontWeight(.semibold)
Image(systemName: "chevron.right")
.font(.caption)
}
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
} else {
NavigationLink(value: NavigationDestination.manageChannels) {
HStack(spacing: 4) {
Text(String(localized: "subscriptions.allChannels"))
.fontWeight(.semibold)
Image(systemName: "chevron.right")
.font(.caption)
}
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
}
}
// MARK: - Loading/Error/Empty Views // MARK: - Loading/Error/Empty Views
private var gridLoadingView: some View { private var gridLoadingView: some View {