mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 10:55: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()
|
footer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.scrollClipDisabled()
|
||||||
#else
|
#else
|
||||||
// iOS/macOS: Background overlay pattern
|
// iOS/macOS: Background overlay pattern
|
||||||
backgroundStyle.color
|
backgroundStyle.color
|
||||||
|
|||||||
@@ -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,10 +246,23 @@ 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)
|
||||||
}
|
}
|
||||||
|
#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)
|
#if !os(tvOS)
|
||||||
.navigationTitle(String(localized: "tabs.subscriptions"))
|
.navigationTitle(String(localized: "tabs.subscriptions"))
|
||||||
.toolbarTitleDisplayMode(.inlineLarge)
|
.toolbarTitleDisplayMode(.inlineLarge)
|
||||||
#endif
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button {
|
Button {
|
||||||
@@ -228,6 +273,7 @@ struct SubscriptionsView: View {
|
|||||||
.liquidGlassTransitionSource(id: "subscriptionsViewOptions", in: sheetTransition)
|
.liquidGlassTransitionSource(id: "subscriptionsViewOptions", in: sheetTransition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.sheet(isPresented: $showViewOptions) {
|
.sheet(isPresented: $showViewOptions) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@@ -343,19 +389,6 @@ struct SubscriptionsView: View {
|
|||||||
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
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo("top", anchor: .top)
|
proxy.scrollTo("top", anchor: .top)
|
||||||
@@ -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,6 +673,13 @@ struct SubscriptionsView: View {
|
|||||||
|
|
||||||
private var feedSectionHeader: some View {
|
private var feedSectionHeader: some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
feedSectionHeaderLabel
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var feedSectionHeaderLabel: some View {
|
||||||
if feedCache.isLoading, let progress = feedCache.loadingProgress {
|
if feedCache.isLoading, let progress = feedCache.loadingProgress {
|
||||||
Text("subscriptions.updatingChannels \(progress.loaded) \(progress.total)")
|
Text("subscriptions.updatingChannels \(progress.loaded) \(progress.total)")
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
@@ -664,8 +711,6 @@ struct SubscriptionsView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Loading/Error/Empty Views
|
// MARK: - Loading/Error/Empty Views
|
||||||
|
|||||||
Reference in New Issue
Block a user