// // 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(GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns), id: \.self) { count in Text("\(count)").tag(count) } } .pickerStyle(.segmented) #else Stepper( "viewOptions.columns \(min(max(GridConstants.minAllowedColumns, gridColumns), gridConfig.maxColumns))", value: $gridColumns, in: GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, 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 { 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) }