mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
watchEntriesMap() built its dictionary with uniqueKeysWithValues, but the same video ID can be recorded under more than one source scope, which trapped on the duplicate key (3 crash logs from build 270, all in SearchView.loadWatchEntries). Keep the most recent entry instead. The subscription metadata map in Manage Channels had the same shape (a channel subscribed under several account scopes), as did the server history hydration map; both now tolerate duplicates.
626 lines
23 KiB
Swift
626 lines
23 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
|
|
#if os(macOS)
|
|
@State private var showSubscriptionsData = false
|
|
@State private var pendingSubscriptionsData = false
|
|
#endif
|
|
@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
|
|
#if os(macOS) || os(tvOS)
|
|
@AppStorage("manageChannelsGridColumns") private var gridColumns = 5
|
|
#else
|
|
@AppStorage("manageChannelsGridColumns") private var gridColumns = 3
|
|
#endif
|
|
@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
|
|
}
|
|
|
|
private var viewOptionsForm: some View {
|
|
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 {
|
|
PlatformMenuPicker(String(localized: "viewOptions.rowSize"), selection: $rowStyle) {
|
|
Text("viewOptions.rowSize.compact").tag(VideoRowStyle.compact)
|
|
Text("viewOptions.rowSize.regular").tag(VideoRowStyle.regular)
|
|
Text("viewOptions.rowSize.large").tag(VideoRowStyle.large)
|
|
}
|
|
}
|
|
|
|
// Grid-specific options
|
|
if layout == .grid {
|
|
#if os(tvOS)
|
|
Picker("viewOptions.columns.header", selection: $gridColumns) {
|
|
ForEach(GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns), id: \.self) { count in
|
|
Text("\(count)").tag(count)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
#else
|
|
Stepper(
|
|
"viewOptions.columns \(min(max(GridConstants.minAllowedColumns, gridColumns), gridConfig.maxColumns))",
|
|
value: $gridColumns,
|
|
in: GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns)
|
|
)
|
|
#endif
|
|
}
|
|
|
|
PlatformMenuPicker(String(localized: "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(macOS)
|
|
// Subscriptions Data (opens as a sheet; popovers can't push)
|
|
Section {
|
|
Button {
|
|
pendingSubscriptionsData = true
|
|
showViewOptions = false
|
|
} label: {
|
|
Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape")
|
|
}
|
|
}
|
|
#elseif os(iOS)
|
|
// Subscriptions Data navigation link
|
|
Section {
|
|
NavigationLink {
|
|
SubscriptionsSettingsView()
|
|
} label: {
|
|
Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape")
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
private var subscriptionsDataSheet: some View {
|
|
NavigationStack {
|
|
SubscriptionsSettingsView()
|
|
.toolbar {
|
|
sheetCloseToolbarItem { showSubscriptionsData = false }
|
|
}
|
|
}
|
|
.frame(minWidth: 500, minHeight: 450)
|
|
}
|
|
#endif
|
|
|
|
// 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 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?.basicAuthCredentialsManager.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
|
|
}
|
|
|
|
/// View options button lives on the leading edge on macOS, trailing elsewhere.
|
|
private var viewOptionsPlacement: ToolbarItemPlacement {
|
|
#if os(macOS)
|
|
.navigation
|
|
#else
|
|
.primaryAction
|
|
#endif
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
#if os(tvOS)
|
|
VStack(spacing: 0) {
|
|
tvOSHeader
|
|
stateContent
|
|
}
|
|
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
|
|
viewWidth = newWidth
|
|
}
|
|
#else
|
|
stateContent
|
|
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
|
|
viewWidth = newWidth
|
|
}
|
|
#endif
|
|
}
|
|
#if !os(tvOS)
|
|
.navigationTitle(String(localized: "subscriptions.channels.title"))
|
|
.toolbarTitleDisplayMode(.inlineLarge)
|
|
.searchable(text: $searchText, prompt: Text(String(localized: "channels.search.placeholder")))
|
|
.toolbar {
|
|
#if os(macOS)
|
|
// Pin the trailing group (search field + toolbar buttons) to the right edge,
|
|
// matching the global Search view.
|
|
if #available(macOS 26, *) {
|
|
ToolbarSpacer(.flexible, placement: .primaryAction)
|
|
}
|
|
#endif
|
|
ToolbarItem(placement: viewOptionsPlacement) {
|
|
Button {
|
|
showViewOptions = true
|
|
} label: {
|
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
}
|
|
.liquidGlassTransitionSource(id: "manageChannelsViewOptions", in: sheetTransition)
|
|
#if os(macOS)
|
|
.popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
|
|
viewOptionsForm
|
|
.padding()
|
|
.frame(width: 300)
|
|
.onDisappear {
|
|
// Present the sheet only after the popover is gone,
|
|
// otherwise the presentation is swallowed.
|
|
if pendingSubscriptionsData {
|
|
pendingSubscriptionsData = false
|
|
showSubscriptionsData = true
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
#endif
|
|
#if os(macOS)
|
|
.sheet(isPresented: $showSubscriptionsData) {
|
|
subscriptionsDataSheet
|
|
}
|
|
#else
|
|
.sheet(isPresented: $showViewOptions) {
|
|
NavigationStack {
|
|
viewOptionsForm
|
|
#if os(tvOS)
|
|
.scrollClipDisabled()
|
|
.padding(.horizontal, 40)
|
|
.padding(.vertical, 24)
|
|
#else
|
|
.navigationTitle(String(localized: "manageChannels.viewOptions.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
#endif
|
|
}
|
|
.presentationDetents([.height(360), .large])
|
|
.presentationDragIndicator(.visible)
|
|
.liquidGlassSheetContent(sourceID: "manageChannelsViewOptions", in: sheetTransition)
|
|
}
|
|
#endif
|
|
.onAppear {
|
|
if let syncChannels = subscriptionService?.fetchSubscriptionsSync() {
|
|
channels = syncChannels
|
|
}
|
|
// A channel can be subscribed under several account scopes.
|
|
subscriptionMetadata = Dictionary(
|
|
(dataManager?.subscriptions() ?? []).map { ($0.channelID, $0) },
|
|
uniquingKeysWith: { first, _ in first }
|
|
)
|
|
}
|
|
.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()
|
|
}
|
|
}
|
|
// The data context, not the legacy setting: switching between two
|
|
// logins on the same instance leaves that value identical, and this
|
|
// view would keep listing one account's channels while unsubscribes
|
|
// went to the other.
|
|
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
|
|
Task {
|
|
await refreshChannels()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Content Views
|
|
|
|
@ViewBuilder
|
|
private var stateContent: some View {
|
|
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"))
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
channelsView
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
/// Header rendered above every state on tvOS so focus always has a target.
|
|
@ViewBuilder
|
|
private var tvOSHeader: some View {
|
|
HStack(spacing: 24) {
|
|
TextField("search.channels.placeholder", text: $searchText)
|
|
.textFieldStyle(.plain)
|
|
|
|
Button {
|
|
showViewOptions = true
|
|
} label: {
|
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
}
|
|
}
|
|
.focusSection()
|
|
.padding(.horizontal, 48)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 20)
|
|
}
|
|
#endif
|
|
|
|
@ViewBuilder
|
|
private var channelsView: some View {
|
|
#if os(tvOS)
|
|
Group {
|
|
if filteredChannels.isEmpty {
|
|
ContentUnavailableView.search(text: searchText)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
switch layout {
|
|
case .list:
|
|
listContent
|
|
case .grid:
|
|
gridContent
|
|
}
|
|
}
|
|
}
|
|
.focusSection()
|
|
#else
|
|
Group {
|
|
if filteredChannels.isEmpty {
|
|
ContentUnavailableView.search(text: searchText)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} 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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.scrollClipDisabled()
|
|
}
|
|
|
|
// 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 httpClient = HTTPClient()
|
|
if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) {
|
|
await httpClient.setDefaultHeaders(["Authorization": authHeader])
|
|
}
|
|
let api = YatteeServerAPI(httpClient: httpClient)
|
|
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)
|
|
}
|