mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
916 lines
36 KiB
Swift
916 lines
36 KiB
Swift
//
|
|
// 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)
|
|
}
|