// // SubscriptionsView.swift // Yattee // // Subscriptions tab with channel filter strip and feed. // import SwiftUI struct SubscriptionsView: View { @Environment(\.appEnvironment) private var appEnvironment @Namespace private var sheetTransition @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 /// 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?.yatteeServerCredentialsManager.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 { Group { switch layout { case .list: listContent case .grid: gridContent } } .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) } .navigationTitle(String(localized: "tabs.subscriptions")) #if !os(tvOS) .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) } } .sheet(isPresented: $showViewOptions) { NavigationStack { Form { Section { // Layout picker (segmented) Picker(selection: $layout) { ForEach(VideoListLayout.allCases, id: \.self) { option in Label(option.displayName, systemImage: option.systemImage) .tag(option) } } label: { Text("viewOptions.layout") } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) // List-specific options if layout == .list { Picker("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 !os(tvOS) if layout == .grid { Stepper( "viewOptions.columns \(min(max(1, gridColumns), gridConfig.maxColumns))", value: $gridColumns, in: 1...gridConfig.maxColumns ) } #endif Toggle("viewOptions.hideWatched", isOn: $hideWatched) Picker("viewOptions.channelStrip", selection: $channelStripSize) { ForEach(ChannelStripSize.allCases, id: \.self) { size in Text(size.displayName).tag(size) } } } #if !os(tvOS) Section { NavigationLink { SubscriptionsSettingsView() } label: { Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape") } } #endif } .navigationTitle(String(localized: "subscriptions.viewOptions.title")) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif } .presentationDetents([.height(420), .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) } } // Bottom overlay for filter strip VStack { Spacer() #if !os(tvOS) if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError { bottomFloatingFilterStrip .transition(.move(edge: .bottom).combined(with: .opacity)) } #endif } } .onChange(of: selectedChannelID) { _, _ in withAnimation { proxy.scrollTo("top", anchor: .top) } } } .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 sectionHeaderView } content: { feedContentRows } footer: { // Bottom spacer for channel strip overlay (outside the card) if channelStripSize != .disabled && subscriptions.count > 1 && !isShowingFullScreenError { Color.clear.frame(height: channelStripSize.totalHeight) } } } /// 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) .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) .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 HStack { feedSectionHeader Spacer() } .padding(.horizontal) .padding(.top, 8) .padding(.bottom, 12) // 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) } } } } // 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: - 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) .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 ) } } // 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 { if feedCache.isLoading, let progress = feedCache.loadingProgress { Text("subscriptions.updatingChannels \(progress.loaded) \(progress.total)") .monospacedDigit() .foregroundStyle(.secondary) } 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(Color.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(Color.accentColor) } .buttonStyle(.plain) } Spacer() } } // 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) .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) .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) .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) .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) }