Rework tvOS Subscriptions view as two-column layout

Replace the tvOS Subscriptions header (All Channels link + View Options
button) with a left-column channels sidebar that filters the feed in
place, and move view options into a button at the top of the sidebar.
Drops the channel strip size picker from the tvOS options sheet since
the strip does not apply there, and mirrors the ContinueWatchingView
focus pattern so initial focus and post-filter focus land on the first
video row.
This commit is contained in:
Arkadiusz Fal
2026-04-16 18:23:51 +02:00
parent f52ece330e
commit df232ad69a
2 changed files with 200 additions and 27 deletions

View File

@@ -11,7 +11,7 @@ struct SubscriptionsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition
#if os(tvOS)
@Namespace private var defaultFocusNamespace
@FocusState private var focusedVideoID: String?
#endif
@State private var feedCache = SubscriptionFeedCache.shared
@State private var subscriptions: [Subscription] = []
@@ -203,6 +203,11 @@ struct SubscriptionsView: View {
ScrollViewReader { proxy in
ZStack {
#if os(tvOS)
HStack(alignment: .top, spacing: 24) {
tvOSChannelsSidebar
.frame(width: max(geometry.size.width * 0.28, 360), alignment: .leading)
.focusSection()
Group {
switch layout {
case .list:
@@ -211,25 +216,11 @@ struct SubscriptionsView: View {
gridContent
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.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)
.padding(.horizontal, 16)
.padding(.top, 20)
#else
Group {
switch layout {
@@ -313,11 +304,13 @@ struct SubscriptionsView: View {
Toggle("viewOptions.hideWatched", isOn: $hideWatched)
#if !os(tvOS)
Picker("viewOptions.channelStrip", selection: $channelStripSize) {
ForEach(ChannelStripSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
#endif
}
#if !os(tvOS)
@@ -394,6 +387,16 @@ struct SubscriptionsView: View {
proxy.scrollTo("top", anchor: .top)
}
}
#if os(tvOS)
.onChange(of: filteredVideos.first?.id.videoID, initial: true) { _, newValue in
// Work around tvOS ScrollView + prefersDefaultFocus bug: set initial focus
// (and refocus after channel filter changes) to the first video once cells materialize.
guard let newValue else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
focusedVideoID = newValue
}
}
#endif
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
@@ -481,7 +484,9 @@ struct SubscriptionsView: View {
loadMoreVideos: loadMoreSubscriptionsCallback
)
}
#if !os(tvOS)
#if os(tvOS)
.focused($focusedVideoID, equals: video.id.videoID)
#else
.videoSwipeActions(video: video)
#endif
}
@@ -622,6 +627,84 @@ struct SubscriptionsView: View {
}
}
// MARK: - tvOS Channels Sidebar
#if os(tvOS)
private var tvOSChannelsSidebar: some View {
VStack(alignment: .leading, spacing: 16) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.bordered)
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 20) {
TVSubscriptionsSidebarRow(
name: String(localized: "subscriptions.allChannels"),
avatarURL: nil,
serverURL: nil,
authHeader: nil,
channelID: nil,
isAllChannels: true,
isSelected: selectedChannelID == nil,
onTap: {
if selectedChannelID != nil {
selectedChannelID = nil
}
}
)
.contextMenu {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .manageChannels)
} label: {
Label(String(localized: "sidebar.manageChannels"), systemImage: "person.2.badge.gearshape")
}
}
ForEach(sortedSubscriptions, id: \.channelID) { subscription in
TVSubscriptionsSidebarRow(
name: subscription.name,
avatarURL: subscription.avatarURL,
serverURL: yatteeServerURL,
authHeader: yatteeServerAuthHeader,
channelID: subscription.channelID,
isAllChannels: false,
isSelected: selectedChannelID == subscription.channelID,
onTap: {
if selectedChannelID == subscription.channelID {
selectedChannelID = nil
} else {
selectedChannelID = subscription.channelID
}
}
)
.contextMenu {
Button {
appEnvironment?.navigationCoordinator.navigate(
to: .channel(subscription.channelID, subscription.contentSource)
)
} label: {
Label(String(localized: "subscriptions.goToChannel"), systemImage: "person.circle")
}
Button(role: .destructive) {
unsubscribeChannel(subscription.channelID)
} label: {
Label(String(localized: "channel.unsubscribe"), systemImage: "person.badge.minus")
}
}
}
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
.scrollClipDisabled()
}
}
#endif
// MARK: - Content Views
private var subscriptionsQueueSource: QueueSource {
@@ -654,6 +737,9 @@ struct SubscriptionsView: View {
videoIndex: index,
loadMoreVideos: loadMoreSubscriptionsCallback
)
#if os(tvOS)
.focused($focusedVideoID, equals: video.id.videoID)
#endif
}
}

View File

@@ -0,0 +1,87 @@
//
// TVSubscriptionsSidebarRow.swift
// Yattee
//
// Compact row used in the tvOS Subscriptions sidebar for the
// "All Channels" entry and each subscribed channel.
//
#if os(tvOS)
import SwiftUI
import NukeUI
struct TVSubscriptionsSidebarRow: View {
let name: String
let avatarURL: URL?
let serverURL: URL?
let authHeader: String?
let channelID: String?
let isAllChannels: Bool
let isSelected: Bool
let onTap: () -> Void
private let avatarSize: CGFloat = 50
private var effectiveAvatarURL: URL? {
guard let channelID else { return nil }
return AvatarURLBuilder.avatarURL(
channelID: channelID,
directURL: avatarURL,
serverURL: serverURL,
size: Int(avatarSize)
)
}
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
avatar
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
Text(name)
.font(.body)
.fontWeight(isSelected ? .semibold : .regular)
.foregroundStyle(isSelected ? Color.accentColor : Color.primary)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 2)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private var avatar: some View {
if isAllChannels {
ZStack {
Circle().fill(.quaternary)
Image(systemName: "rectangle.stack.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(.secondary)
.padding(avatarSize * 0.25)
}
} else {
LazyImage(request: AvatarURLBuilder.imageRequest(url: effectiveAvatarURL, authHeader: authHeader)) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.quaternary)
.overlay {
Text(String(name.prefix(1)))
.font(.headline)
.foregroundStyle(.secondary)
}
}
}
}
}
}
#endif