Files
yattee/Yattee/Views/Subscriptions/SubscriptionsView.swift
2026-05-08 02:18:25 +02:00

1420 lines
56 KiB
Swift

//
// SubscriptionsView.swift
// Yattee
//
// Subscriptions tab with channel filter strip and feed.
//
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct SubscriptionsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition
#if os(tvOS)
@FocusState private var focusedVideoID: String?
#endif
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
@State private var feedCache = SubscriptionFeedCache.shared
@State private var subscriptions: [Subscription] = []
@State private var subscriptionsLoaded = false
@State private var selectedChannelID: String? = nil
@State private var errorMessage: String?
@State private var watchEntriesMap: [String: WatchEntry] = [:]
@State private var showViewOptions = false
// View options (persisted)
@AppStorage("subscriptionsLayout") private var layout: VideoListLayout = .list
@AppStorage("subscriptionsRowStyle") private var rowStyle: VideoRowStyle = .regular
@AppStorage("subscriptionsGridColumns") private var gridColumns = 2
@AppStorage("subscriptionsHideWatched") private var hideWatched = false
@AppStorage("subscriptionsChannelStripSize") private var channelStripSize: ChannelStripSize = .normal
@AppStorage("subscriptionsShowSidebar") private var showSidebar = true
#if os(macOS)
@AppStorage("subscriptionsMacOSSidebarWidth") private var macOSSidebarWidth = 240.0
@State private var macOSSidebarDragStartWidth: Double?
#elseif os(iOS)
@AppStorage("subscriptionsIPadSidebarWidth") private var iPadSidebarWidth = 260.0
@State private var iPadSidebarDragStartWidth: Double?
#endif
/// List style from centralized settings.
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
// Grid layout configuration
@State private var viewWidth: CGFloat = 0
private var gridConfig: GridLayoutConfiguration {
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
}
private var isShowingFullScreenError: Bool {
if case .error = feedCache.feedLoadState, feedCache.videos.isEmpty {
return true
}
return false
}
private var dataManager: DataManager? { appEnvironment?.dataManager }
private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService }
private var accentColor: Color { appEnvironment?.settingsManager.accentColor.color ?? .accentColor }
private var yatteeServer: Instance? {
appEnvironment?.instancesManager.enabledYatteeServerInstances.first
}
private var yatteeServerURL: URL? { yatteeServer?.url }
private var yatteeServerAuthHeader: String? {
guard let server = yatteeServer else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
}
/// Generates a unique ID based on instances configuration.
private var instanceConfigurationID: String {
guard let instances = appEnvironment?.instancesManager.instances else {
return "none"
}
return instances
.filter { $0.type == .yatteeServer }
.map { "\($0.id):\($0.isEnabled):\($0.apiKey?.isEmpty == false)" }
.joined(separator: "|")
}
/// Videos filtered by selected channel and watch status.
private var filteredVideos: [Video] {
var videos = feedCache.videos
if let channelID = selectedChannelID {
videos = videos.filter { $0.author.id == channelID }
}
if hideWatched {
videos = videos.filter { video in
guard let entry = watchEntriesMap[video.id.videoID] else { return true }
return !entry.isFinished
}
}
return videos
}
/// The currently selected subscription (if any).
private var selectedSubscription: Subscription? {
guard let channelID = selectedChannelID else { return nil }
return subscriptions.first { $0.channelID == channelID }
}
/// Banner showing feed loading progress when server is fetching channels.
@ViewBuilder
private var feedStatusBanner: some View {
switch feedCache.feedLoadState {
case .partiallyLoaded(let ready, let pending, let errors):
let total = ready + pending + errors
HStack(spacing: 8) {
if pending > 0 {
ProgressView()
.scaleEffect(0.8)
}
if errors > 0 {
Text("subscriptions.loadingFeedWithErrors \(ready) \(total) \(errors)")
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
} else {
Text("subscriptions.loadingFeed \(ready) \(total)")
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
#if os(tvOS)
.background(Color.black.opacity(0.3))
#endif
case .error(let error):
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
Text(errorMessage(for: error))
.font(.caption)
}
.foregroundStyle(.red)
.padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
#if os(tvOS)
.background(Color.black.opacity(0.3))
#endif
case .loadingMore:
HStack(spacing: 8) {
ProgressView()
.scaleEffect(0.8)
Text(String(localized: "subscriptions.loadingMore"))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
#if os(tvOS)
.background(Color.black.opacity(0.3))
#endif
default:
EmptyView()
}
}
/// Converts feed error to localized message.
private func errorMessage(for error: FeedLoadState.FeedLoadError) -> String {
switch error {
case .yatteeServerRequired:
return String(localized: "subscriptions.error.yatteeServerRequired")
case .notAuthenticated:
return String(localized: "subscriptions.error.notAuthenticated")
case .networkError(let message):
return message
}
}
/// Subscriptions sorted by most recent video upload date.
private var sortedSubscriptions: [Subscription] {
var latestVideoDate: [String: Date] = [:]
for video in feedCache.videos {
let channelID = video.author.id
let videoDate = video.publishedAt ?? .distantPast
if let existing = latestVideoDate[channelID] {
if videoDate > existing {
latestVideoDate[channelID] = videoDate
}
} else {
latestVideoDate[channelID] = videoDate
}
}
return subscriptions.sorted { sub1, sub2 in
let date1 = latestVideoDate[sub1.channelID] ?? .distantPast
let date2 = latestVideoDate[sub2.channelID] ?? .distantPast
return date1 > date2
}
}
/// Gets the watch progress (0.0-1.0) for a video, or nil if not watched/finished.
private func watchProgress(for video: Video) -> Double? {
guard let entry = watchEntriesMap[video.id.videoID] else { return nil }
let progress = entry.progress
return progress > 0 && progress < 1 ? progress : nil
}
var body: some View {
GeometryReader { geometry in
ScrollViewReader { proxy in
ZStack {
#if os(tvOS)
HStack(alignment: .top, spacing: 24) {
if showSidebar && subscriptionsLoaded && subscriptions.count > 1 {
tvOSChannelsSidebar
.frame(width: max(geometry.size.width * 0.28, 360), alignment: .leading)
.focusSection()
}
VStack(alignment: .leading, spacing: 16) {
if !showSidebar || subscriptions.count <= 1 {
tvOSViewOptionsButton
}
Group {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.focusSection()
}
.padding(.horizontal, 16)
.padding(.top, 20)
#elseif os(macOS)
HStack(spacing: 0) {
if showSidebar && subscriptionsLoaded && subscriptions.count > 1 {
macOSChannelsSidebar
.frame(width: clampedMacOSSidebarWidth)
macOSSidebarResizeHandle
}
Group {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
}
#else
if isIPadRegular && showSidebar && subscriptionsLoaded && subscriptions.count > 1 {
HStack(spacing: 0) {
iOSChannelsSidebar
.frame(width: clampedIPadSidebarWidth)
iPadSidebarResizeHandle
feedLayout
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.refreshable {
guard let appEnvironment else { return }
LoggingService.shared.info("User initiated pull-to-refresh in Subscriptions view", category: .general)
await loadSubscriptionsAsync()
await feedCache.refresh(using: appEnvironment)
LoggingService.shared.info("Pull-to-refresh completed", category: .general)
}
} else {
feedLayout
.refreshable {
guard let appEnvironment else { return }
LoggingService.shared.info("User initiated pull-to-refresh in Subscriptions view", category: .general)
await loadSubscriptionsAsync()
await feedCache.refresh(using: appEnvironment)
LoggingService.shared.info("Pull-to-refresh completed", category: .general)
}
}
#endif
// Bottom overlay for filter strip (iOS only, compact width)
#if os(iOS)
VStack {
Spacer()
if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError && !(isIPadRegular && showSidebar) {
bottomFloatingFilterStrip
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
#endif
}
#if !os(tvOS)
.navigationTitle(String(localized: "tabs.subscriptions"))
#if os(iOS)
.toolbarTitleDisplayMode(isIPadRegular ? .inline : .inlineLarge)
#else
.toolbarTitleDisplayMode(.inlineLarge)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "subscriptionsViewOptions", in: sheetTransition)
}
}
#endif
.sheet(isPresented: $showViewOptions) {
NavigationStack {
Form {
Section {
#if os(macOS)
Toggle("viewOptions.showSidebar", isOn: $showSidebar)
#elseif os(iOS)
if isIPadRegular {
Toggle("viewOptions.showSidebar", isOn: $showSidebar)
}
#elseif os(tvOS)
PlatformMenuPicker(String(localized: "viewOptions.showSidebar"), selection: $showSidebar) {
Text("common.on").tag(true)
Text("common.off").tag(false)
}
#endif
// Layout picker (inline menu)
PlatformMenuPicker(String(localized: "viewOptions.layout"), selection: $layout) {
ForEach(VideoListLayout.allCases, id: \.self) { option in
Text(option.displayName).tag(option)
}
}
// List-specific options
if layout == .list {
PlatformMenuPicker(String(localized: "viewOptions.rowSize"), selection: $rowStyle) {
Text("viewOptions.rowSize.compact").tag(VideoRowStyle.compact)
Text("viewOptions.rowSize.regular").tag(VideoRowStyle.regular)
Text("viewOptions.rowSize.large").tag(VideoRowStyle.large)
}
}
// Grid-specific options
if layout == .grid {
#if os(tvOS)
Picker("viewOptions.columns.header", selection: $gridColumns) {
ForEach(1...max(1, gridConfig.maxColumns), id: \.self) { count in
Text("\(count)").tag(count)
}
}
.pickerStyle(.segmented)
#else
Stepper(
"viewOptions.columns \(min(max(1, gridColumns), gridConfig.maxColumns))",
value: $gridColumns,
in: 1...gridConfig.maxColumns
)
#endif
}
#if os(tvOS)
PlatformMenuPicker(String(localized: "viewOptions.hideWatched"), selection: $hideWatched) {
Text("common.on").tag(true)
Text("common.off").tag(false)
}
#else
Toggle("viewOptions.hideWatched", isOn: $hideWatched)
#endif
#if os(iOS)
Picker("viewOptions.channelStrip", selection: $channelStripSize) {
ForEach(ChannelStripSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
.disabled(isIPadRegular && showSidebar)
#endif
}
#if !os(tvOS)
Section {
NavigationLink {
SubscriptionsSettingsView()
} label: {
Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape")
}
}
#endif
}
#if os(tvOS)
.scrollClipDisabled()
.padding(.horizontal, 40)
.padding(.vertical, 24)
#else
.navigationTitle(String(localized: "subscriptions.viewOptions.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#endif
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 450)
#endif
.presentationDetents([.height(520), .large])
.presentationDragIndicator(.visible)
.liquidGlassSheetContent(sourceID: "subscriptionsViewOptions", in: sheetTransition)
}
.task {
await loadSubscriptionsAsync()
loadWatchEntries()
}
.onReceive(NotificationCenter.default.publisher(for: .subscriptionsDidChange)) { _ in
Task {
await loadSubscriptionsAsync()
}
// Subscription changes now trigger a full refresh via invalidation
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadWatchEntries()
}
.onChange(of: appEnvironment?.settingsManager.subscriptionAccount) { _, _ in
// Clear cache and refresh when subscription account changes
feedCache.handleAccountChange()
subscriptions = []
subscriptionsLoaded = false
Task {
guard let appEnvironment else { return }
await loadSubscriptionsAsync()
await feedCache.refresh(using: appEnvironment)
}
}
.task(id: instanceConfigurationID) {
LoggingService.shared.debug("SubscriptionsView task triggered, instanceConfigurationID: \(instanceConfigurationID)", category: .general)
await loadSubscriptionsAsync()
await feedCache.loadFromDiskIfNeeded()
let hasYatteeServer = appEnvironment?.instancesManager.instances.contains {
$0.type == .yatteeServer && $0.isEnabled
} ?? false
let cacheValid = feedCache.isCacheValid(using: appEnvironment?.settingsManager)
LoggingService.shared.debug(
"hasYatteeServer: \(hasYatteeServer), cacheValid: \(cacheValid), isLoading: \(feedCache.isLoading)",
category: .general
)
if hasYatteeServer {
LoggingService.shared.info("Yattee Server detected, forcing feed refresh", category: .general)
await loadFeed(forceRefresh: true)
} else if !cacheValid && !feedCache.isLoading {
LoggingService.shared.info("Cache invalid and not loading, refreshing feed", category: .general)
await loadFeed(forceRefresh: false)
} else {
LoggingService.shared.debug("Using cached feed, no refresh needed", category: .general)
}
}
.onChange(of: selectedChannelID) { _, _ in
withAnimation {
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
}
}
}
// MARK: - List Layout
private var listContent: some View {
VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) {
// Header: status banner with scroll anchor
feedStatusBanner
.id("top")
// Section header (channel link is shown in the inline header on tvOS)
#if !os(tvOS)
sectionHeaderView
#endif
} content: {
feedContentRows
} footer: {
#if os(iOS)
// Bottom spacer for channel strip overlay (outside the card)
if channelStripSize != .disabled && subscriptions.count > 1 && !isShowingFullScreenError {
Color.clear.frame(height: channelStripSize.totalHeight)
}
#else
EmptyView()
#endif
}
}
/// Section header with proper padding for list style.
private var sectionHeaderView: some View {
HStack {
feedSectionHeader
Spacer()
}
.padding(.horizontal, listStyle == .inset ? 32 : 16)
.padding(.top, 16)
.padding(.bottom, 8)
}
/// Feed content rows or empty/loading states.
@ViewBuilder
private var feedContentRows: some View {
if case .error(let feedError) = feedCache.feedLoadState, feedCache.videos.isEmpty {
// Show specific error states
switch feedError {
case .yatteeServerRequired:
yatteeServerRequiredView
case .notAuthenticated:
notAuthenticatedView
case .networkError(let message):
gridErrorView(message)
}
} else if feedCache.isLoading && feedCache.videos.isEmpty {
gridLoadingView
} else if let error = errorMessage, feedCache.videos.isEmpty {
gridErrorView(error)
} else if !feedCache.videos.isEmpty {
if filteredVideos.isEmpty && selectedChannelID != nil {
ContentUnavailableView {
Label(String(localized: "subscriptions.noVideosFromChannel"), systemImage: "video.slash")
} description: {
Text(String(localized: "subscriptions.noVideosFromChannel.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
} else {
ForEach(Array(filteredVideos.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == filteredVideos.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
VideoRowView(
video: video,
style: rowStyle,
watchProgress: watchProgress(for: video)
)
.tappableVideo(
video,
queueSource: subscriptionsQueueSource,
sourceLabel: String(localized: "queue.source.subscriptions"),
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreSubscriptionsCallback
)
}
#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
.frame(height: 1)
.onAppear {
Task {
guard let appEnvironment else { return }
await feedCache.loadMoreInvidiousFeed(using: appEnvironment)
}
}
}
}
} else if feedCache.hasLoadedOnce {
gridEmptyView
} else {
gridLoadingView
}
}
// MARK: - Grid Layout
private var gridContent: some View {
ScrollView {
LazyVStack(spacing: 0) {
feedStatusBanner
.id("top")
// Section header (channel link is shown in the inline header on tvOS)
#if !os(tvOS)
HStack {
feedSectionHeader
Spacer()
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 12)
#endif
// Content
if case .error(let feedError) = feedCache.feedLoadState, feedCache.videos.isEmpty {
// Show specific error states
switch feedError {
case .yatteeServerRequired:
yatteeServerRequiredView
case .notAuthenticated:
notAuthenticatedView
case .networkError(let message):
gridErrorView(message)
}
} else if feedCache.isLoading && feedCache.videos.isEmpty {
gridLoadingView
} else if let error = errorMessage, feedCache.videos.isEmpty {
gridErrorView(error)
} else if !feedCache.videos.isEmpty {
gridFeedContent
} else if feedCache.hasLoadedOnce {
gridEmptyView
} else {
gridLoadingView
}
// Bottom spacer for channel strip overlay
if channelStripSize != .disabled && subscriptions.count > 1 && !isShowingFullScreenError {
Color.clear.frame(height: channelStripSize.totalHeight)
}
}
}
#if os(tvOS)
.scrollClipDisabled()
#endif
}
// MARK: - Channel Filter Strip
private var bottomFloatingFilterStrip: some View {
ViewThatFits(in: .horizontal) {
// Option 1: Non-scrolling centered layout (used when all chips fit)
channelChipsHStack
.padding(.horizontal, 12)
.padding(.vertical, channelStripSize.verticalPadding)
.clipShape(Capsule())
#if os(tvOS)
.background(Color.black.opacity(0.3))
#else
.glassBackground(.regular, in: .capsule, fallback: .regularMaterial)
#endif
// Option 2: Scrollable layout (used when chips overflow)
ScrollView(.horizontal, showsIndicators: false) {
channelChipsHStack
.padding(.horizontal, 12)
.padding(.vertical, channelStripSize.verticalPadding)
}
.clipShape(Capsule())
#if os(tvOS)
.background(Color.black.opacity(0.3))
#else
.glassBackground(.regular, in: .capsule, fallback: .regularMaterial)
#endif
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
/// The HStack containing channel filter chips (extracted to avoid duplication).
private var channelChipsHStack: some View {
HStack(spacing: channelStripSize.chipSpacing) {
ForEach(sortedSubscriptions, id: \.channelID) { subscription in
ChannelFilterChip(
channelID: subscription.channelID,
name: subscription.name,
avatarURL: subscription.avatarURL,
serverURL: yatteeServerURL,
isSelected: selectedChannelID == subscription.channelID,
avatarSize: channelStripSize.avatarSize,
onTap: {
if selectedChannelID == subscription.channelID {
selectedChannelID = nil
} else {
selectedChannelID = subscription.channelID
}
},
onGoToChannel: {
appEnvironment?.navigationCoordinator.navigate(
to: .channel(subscription.channelID, subscription.contentSource)
)
},
onUnsubscribe: {
unsubscribeChannel(subscription.channelID)
},
authHeader: yatteeServerAuthHeader
)
}
}
}
// MARK: - tvOS Channels Sidebar
#if os(tvOS)
private var tvOSViewOptionsButton: some View {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.buttonStyle(.bordered)
}
private var tvOSChannelsSidebar: some View {
VStack(alignment: .leading, spacing: 16) {
tvOSViewOptionsButton
.frame(maxWidth: .infinity, alignment: .leading)
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: - Channels Sidebar (macOS / iPad)
#if !os(tvOS)
private static let sidebarAllChannelsTag = "__all__"
private var sidebarSelection: Binding<String?> {
Binding(
get: { selectedChannelID ?? Self.sidebarAllChannelsTag },
set: { newValue in
selectedChannelID = (newValue == Self.sidebarAllChannelsTag) ? nil : newValue
}
)
}
@ViewBuilder
private var channelsSidebarContent: some View {
SubscriptionsSidebarRow(
name: String(localized: "subscriptions.allChannels"),
avatarURL: nil,
serverURL: nil,
authHeader: nil,
channelID: nil,
isAllChannels: true
)
.tag(Self.sidebarAllChannelsTag)
.contextMenu {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .manageChannels)
} label: {
Label(String(localized: "sidebar.manageChannels"), systemImage: "person.2.badge.gearshape")
}
}
ForEach(sortedSubscriptions, id: \.channelID) { subscription in
SubscriptionsSidebarRow(
name: subscription.name,
avatarURL: subscription.avatarURL,
serverURL: yatteeServerURL,
authHeader: yatteeServerAuthHeader,
channelID: subscription.channelID,
isAllChannels: false
)
.tag(Optional(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")
}
}
}
}
#endif
#if os(macOS)
private static let macOSSidebarMinWidth = 180.0
private static let macOSSidebarMaxWidth = 400.0
private var clampedMacOSSidebarWidth: CGFloat {
CGFloat(min(max(macOSSidebarWidth, Self.macOSSidebarMinWidth), Self.macOSSidebarMaxWidth))
}
private var macOSChannelsSidebar: some View {
List(selection: sidebarSelection) {
channelsSidebarContent
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
}
private var macOSSidebarResizeHandle: some View {
ZStack {
Rectangle()
.fill(.separator.opacity(0.6))
.frame(width: 1)
Rectangle()
.fill(.clear)
.frame(width: 8)
.contentShape(Rectangle())
}
.frame(width: 8)
.frame(maxHeight: .infinity)
.background(.clear)
.contentShape(Rectangle())
.highPriorityGesture(macOSSidebarResizeGesture)
.onHover { isHovering in
if isHovering {
NSCursor.resizeLeftRight.push()
} else {
NSCursor.pop()
}
}
}
private var macOSSidebarResizeGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if macOSSidebarDragStartWidth == nil {
macOSSidebarDragStartWidth = macOSSidebarWidth
}
let baseWidth = macOSSidebarDragStartWidth ?? macOSSidebarWidth
let newWidth = baseWidth + value.translation.width
macOSSidebarWidth = min(max(newWidth, Self.macOSSidebarMinWidth), Self.macOSSidebarMaxWidth)
}
.onEnded { _ in
macOSSidebarDragStartWidth = nil
}
}
#endif
private var isSidebarVisible: Bool {
#if os(macOS)
return showSidebar && subscriptionsLoaded && subscriptions.count > 1
#elseif os(iOS)
return isIPadRegular && showSidebar && subscriptionsLoaded && subscriptions.count > 1
#elseif os(tvOS)
return showSidebar && subscriptionsLoaded && subscriptions.count > 1
#else
return false
#endif
}
#if os(iOS)
private static let iPadSidebarMinWidth = 220.0
private static let iPadSidebarMaxWidth = 420.0
private var isIPadRegular: Bool {
UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .regular
}
private var clampedIPadSidebarWidth: CGFloat {
CGFloat(min(max(iPadSidebarWidth, Self.iPadSidebarMinWidth), Self.iPadSidebarMaxWidth))
}
@ViewBuilder
private var feedLayout: some View {
Group {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
private var iOSChannelsSidebar: some View {
ScrollView {
LazyVStack(spacing: 2) {
iOSSidebarRow(
name: String(localized: "subscriptions.allChannels"),
avatarURL: nil,
serverURL: nil,
authHeader: nil,
channelID: nil,
isAllChannels: true,
isSelected: selectedChannelID == nil
) {
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
iOSSidebarRow(
name: subscription.name,
avatarURL: subscription.avatarURL,
serverURL: yatteeServerURL,
authHeader: yatteeServerAuthHeader,
channelID: subscription.channelID,
isAllChannels: false,
isSelected: selectedChannelID == subscription.channelID
) {
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(.horizontal, 8)
.padding(.vertical, 8)
}
}
@ViewBuilder
private var iPadSidebarResizeHandle: some View {
if isIPadRegular {
Rectangle()
.fill(.separator.opacity(0.7))
.frame(width: 1)
.frame(width: 14)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.highPriorityGesture(iPadSidebarResizeGesture)
}
}
private var iPadSidebarResizeGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if iPadSidebarDragStartWidth == nil {
iPadSidebarDragStartWidth = iPadSidebarWidth
}
let baseWidth = iPadSidebarDragStartWidth ?? iPadSidebarWidth
let newWidth = baseWidth + value.translation.width
iPadSidebarWidth = min(max(newWidth, Self.iPadSidebarMinWidth), Self.iPadSidebarMaxWidth)
}
.onEnded { _ in
iPadSidebarDragStartWidth = nil
}
}
@ViewBuilder
private func iOSSidebarRow(
name: String,
avatarURL: URL?,
serverURL: URL?,
authHeader: String?,
channelID: String?,
isAllChannels: Bool,
isSelected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
SubscriptionsSidebarRow(
name: name,
avatarURL: avatarURL,
serverURL: serverURL,
authHeader: authHeader,
channelID: channelID,
isAllChannels: isAllChannels,
isSelected: isSelected
)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(isSelected ? accentColor.opacity(0.2) : Color.clear)
)
}
.buttonStyle(.plain)
}
#endif
// MARK: - Content Views
private var subscriptionsQueueSource: QueueSource {
.subscriptions(continuation: nil)
}
@ViewBuilder
private var gridFeedContent: some View {
if filteredVideos.isEmpty && selectedChannelID != nil {
ContentUnavailableView {
Label(String(localized: "subscriptions.noVideosFromChannel"), systemImage: "video.slash")
} description: {
Text(String(localized: "subscriptions.noVideosFromChannel.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
} else {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredVideos.enumerated()), id: \.element.id) { index, video in
VideoCardView(
video: video,
watchProgress: watchProgress(for: video),
isCompact: gridConfig.isCompactCards
)
.tappableVideo(
video,
queueSource: subscriptionsQueueSource,
sourceLabel: String(localized: "queue.source.subscriptions"),
videoList: filteredVideos,
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
.frame(height: 1)
.onAppear {
Task {
guard let appEnvironment else { return }
await feedCache.loadMoreInvidiousFeed(using: appEnvironment)
}
}
}
}
}
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()
.foregroundStyle(.secondary)
} else if isSidebarVisible {
EmptyView()
} else if let subscription = selectedSubscription {
Button {
appEnvironment?.navigationCoordinator.navigate(
to: .channel(subscription.channelID, subscription.contentSource)
)
} label: {
HStack(spacing: 4) {
Text(subscription.name)
.fontWeight(.semibold)
Image(systemName: "chevron.right")
.font(.caption)
}
.foregroundStyle(accentColor)
}
.buttonStyle(.plain)
} else {
NavigationLink(value: NavigationDestination.manageChannels) {
HStack(spacing: 4) {
Text(String(localized: "subscriptions.allChannels"))
.fontWeight(.semibold)
Image(systemName: "chevron.right")
.font(.caption)
}
.foregroundStyle(accentColor)
}
.buttonStyle(.plain)
}
}
// MARK: - Loading/Error/Empty Views
private var gridLoadingView: some View {
VStack(spacing: 16) {
ProgressView()
if let progress = feedCache.loadingProgress {
Text(verbatim: "\(progress.loaded)/\(progress.total)")
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var gridEmptyView: some View {
ContentUnavailableView {
Label(String(localized: "subscriptions.feed.title"), systemImage: "play.rectangle.on.rectangle")
} description: {
Text(String(localized: "subscriptions.empty.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
/// Empty state shown when Yattee Server is required but not configured.
private var yatteeServerRequiredView: some View {
ContentUnavailableView {
Label(String(localized: "subscriptions.yatteeServerRequired.title"), systemImage: "server.rack")
} description: {
Text(String(localized: "subscriptions.yatteeServerRequired.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
/// Empty state shown when Invidious account is not authenticated.
private var notAuthenticatedView: some View {
ContentUnavailableView {
Label(String(localized: "subscriptions.notAuthenticated.title"), systemImage: "person.crop.circle.badge.exclamationmark")
} description: {
Text(String(localized: "subscriptions.notAuthenticated.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
private func gridErrorView(_ error: String) -> some View {
ContentUnavailableView {
Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button(String(localized: "common.retry")) {
Task { await loadFeed(forceRefresh: true) }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
// MARK: - Data Loading
private func loadSubscriptions() {
// For local account, load from DataManager
// For Invidious, subscriptions will be loaded async in loadSubscriptionsAsync
if appEnvironment?.settingsManager.subscriptionAccount.type == .local {
subscriptions = dataManager?.subscriptions() ?? []
subscriptionsLoaded = true
}
if let selectedID = selectedChannelID,
!subscriptions.contains(where: { $0.channelID == selectedID }) {
selectedChannelID = nil
}
}
/// Loads subscriptions asynchronously from the current provider.
/// For Invidious, this fetches from the API and creates temporary Subscription objects for UI.
private func loadSubscriptionsAsync() async {
guard let subscriptionService, let appEnvironment else { return }
// For local account, just load from DataManager (fast)
if appEnvironment.settingsManager.subscriptionAccount.type == .local {
subscriptions = dataManager?.subscriptions() ?? []
subscriptionsLoaded = true
return
}
// For Invidious, fetch from API
do {
let channels = try await subscriptionService.fetchSubscriptions()
// Convert channels to Subscription objects for UI (not persisted)
subscriptions = channels.map { Subscription.from(channel: $0) }
subscriptionsLoaded = true
} catch {
LoggingService.shared.error(
"Failed to load subscriptions: \(error.localizedDescription)",
category: .general
)
subscriptions = []
subscriptionsLoaded = true
}
}
private func loadWatchEntries() {
watchEntriesMap = dataManager?.watchEntriesMap() ?? [:]
}
private func loadFeed(forceRefresh: Bool) async {
guard let appEnvironment else { return }
if !forceRefresh && feedCache.isCacheValid(using: appEnvironment.settingsManager) {
return
}
errorMessage = nil
await feedCache.refresh(using: appEnvironment)
}
private func unsubscribeChannel(_ channelID: String) {
Task {
do {
try await subscriptionService?.unsubscribe(from: channelID)
// Remove from local list immediately for responsiveness
subscriptions.removeAll { $0.channelID == channelID }
} catch {
LoggingService.shared.error(
"Failed to unsubscribe: \(error.localizedDescription)",
category: .general
)
}
}
}
@Sendable
private func loadMoreSubscriptionsCallback() async throws -> ([Video], String?) {
return ([], nil)
}
}
// MARK: - Preview
#Preview("With Subscriptions") {
PreviewWrapper()
}
private struct PreviewWrapper: View {
let dataManager: DataManager
let previewEnvironment: AppEnvironment
init() {
let dataManager = try! DataManager.preview()
let channel1 = Channel(
id: ChannelID(source: .global(provider: ContentSource.youtubeProvider), channelID: "UC1"),
name: "Apple Developer",
thumbnailURL: nil
)
let channel2 = Channel(
id: ChannelID(source: .global(provider: ContentSource.youtubeProvider), channelID: "UC2"),
name: "Marques Brownlee",
thumbnailURL: nil
)
let channel3 = Channel(
id: ChannelID(source: .global(provider: ContentSource.youtubeProvider), channelID: "UC3"),
name: "Music Channel",
thumbnailURL: nil
)
dataManager.subscribe(to: channel1)
dataManager.subscribe(to: channel2)
dataManager.subscribe(to: channel3)
self.dataManager = dataManager
self.previewEnvironment = AppEnvironment(dataManager: dataManager)
let cache = SubscriptionFeedCache.shared
cache.videos = [
Video(
id: VideoID(source: .global(provider: ContentSource.youtubeProvider), videoID: "video1"),
title: "SwiftUI Tutorial: Building Amazing Apps",
description: "Learn how to build amazing apps with SwiftUI",
author: Author(id: "UC1", name: "Apple Developer"),
duration: 600,
publishedAt: Date().addingTimeInterval(-3600),
publishedText: "1 hour ago",
viewCount: 10000,
likeCount: 500,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
),
Video(
id: VideoID(source: .global(provider: ContentSource.youtubeProvider), videoID: "video2"),
title: "Tech Review: Latest Innovations",
description: "Reviewing the latest tech innovations",
author: Author(id: "UC2", name: "Marques Brownlee"),
duration: 900,
publishedAt: Date().addingTimeInterval(-7200),
publishedText: "2 hours ago",
viewCount: 50000,
likeCount: 2000,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
),
Video(
id: VideoID(source: .global(provider: ContentSource.youtubeProvider), videoID: "video3"),
title: "Music Production Tips and Tricks",
description: "Professional music production techniques",
author: Author(id: "UC3", name: "Music Channel"),
duration: 450,
publishedAt: Date().addingTimeInterval(-10800),
publishedText: "3 hours ago",
viewCount: 5000,
likeCount: 250,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
]
cache.hasLoadedOnce = true
cache.lastUpdated = Date()
}
var body: some View {
NavigationStack {
SubscriptionsView()
}
.appEnvironment(previewEnvironment)
}
}
#Preview("Empty") {
NavigationStack {
SubscriptionsView()
}
.appEnvironment(.preview)
}