From df232ad69a71fa4426ce186ff00fc659e2222469 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 16 Apr 2026 18:23:51 +0200 Subject: [PATCH] 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. --- .../Subscriptions/SubscriptionsView.swift | 140 ++++++++++++++---- .../TVSubscriptionsSidebarRow.swift | 87 +++++++++++ 2 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 Yattee/Views/Subscriptions/TVSubscriptionsSidebarRow.swift diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index cfa59ff1..bdab7b48 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -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,33 +203,24 @@ struct SubscriptionsView: View { 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") + HStack(alignment: .top, spacing: 24) { + tvOSChannelsSidebar + .frame(width: max(geometry.size.width * 0.28, 360), alignment: .leading) + .focusSection() + + Group { + switch layout { + case .list: + listContent + case .grid: + gridContent } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .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,11 +484,13 @@ struct SubscriptionsView: View { loadMoreVideos: loadMoreSubscriptionsCallback ) } - #if !os(tvOS) + #if os(tvOS) + .focused($focusedVideoID, equals: video.id.videoID) + #else .videoSwipeActions(video: video) #endif } - + // Infinite scroll trigger for Invidious feed if feedCache.hasMorePages && !feedCache.isLoading { Color.clear @@ -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,9 +737,12 @@ struct SubscriptionsView: View { videoIndex: index, loadMoreVideos: loadMoreSubscriptionsCallback ) + #if os(tvOS) + .focused($focusedVideoID, equals: video.id.videoID) + #endif } } - + // Infinite scroll trigger for Invidious feed if feedCache.hasMorePages && !feedCache.isLoading { Color.clear diff --git a/Yattee/Views/Subscriptions/TVSubscriptionsSidebarRow.swift b/Yattee/Views/Subscriptions/TVSubscriptionsSidebarRow.swift new file mode 100644 index 00000000..eb464116 --- /dev/null +++ b/Yattee/Views/Subscriptions/TVSubscriptionsSidebarRow.swift @@ -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