mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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:
@@ -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
|
||||
|
||||
87
Yattee/Views/Subscriptions/TVSubscriptionsSidebarRow.swift
Normal file
87
Yattee/Views/Subscriptions/TVSubscriptionsSidebarRow.swift
Normal 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
|
||||
Reference in New Issue
Block a user