mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
915
Yattee/Views/Subscriptions/SubscriptionsView.swift
Normal file
915
Yattee/Views/Subscriptions/SubscriptionsView.swift
Normal file
@@ -0,0 +1,915 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user