mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 02:45:03 +00:00
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:
@@ -58,6 +58,7 @@ struct VideoListContainer<Header: View, Content: View, Footer: View>: View {
|
||||
footer()
|
||||
}
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
#else
|
||||
// iOS/macOS: Background overlay pattern
|
||||
backgroundStyle.color
|
||||
|
||||
@@ -10,6 +10,9 @@ import SwiftUI
|
||||
struct SubscriptionsView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@Namespace private var sheetTransition
|
||||
#if os(tvOS)
|
||||
@Namespace private var defaultFocusNamespace
|
||||
#endif
|
||||
@State private var feedCache = SubscriptionFeedCache.shared
|
||||
@State private var subscriptions: [Subscription] = []
|
||||
@State private var subscriptionsLoaded = false
|
||||
@@ -199,6 +202,35 @@ struct SubscriptionsView: View {
|
||||
GeometryReader { geometry in
|
||||
ScrollViewReader { proxy in
|
||||
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 {
|
||||
switch layout {
|
||||
case .list:
|
||||
@@ -214,10 +246,23 @@ struct SubscriptionsView: View {
|
||||
await feedCache.refresh(using: appEnvironment)
|
||||
LoggingService.shared.info("Pull-to-refresh completed", category: .general)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Bottom overlay for filter strip
|
||||
#if !os(tvOS)
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError {
|
||||
bottomFloatingFilterStrip
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle(String(localized: "tabs.subscriptions"))
|
||||
.toolbarTitleDisplayMode(.inlineLarge)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
@@ -228,6 +273,7 @@ struct SubscriptionsView: View {
|
||||
.liquidGlassTransitionSource(id: "subscriptionsViewOptions", in: sheetTransition)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.sheet(isPresented: $showViewOptions) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -343,19 +389,6 @@ struct SubscriptionsView: View {
|
||||
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
|
||||
withAnimation {
|
||||
proxy.scrollTo("top", anchor: .top)
|
||||
@@ -376,8 +409,10 @@ struct SubscriptionsView: View {
|
||||
feedStatusBanner
|
||||
.id("top")
|
||||
|
||||
// Section header
|
||||
// Section header (channel link is shown in the inline header on tvOS)
|
||||
#if !os(tvOS)
|
||||
sectionHeaderView
|
||||
#endif
|
||||
} content: {
|
||||
feedContentRows
|
||||
} footer: {
|
||||
@@ -478,7 +513,8 @@ struct SubscriptionsView: View {
|
||||
feedStatusBanner
|
||||
.id("top")
|
||||
|
||||
// Section header
|
||||
// Section header (channel link is shown in the inline header on tvOS)
|
||||
#if !os(tvOS)
|
||||
HStack {
|
||||
feedSectionHeader
|
||||
Spacer()
|
||||
@@ -486,6 +522,7 @@ struct SubscriptionsView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 12)
|
||||
#endif
|
||||
|
||||
// Content
|
||||
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
|
||||
@@ -633,6 +673,13 @@ struct SubscriptionsView: View {
|
||||
|
||||
private var feedSectionHeader: some View {
|
||||
HStack {
|
||||
feedSectionHeaderLabel
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var feedSectionHeaderLabel: some View {
|
||||
if feedCache.isLoading, let progress = feedCache.loadingProgress {
|
||||
Text("subscriptions.updatingChannels \(progress.loaded) \(progress.total)")
|
||||
.monospacedDigit()
|
||||
@@ -664,8 +711,6 @@ struct SubscriptionsView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading/Error/Empty Views
|
||||
|
||||
Reference in New Issue
Block a user