Files
yattee/Yattee/Views/Subscriptions/ManageChannelsView.swift
2026-02-08 18:33:56 +01:00

515 lines
20 KiB
Swift

//
// ManageChannelsView.swift
// Yattee
//
// View for managing subscribed channels.
//
import SwiftUI
struct ManageChannelsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition
@State private var channels: [Channel] = []
@State private var showViewOptions = false
@State private var searchText = ""
@State private var isLoading = false
@State private var notificationStates: [String: Bool] = [:]
// View options (persisted)
@AppStorage("manageChannelsLayout") private var layout: VideoListLayout = .grid
@AppStorage("manageChannelsRowStyle") private var rowStyle: VideoRowStyle = .regular
@AppStorage("manageChannelsGridColumns") private var gridColumns = 3
@AppStorage("manageChannelsSortOrder") private var sortOrder: SidebarChannelSort = .alphabetical
@State private var subscriptionMetadata: [String: Subscription] = [:]
/// 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 dataManager: DataManager? { appEnvironment?.dataManager }
private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService }
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
private var toastManager: ToastManager? { appEnvironment?.toastManager }
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)
}
/// Channels filtered by search query and sorted by selected order.
private var filteredChannels: [Channel] {
var result = channels
if !searchText.isEmpty {
let query = searchText.lowercased()
result = result.filter { $0.name.lowercased().contains(query) }
}
switch sortOrder {
case .alphabetical:
result.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
case .recentlySubscribed:
result.sort { ch1, ch2 in
let d1 = subscriptionMetadata[ch1.id.channelID]?.subscribedAt ?? .distantPast
let d2 = subscriptionMetadata[ch2.id.channelID]?.subscribedAt ?? .distantPast
return d1 > d2
}
case .lastUploaded:
result.sort { ch1, ch2 in
let d1 = subscriptionMetadata[ch1.id.channelID]?.lastVideoPublishedAt ?? .distantPast
let d2 = subscriptionMetadata[ch2.id.channelID]?.lastVideoPublishedAt ?? .distantPast
return d1 > d2
}
case .custom:
break
}
return result
}
var body: some View {
GeometryReader { geometry in
Group {
if isLoading && channels.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if channels.isEmpty {
ContentUnavailableView {
Label(String(localized: "subscriptions.channels.title"), systemImage: "person.2")
} description: {
Text(String(localized: "subscriptions.channels.empty"))
}
} else {
channelsView
}
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
}
.navigationTitle(String(localized: "subscriptions.channels.title"))
#if !os(tvOS)
.toolbarTitleDisplayMode(.inlineLarge)
.searchable(text: $searchText, prompt: Text("Search channels"))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "manageChannelsViewOptions", in: sheetTransition)
}
}
#endif
.sheet(isPresented: $showViewOptions) {
NavigationStack {
Form {
// View options section
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
Picker("manageChannels.sortBy", selection: $sortOrder) {
Text("manageChannels.sortBy.name").tag(SidebarChannelSort.alphabetical)
Text("manageChannels.sortBy.recentlySubscribed").tag(SidebarChannelSort.recentlySubscribed)
Text("manageChannels.sortBy.lastUploaded").tag(SidebarChannelSort.lastUploaded)
}
}
#if !os(tvOS)
// Subscriptions Data navigation link
Section {
NavigationLink {
SubscriptionsSettingsView()
} label: {
Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape")
}
}
#endif
}
.navigationTitle(String(localized: "manageChannels.viewOptions.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
.presentationDetents([.height(360), .large])
.presentationDragIndicator(.visible)
.liquidGlassSheetContent(sourceID: "manageChannelsViewOptions", in: sheetTransition)
}
.onAppear {
if let syncChannels = subscriptionService?.fetchSubscriptionsSync() {
channels = syncChannels
}
subscriptionMetadata = Dictionary(
uniqueKeysWithValues: (dataManager?.subscriptions() ?? []).map { ($0.channelID, $0) }
)
}
.task {
guard channels.isEmpty else { return }
await refreshChannels()
}
.task {
// Fetch missing subscriber counts from Yattee Server (runs after onAppear)
await fetchMissingSubscriberCounts()
}
.onReceive(NotificationCenter.default.publisher(for: .subscriptionsDidChange)) { _ in
Task {
await refreshChannels()
}
}
.onChange(of: settingsManager?.subscriptionAccount) { _, _ in
Task {
await refreshChannels()
}
}
}
// MARK: - Content Views
@ViewBuilder
private var channelsView: some View {
#if os(tvOS)
VStack(spacing: 0) {
// tvOS: Inline search field and action button for better focus navigation
HStack(spacing: 24) {
TextField("Search channels", text: $searchText)
.textFieldStyle(.plain)
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
}
.focusSection()
.padding(.horizontal, 48)
.padding(.top, 20)
// Content
Group {
if filteredChannels.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
.focusSection()
}
#else
Group {
if filteredChannels.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
#endif
}
private var listContent: some View {
VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) {
Spacer()
.frame(height: 16)
} content: {
ForEach(Array(filteredChannels.enumerated()), id: \.element.id.channelID) { index, channel in
VideoListRow(
isLast: index == filteredChannels.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
channelRow(channel: channel)
}
}
}
}
@ViewBuilder
private func channelRow(channel: Channel) -> some View {
NavigationLink(
value: NavigationDestination.channel(
channel.id.channelID,
channel.id.source
)
) {
ChannelRowView(
channel: channelWithOptimizedAvatar(channel),
style: rowStyle,
authHeader: yatteeServerAuthHeader
)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: channel.id.channelID)
.buttonStyle(.plain)
.swipeActions {
SwipeAction(
symbolImage: notificationsEnabled(for: channel) ? "bell.slash" : "bell",
tint: .white,
background: .blue,
font: .body,
size: CGSize(width: 38, height: 38)
) { reset in
toggleNotifications(for: channel)
reset()
}
SwipeAction(
symbolImage: "person.badge.minus",
tint: .white,
background: .red,
font: .body,
size: CGSize(width: 38, height: 38)
) { reset in
unsubscribe(from: channel)
reset()
}
}
.contextMenu {
Button {
toggleNotifications(for: channel)
} label: {
Label(
notificationsEnabled(for: channel)
? String(localized: "channel.menu.disableNotifications")
: String(localized: "channel.menu.enableNotifications"),
systemImage: notificationsEnabled(for: channel) ? "bell.slash" : "bell"
)
}
Button(role: .destructive) {
unsubscribe(from: channel)
} label: {
Label(String(localized: "channel.unsubscribe"), systemImage: "person.badge.minus")
}
}
}
private var gridContent: some View {
ScrollView {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(filteredChannels, id: \.id.channelID) { channel in
NavigationLink(
value: NavigationDestination.channel(
channel.id.channelID,
channel.id.source
)
) {
ChannelCardGridView(
channel: channelWithOptimizedAvatar(channel),
isCompact: gridConfig.isCompactCards,
authHeader: yatteeServerAuthHeader
)
.frame(maxHeight: .infinity, alignment: .top)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: channel.id.channelID)
.buttonStyle(.plain)
.contextMenu {
Button {
toggleNotifications(for: channel)
} label: {
Label(
notificationsEnabled(for: channel)
? String(localized: "channel.menu.disableNotifications")
: String(localized: "channel.menu.enableNotifications"),
systemImage: notificationsEnabled(for: channel) ? "bell.slash" : "bell"
)
}
Button(role: .destructive) {
unsubscribe(from: channel)
} label: {
Label(String(localized: "channel.unsubscribe"), systemImage: "person.badge.minus")
}
}
}
}
}
}
// MARK: - Helpers
/// Returns channel with optimized avatar URL from Yattee Server when available.
private func channelWithOptimizedAvatar(_ channel: Channel) -> Channel {
let effectiveAvatarURL = AvatarURLBuilder.avatarURL(
channelID: channel.id.channelID,
directURL: channel.thumbnailURL,
serverURL: yatteeServerURL,
size: gridConfig.isCompactCards ? 80 : 100
)
return Channel(
id: channel.id,
name: channel.name,
description: channel.description,
subscriberCount: channel.subscriberCount,
thumbnailURL: effectiveAvatarURL,
bannerURL: channel.bannerURL,
isVerified: channel.isVerified
)
}
/// Refreshes channels from the current subscription provider.
private func refreshChannels() async {
guard let subscriptionService else { return }
isLoading = true
do {
channels = try await subscriptionService.fetchSubscriptions()
} catch {
// Show empty state on error
channels = []
LoggingService.shared.error(
"Failed to fetch subscriptions: \(error.localizedDescription)",
category: .general
)
}
isLoading = false
}
/// Fetches subscriber counts for channels that don't have them cached.
/// Uses Yattee Server's cached metadata endpoint (no YouTube API calls).
private func fetchMissingSubscriberCounts() async {
guard let appEnvironment,
let yatteeServer = appEnvironment.instancesManager.enabledYatteeServerInstances.first else {
return
}
// Find channels missing subscriber counts
let channelsNeedingCounts = channels.filter { $0.subscriberCount == nil }
guard !channelsNeedingCounts.isEmpty else { return }
let channelIDs = channelsNeedingCounts.compactMap { $0.id.channelID }
guard !channelIDs.isEmpty else { return }
do {
let api = YatteeServerAPI(httpClient: HTTPClient())
let authHeader = appEnvironment.yatteeServerCredentialsManager.basicAuthHeader(for: yatteeServer)
await api.setAuthHeader(authHeader)
let response = try await api.channelsMetadata(channelIDs: channelIDs, instance: yatteeServer)
// Update subscriptions in SwiftData
for metadata in response.channels {
if let count = metadata.subscriberCount {
appEnvironment.dataManager.updateSubscriberCount(
for: metadata.channelId,
count: count,
isVerified: metadata.isVerifiedBool
)
}
}
// Refresh channels from the service to pick up updated counts
if let syncChannels = subscriptionService?.fetchSubscriptionsSync() {
channels = syncChannels
}
} catch {
// Silently fail - subscriber counts are optional enhancement
LoggingService.shared.debug(
"Failed to fetch subscriber counts: \(error.localizedDescription)",
category: .general
)
}
}
private func unsubscribe(from channel: Channel) {
Task {
do {
try await subscriptionService?.unsubscribe(from: channel.id.channelID)
// Remove from local list immediately for responsiveness
channels.removeAll { $0.id.channelID == channel.id.channelID }
} catch {
toastManager?.showError(
String(localized: "channel.unsubscribe.error.title"),
subtitle: error.localizedDescription
)
}
}
}
private func notificationsEnabled(for channel: Channel) -> Bool {
// Use local state if available, otherwise query DataManager
if let localState = notificationStates[channel.id.channelID] {
return localState
}
return dataManager?.notificationsEnabled(for: channel.id.channelID) ?? false
}
private func toggleNotifications(for channel: Channel) {
let currentState = notificationsEnabled(for: channel)
if currentState {
// Disabling no permission check needed
notificationStates[channel.id.channelID] = false
dataManager?.setNotificationsEnabled(false, for: channel.id.channelID)
} else {
Task {
guard let appEnvironment, await appEnvironment.ensureNotificationsEnabled() else { return }
notificationStates[channel.id.channelID] = true
appEnvironment.dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
}
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
ManageChannelsView()
}
.appEnvironment(.preview)
}