Files
yattee/Yattee/Views/Channel/ChannelView.swift
Arkadiusz Fal 4f5781bc20 Show toolbar buttons and tab picker during channel loading
Display the view options button, channel menu, and content type tabs
immediately when the cached header is shown, instead of waiting for
the full channel data to load. The spinner now appears only in the
content area below the tabs.
2026-02-20 20:14:01 +01:00

2088 lines
74 KiB
Swift

//
// ChannelView.swift
// Yattee
//
// Channel view with zoom/scale header and video grid.
//
import SwiftUI
import NukeUI
struct ChannelView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let channelID: String
let source: ContentSource
/// URL for external channel extraction (nil for YouTube/Invidious channels)
var channelURL: URL? = nil
/// Whether this is an external channel that requires extraction
private var isExternalChannel: Bool {
channelURL != nil
}
@Namespace private var sheetTransition
@State private var channel: Channel?
@State private var selectedTab: ChannelTab = .videos
@State private var isLoading = true
@State private var isLoadingMore = false
@State private var errorMessage: String?
@State private var subscription: Subscription?
@State private var isSubscribed = false
@State private var showingUnsubscribeConfirmation = false
@State private var scrollOffset: CGFloat = 0
@State private var scrollToTop: Bool = false
@State private var cachedHeader: CachedChannelData?
// View options (persisted)
@AppStorage("channel.layout") private var layout: VideoListLayout = .list
@AppStorage("channel.rowStyle") private var rowStyle: VideoRowStyle = .regular
@AppStorage("channel.gridColumns") private var gridColumns = 2
@AppStorage("channel.hideWatched") private var hideWatched = false
/// List style from centralized settings.
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
// UI state for view options
@State private var showViewOptions = false
@State private var viewWidth: CGFloat = 0
@State private var watchEntriesMap: [String: WatchEntry] = [:]
// Videos tab state
@State private var videos: [Video] = []
@State private var videosContinuation: String?
@State private var videosLoaded = false
// Playlists tab state
@State private var playlists: [Playlist] = []
@State private var playlistsContinuation: String?
@State private var playlistsLoaded = false
// Shorts tab state
@State private var shorts: [Video] = []
@State private var shortsContinuation: String?
@State private var shortsLoaded = false
// Streams tab state
@State private var streams: [Video] = []
@State private var streamsContinuation: String?
@State private var streamsLoaded = false
// Search state
@State private var searchText = ""
@State private var isSearchActive = false
@State private var hasSearched = false // True after user submits a search query
@State private var searchResults: ChannelSearchPage = .empty
@State private var isSearchLoading = false
// External channel state (page-based pagination for extracted channels)
@State private var externalCurrentPage = 1
// Header configuration
private let baseHeaderHeight: CGFloat = 280
private let searchBarExtraHeight: CGFloat = 70
private let collapsedHeaderHeight: CGFloat = 60
private let avatarSize: CGFloat = 80
private let collapsedAvatarSize: CGFloat = 36
/// Whether search bar adjustments are needed (only on compact/iPhone where search bar overlays content)
private var needsSearchBarAdjustment: Bool {
supportsChannelSearch && horizontalSizeClass == .compact
}
/// Header height adjusted for search bar on iPhone (iOS 18+ places search bar in navigation area)
private var headerHeight: CGFloat {
baseHeaderHeight + (needsSearchBarAdjustment ? searchBarExtraHeight : 0)
}
private var accentColor: Color {
appEnvironment?.settingsManager.accentColor.color ?? .accentColor
}
// Grid layout configuration
private var gridConfig: GridLayoutConfiguration {
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
}
// Filtered video arrays (for hideWatched)
private var filteredVideos: [Video] {
hideWatched ? videos.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : videos
}
private var filteredShorts: [Video] {
hideWatched ? shorts.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : shorts
}
private var filteredStreams: [Video] {
hideWatched ? streams.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : streams
}
/// Whether inset grouped background should be shown.
private var showInsetBackground: Bool {
layout == .list && listStyle == .inset
}
/// Background color for the view based on list style.
private var viewBackgroundColor: Color {
showInsetBackground ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color
}
var body: some View {
Group {
if let channel {
channelContent(channel)
} else if let cachedHeader {
// Show header with cached data + spinner for content area
loadingContent(cachedHeader)
} else if isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage {
errorView(error)
}
}
.background(showInsetBackground ? viewBackgroundColor : .clear)
.task {
await loadChannel()
}
.onAppear {
// Update Handoff activity for this specific channel
appEnvironment?.handoffManager.updateActivity(for: .channel(channelID, source))
loadWatchEntries()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadWatchEntries()
}
}
private func loadWatchEntries() {
watchEntriesMap = appEnvironment?.dataManager.watchEntriesMap() ?? [:]
}
// MARK: - Computed Properties for Scroll Animation
/// Progress from 0 (fully expanded) to 1 (fully collapsed)
private var collapseProgress: CGFloat {
let progress = scrollOffset / (headerHeight - collapsedHeaderHeight)
return min(max(progress, 0), 1)
}
/// Current avatar size interpolated between full and collapsed
private var currentAvatarSize: CGFloat {
avatarSize - (avatarSize - collapsedAvatarSize) * collapseProgress
}
/// Opacity for expanded content (fades out as we scroll)
private var expandedContentOpacity: CGFloat {
1 - min(collapseProgress * 2, 1)
}
/// Opacity for collapsed title (fades in as we scroll)
private var collapsedTitleOpacity: CGFloat {
max((collapseProgress - 0.5) * 2, 0)
}
// MARK: - Channel Content
@ViewBuilder
private func channelContent(_ channel: Channel) -> some View {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
// Only hide header and tabs after user submits a search query
if !(isSearchActive && hasSearched) {
// Header with zoom/scale effect
header(channel)
.id("channelTop")
// Content based on instance type
if supportsChannelTabs {
// Pill-style content type switcher
contentTypePicker
.padding(.horizontal)
.padding(.vertical, 8)
} else {
// Non-tab instances: show description
channelDescription(channel)
}
}
// Tab content or search results
// Only show search results after user has submitted a query
if isSearchActive && hasSearched {
// Spacer to push content below nav bar + search bar
// Since we use .ignoresSafeArea(edges: .top), we need manual spacing
// iOS: safe area (~59pt) + nav bar (~44pt) + search bar (~56pt) = ~130pt extra
// macOS: safe area + toolbar (~52pt)
#if os(iOS)
Spacer()
.frame(height: geometry.safeAreaInsets.top + 130)
#elseif os(macOS)
Spacer()
.frame(height: geometry.safeAreaInsets.top + 52)
#else
Spacer()
.frame(height: geometry.safeAreaInsets.top)
#endif
searchResultsContent
} else if supportsChannelTabs {
tabContent
} else {
videosGrid
}
}
}
.onChange(of: scrollToTop) { _, shouldScroll in
if shouldScroll {
withAnimation {
proxy.scrollTo("channelTop", anchor: .top)
}
scrollToTop = false
}
}
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
}
.background(viewBackgroundColor)
.ignoresSafeArea(edges: .top)
.animation(.easeInOut(duration: 0.25), value: isSearchActive)
.modifier(ChannelScrollOffsetModifier(
scrollOffset: $scrollOffset,
isPlayerExpanded: appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
))
.toolbar {
ToolbarItem(placement: .principal) {
// Collapsed title in toolbar
HStack(spacing: 8) {
if collapseProgress > 0.5 {
LazyImage(url: channel.thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.quaternary)
}
}
.frame(width: 28, height: 28)
.clipShape(Circle())
}
Text(channel.name)
.font(.headline)
.lineLimit(1)
}
.opacity(collapsedTitleOpacity)
}
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition)
}
#if !os(tvOS)
if #available(iOS 26, macOS 26, *) {
ToolbarSpacer(.fixed, placement: .primaryAction)
}
#endif
ToolbarItem(placement: .primaryAction) {
channelMenu
}
}
.sheet(isPresented: $showViewOptions) {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumns,
hideWatched: $hideWatched,
maxGridColumns: gridConfig.maxColumns
)
.liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition)
}
#if os(iOS)
.toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar)
.navigationBarTitleDisplayMode(.inline)
#endif
#if os(iOS)
.if(supportsChannelSearch) { view in
view.searchable(
text: $searchText,
isPresented: $isSearchActive,
placement: .navigationBarDrawer(displayMode: .automatic),
prompt: Text("channel.search.placeholder")
)
}
#elseif os(macOS)
.if(supportsChannelSearch) { view in
view.searchable(
text: $searchText,
isPresented: $isSearchActive,
placement: .toolbar,
prompt: Text("channel.search.placeholder")
)
}
#endif
.onSubmit(of: .search) {
Task {
await performSearch()
}
}
.onChange(of: searchText) { _, newValue in
if newValue.isEmpty && isSearchActive {
// User cleared the search text, reset search state
searchResults = .empty
}
}
.onChange(of: isSearchActive) { _, isActive in
if !isActive {
// Search was dismissed, clear results
hasSearched = false
searchResults = .empty
searchText = ""
}
}
.confirmationDialog(
String(localized: "channel.unsubscribe.confirmation.title"),
isPresented: $showingUnsubscribeConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "channel.unsubscribe.confirmation.action"), role: .destructive) {
unsubscribe()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(String(localized: "channel.unsubscribe.confirmation.message"))
}
}
// MARK: - Loading Content
/// Shows cached header with a spinner below while loading full channel data.
@ViewBuilder
private func loadingContent(_ cached: CachedChannelData) -> some View {
GeometryReader { geometry in
ScrollView {
LazyVStack(spacing: 0) {
header(name: cached.name, thumbnailURL: cached.thumbnailURL, bannerURL: cached.bannerURL)
.id("channelTop")
// Show tab picker during loading (doesn't depend on channel)
if supportsChannelTabs {
contentTypePicker
.padding(.horizontal)
.padding(.vertical, 8)
}
// Centered spinner for content area below tabs
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
}
.background(viewBackgroundColor)
.ignoresSafeArea(edges: .top)
.toolbar {
ToolbarItem(placement: .principal) {
// Collapsed title in toolbar (using cached data)
HStack(spacing: 8) {
if collapseProgress > 0.5 {
LazyImage(url: cached.thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.ultraThinMaterial)
}
}
.frame(width: collapsedAvatarSize, height: collapsedAvatarSize)
.clipShape(Circle())
Text(cached.name)
.font(.headline)
.lineLimit(1)
}
}
.opacity(collapsedTitleOpacity)
}
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition)
}
#if !os(tvOS)
if #available(iOS 26, macOS 26, *) {
ToolbarSpacer(.fixed, placement: .primaryAction)
}
#endif
ToolbarItem(placement: .primaryAction) {
channelMenu
}
}
.sheet(isPresented: $showViewOptions) {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumns,
hideWatched: $hideWatched,
maxGridColumns: gridConfig.maxColumns
)
.liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition)
}
#if os(iOS)
.toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar)
.navigationBarTitleDisplayMode(.inline)
#endif
.modifier(ChannelScrollOffsetModifier(
scrollOffset: $scrollOffset,
isPlayerExpanded: appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
))
.confirmationDialog(
String(localized: "channel.unsubscribe.confirmation.title"),
isPresented: $showingUnsubscribeConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "channel.unsubscribe.confirmation.action"), role: .destructive) {
unsubscribe()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(String(localized: "channel.unsubscribe.confirmation.message"))
}
}
// MARK: - Header
private func header(_ channel: Channel) -> some View {
header(name: channel.name, thumbnailURL: channel.thumbnailURL, bannerURL: channel.bannerURL)
}
private func header(name: String, thumbnailURL: URL?, bannerURL: URL?) -> some View {
GeometryReader { geometry in
// Calculate avatar vertical position
let avatarBottomPadding: CGFloat = 20
// Smoothly interpolate the space for channel name (40pt when visible, 0 when collapsed)
// Name starts fading at progress 0, fully gone at 0.3
let nameSpaceProgress = min(collapseProgress / 0.3, 1.0)
let nameSpace: CGFloat = 40 * (1 - nameSpaceProgress)
let avatarContentHeight: CGFloat = currentAvatarSize + nameSpace + avatarBottomPadding
let idealAvatarY = headerHeight - avatarContentHeight
// Minimum Y position to prevent going into nav bar
// On iOS 18+ iPhone, the search bar is present in the navigation area (~56pt + padding)
// We add extra height to the header, so minAvatarY just needs to clear nav bar + search
let searchBarReservedHeight: CGFloat = needsSearchBarAdjustment ? 70 : 0
let minAvatarY: CGFloat = 60 + searchBarReservedHeight
let clampedAvatarY = max(idealAvatarY, minAvatarY)
// iPad/Mac uses backgroundExtensionEffect (no zoom/scale needed)
// iPhone uses zoom/scale + pinOffset to keep banner fixed
let isRegularSizeClass = horizontalSizeClass != .compact
// Pin offset: when pulling down (scrollOffset < 0), offset banner up to keep it pinned (iPhone only)
let pinOffset = (!isRegularSizeClass && scrollOffset < 0) ? scrollOffset : 0
// Zoom scale for banner and gradient (iPhone only)
let zoomScale = isRegularSizeClass ? 1 : (1 + max(-scrollOffset / 400, 0))
ZStack(alignment: .top) {
// Banner image
// iPad/Mac: static with backgroundExtensionEffect for Liquid Glass sidebar
// iPhone: zoom/scale + pinOffset to keep banner fixed at top when pulling down
bannerImage(url: bannerURL)
.frame(width: geometry.size.width, height: headerHeight)
.clipped()
.scaleEffect(zoomScale, anchor: .top)
.offset(y: pinOffset)
.modifier(BackgroundExtensionModifier(
useBackgroundExtension: isRegularSizeClass
))
// Gradient overlay for readability - matches banner scale and position
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .center,
endPoint: .bottom
)
.frame(width: geometry.size.width, height: headerHeight * 0.7)
.offset(y: headerHeight * 0.3)
.scaleEffect(zoomScale, anchor: .top)
.offset(y: pinOffset)
// Avatar and channel name - pinned to bottom of banner
// Smooth opacity fade based on collapse progress
let avatarOpacity = max(0, 1.0 - collapseProgress * 1.4)
// Channel name fades out faster than avatar (starts fading at 0, gone by 0.3)
let nameOpacity = max(0, 1.0 - collapseProgress * 3.3)
VStack(spacing: 8) {
LazyImage(url: thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.ultraThinMaterial)
.overlay {
Text(String(name.prefix(1)))
.font(.system(size: currentAvatarSize * 0.4, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
.frame(width: currentAvatarSize, height: currentAvatarSize)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.white.opacity(0.8), lineWidth: 3)
)
.shadow(color: .black.opacity(0.4), radius: 10, y: 5)
// Channel name with smooth fade
Text(name)
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.6), radius: 4, y: 2)
.opacity(nameOpacity)
}
.offset(y: clampedAvatarY + pinOffset)
.opacity(avatarOpacity)
}
.frame(height: headerHeight)
}
.frame(height: headerHeight)
}
// MARK: - Banner Image
@ViewBuilder
private func bannerImage(url bannerURL: URL?) -> some View {
if let bannerURL {
LazyImage(url: bannerURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
LinearGradient(
colors: [.gray.opacity(0.3), .gray.opacity(0.5)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
} else {
// Gradient placeholder when no banner
LinearGradient(
colors: [.accentColor.opacity(0.6), .accentColor.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
// MARK: - Channel Description
/// Channel description for non-tab instances (shows full description)
@ViewBuilder
private func channelDescription(_ channel: Channel) -> some View {
if let description = channel.description, !description.isEmpty {
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.bottom, 8)
}
}
/// Whether the current instance supports channel tabs (Invidious, Yattee Server, or Piped - not external channels)
private var supportsChannelTabs: Bool {
// External channels only show videos (no tabs)
guard !isExternalChannel else { return false }
guard let instance = instanceForSource() else {
return false
}
return instance.type == .invidious || instance.type == .yatteeServer || instance.type == .piped
}
/// Whether the current instance supports channel search (Invidious or Yattee Server, not external channels or Piped)
private var supportsChannelSearch: Bool {
// External channels don't support search
guard !isExternalChannel else { return false }
guard let instance = instanceForSource() else { return false }
return instance.type == .invidious || instance.type == .yatteeServer
}
// MARK: - Channel Menu
/// Icon for the channel menu based on subscription/notification state
private var channelMenuIcon: String {
if isSubscribed {
let notificationsEnabled = channel.map {
appEnvironment?.dataManager.notificationsEnabled(for: $0.id.channelID) ?? false
} ?? false
return notificationsEnabled ? "bell.fill" : "person.fill"
}
return "person.badge.plus"
}
/// Toolbar menu for subscribe/unsubscribe and notification actions
private var channelMenu: some View {
Menu {
// Subscribe/Unsubscribe button
Button {
toggleSubscription()
} label: {
Label(
isSubscribed
? String(localized: "channel.menu.unsubscribe")
: String(localized: "channel.menu.subscribe"),
systemImage: isSubscribed ? "person.fill.xmark" : "person.badge.plus"
)
}
// Notifications toggle (only visible when subscribed)
if isSubscribed, let channel {
let notificationsEnabled = appEnvironment?.dataManager.notificationsEnabled(for: channel.id.channelID) ?? false
Button {
toggleNotifications()
} label: {
Label(
notificationsEnabled
? String(localized: "channel.menu.disableNotifications")
: String(localized: "channel.menu.enableNotifications"),
systemImage: notificationsEnabled
? "bell.slash"
: "bell"
)
}
}
} label: {
Image(systemName: channelMenuIcon)
}
}
// MARK: - Subscription Actions
private func toggleSubscription() {
if isSubscribed {
showingUnsubscribeConfirmation = true
} else {
Task { await subscribe() }
}
}
private func subscribe() async {
guard let channel,
let subscriptionService = appEnvironment?.subscriptionService,
let dataManager = appEnvironment?.dataManager else { return }
let author = Author(
id: channel.id.channelID,
name: channel.name,
thumbnailURL: channel.thumbnailURL,
subscriberCount: channel.subscriberCount
)
do {
try await subscriptionService.subscribe(to: author, source: source)
// Set default notification preference for new subscription
let defaultNotifications = appEnvironment?.settingsManager.defaultNotificationsForNewChannels ?? false
if defaultNotifications {
dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
}
isSubscribed = true
refreshSubscription()
} catch {
appEnvironment?.toastManager.showError(
String(localized: "channel.subscribe.error.title"),
subtitle: error.localizedDescription
)
}
}
private func unsubscribe() {
guard let channel else { return }
Task {
do {
try await appEnvironment?.subscriptionService.unsubscribe(from: channel.id.channelID)
isSubscribed = false
subscription = nil
} catch {
appEnvironment?.toastManager.showError(
String(localized: "channel.unsubscribe.error.title"),
subtitle: error.localizedDescription
)
}
}
}
private func toggleNotifications() {
guard let channel else { return }
let currentEnabled = appEnvironment?.dataManager.notificationsEnabled(for: channel.id.channelID) ?? false
if currentEnabled {
appEnvironment?.dataManager.setNotificationsEnabled(false, for: channel.id.channelID)
} else {
Task {
guard let appEnvironment, await appEnvironment.ensureNotificationsEnabled() else { return }
appEnvironment.dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
}
}
}
// MARK: - Content Type Picker
/// Pill-style segmented picker for switching between content types
private var contentTypePicker: some View {
Picker("", selection: $selectedTab) {
Text(ChannelTab.about.title).tag(ChannelTab.about)
Text(ChannelTab.videos.title).tag(ChannelTab.videos)
Text(ChannelTab.shorts.title).tag(ChannelTab.shorts)
Text(ChannelTab.streams.title).tag(ChannelTab.streams)
Text(ChannelTab.playlists.title).tag(ChannelTab.playlists)
}
.pickerStyle(.segmented)
.onChange(of: selectedTab) { oldTab, newTab in
if oldTab != newTab {
scrollToTop = true
Task {
await loadTabContentIfNeeded(newTab)
}
}
}
}
// MARK: - Tab Content
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
case .about:
aboutContent
case .videos:
videosGrid
case .playlists:
playlistsGrid
case .shorts:
shortsGrid
case .streams:
streamsGrid
}
}
// MARK: - About Content
@ViewBuilder
private var aboutContent: some View {
if let channel {
let hasSubscriberCount = channel.subscriberCount != nil
let hasDescription = channel.description?.isEmpty == false
if hasSubscriberCount || hasDescription {
let content = VStack(alignment: .leading, spacing: 12) {
// Subscriber count (count is bold, "subscribers" is regular)
if let subscriberCount = channel.subscriberCount {
Text(CountFormatter.compact(subscriberCount))
.fontWeight(.bold)
+ Text(verbatim: " ")
+ Text(String(localized: "channel.subscribers"))
}
// Description
if let description = channel.description, !description.isEmpty {
Text(DescriptionText.attributed(description, linkColor: accentColor))
.font(.subheadline)
.tint(accentColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showInsetBackground {
content
.background(ListBackgroundStyle.card.color)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
} else {
content
}
} else {
ContentUnavailableView {
Label(String(localized: "channel.noDescription"), systemImage: "text.alignleft")
}
.padding(.vertical, 40)
}
}
}
// MARK: - Videos Grid
/// Queue source for continuation loading
private var videosQueueSource: QueueSource {
.channel(channelID: channelID, source: source, continuation: videosContinuation)
}
@ViewBuilder
private var videosGrid: some View {
switch layout {
case .list:
videosListContent
case .grid:
videosGridContent
}
}
@ViewBuilder
private var videosListContent: some View {
if videosLoaded && filteredVideos.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noVideos"), systemImage: "play.rectangle")
}
.padding(.vertical, 40)
} else if !filteredVideos.isEmpty {
VideoListContent(listStyle: listStyle) {
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)
.tappableVideo(
video,
queueSource: videosQueueSource,
sourceLabel: channel?.name,
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreVideosCallback
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
// External channels use manual load more button
if isExternalChannel {
externalLoadMoreButton
} else {
LoadMoreTrigger(
isLoading: isLoadingMore && selectedTab == .videos,
hasMore: videosContinuation != nil
) {
Task { await loadMoreVideos() }
}
}
}
}
}
/// Manual load more button for external channels
private var externalLoadMoreButton: some View {
Group {
if videosContinuation != nil {
Button {
Task { await loadMoreVideos() }
} label: {
HStack {
if isLoadingMore {
ProgressView()
.padding(.trailing, 8)
}
Text(isLoadingMore
? String(localized: "externalChannel.loadingMore")
: String(localized: "externalChannel.loadMore"))
}
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.bordered)
.disabled(isLoadingMore)
.padding()
}
}
}
@ViewBuilder
private var videosGridContent: some View {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredVideos.enumerated()), id: \.element.id) { index, video in
VideoCardView(video: video, isCompact: gridConfig.isCompactCards)
.tappableVideo(
video,
queueSource: videosQueueSource,
sourceLabel: channel?.name,
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreVideosCallback
)
// Load more trigger (automatic for regular channels)
if !isExternalChannel && video.id == filteredVideos.last?.id && videosContinuation != nil && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMoreVideos()
}
}
}
}
}
if videosLoaded && filteredVideos.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noVideos"), systemImage: "play.rectangle")
}
.padding(.vertical, 40)
}
// External channels use manual load more button
if isExternalChannel {
externalLoadMoreButton
} else if isLoadingMore && selectedTab == .videos {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Playlists Grid
@ViewBuilder
private var playlistsGrid: some View {
switch layout {
case .list:
playlistsListContent
case .grid:
playlistsGridContent
}
}
@ViewBuilder
private var playlistsListContent: some View {
// Loading state
if !playlistsLoaded && playlists.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task { await loadPlaylists() }
}
}
if playlistsLoaded && playlists.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noPlaylists"), systemImage: "list.bullet.rectangle")
}
.padding(.vertical, 40)
} else if !playlists.isEmpty {
VideoListContent(listStyle: listStyle) {
ForEach(Array(playlists.enumerated()), id: \.element.id) { index, playlist in
VideoListRow(
isLast: index == playlists.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
ChannelPlaylistRow(playlist: playlist, style: rowStyle)
}
}
LoadMoreTrigger(
isLoading: isLoadingMore && selectedTab == .playlists,
hasMore: playlistsContinuation != nil
) {
Task { await loadMorePlaylists() }
}
}
}
}
@ViewBuilder
private var playlistsGridContent: some View {
if !playlistsLoaded && playlists.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task {
await loadPlaylists()
}
}
}
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(playlists) { playlist in
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) {
PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
// Load more trigger
if playlist.id == playlists.last?.id && playlistsContinuation != nil && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMorePlaylists()
}
}
}
}
}
if playlistsLoaded && playlists.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noPlaylists"), systemImage: "list.bullet.rectangle")
}
.padding(.vertical, 40)
}
if isLoadingMore && selectedTab == .playlists {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Shorts Grid
private var shortsQueueSource: QueueSource {
.channel(channelID: channelID, source: source, continuation: shortsContinuation)
}
@ViewBuilder
private var shortsGrid: some View {
switch layout {
case .list:
shortsListContent
case .grid:
shortsGridContent
}
}
@ViewBuilder
private var shortsListContent: some View {
// Loading state
if !shortsLoaded && filteredShorts.isEmpty && shorts.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task { await loadShorts() }
}
}
if shortsLoaded && filteredShorts.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noShorts"), systemImage: "bolt")
}
.padding(.vertical, 40)
} else if !filteredShorts.isEmpty {
VideoListContent(listStyle: listStyle) {
ForEach(Array(filteredShorts.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == filteredShorts.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
VideoRowView(video: video, style: rowStyle)
.tappableVideo(
video,
queueSource: shortsQueueSource,
sourceLabel: channel?.name,
videoList: filteredShorts,
videoIndex: index,
loadMoreVideos: loadMoreShortsCallback
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
LoadMoreTrigger(
isLoading: isLoadingMore && selectedTab == .shorts,
hasMore: shortsContinuation != nil
) {
Task { await loadMoreShorts() }
}
}
}
}
@ViewBuilder
private var shortsGridContent: some View {
if !shortsLoaded && filteredShorts.isEmpty && shorts.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task {
await loadShorts()
}
}
}
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredShorts.enumerated()), id: \.element.id) { index, video in
VideoCardView(video: video, isCompact: gridConfig.isCompactCards)
.tappableVideo(
video,
queueSource: shortsQueueSource,
sourceLabel: channel?.name,
videoList: filteredShorts,
videoIndex: index,
loadMoreVideos: loadMoreShortsCallback
)
// Load more trigger
if video.id == filteredShorts.last?.id && shortsContinuation != nil && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMoreShorts()
}
}
}
}
}
if shortsLoaded && filteredShorts.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noShorts"), systemImage: "bolt")
}
.padding(.vertical, 40)
}
if isLoadingMore && selectedTab == .shorts {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Streams Grid
private var streamsQueueSource: QueueSource {
.channel(channelID: channelID, source: source, continuation: streamsContinuation)
}
@ViewBuilder
private var streamsGrid: some View {
switch layout {
case .list:
streamsListContent
case .grid:
streamsGridContent
}
}
@ViewBuilder
private var streamsListContent: some View {
// Loading state
if !streamsLoaded && filteredStreams.isEmpty && streams.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task { await loadStreams() }
}
}
if streamsLoaded && filteredStreams.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noStreams"), systemImage: "video")
}
.padding(.vertical, 40)
} else if !filteredStreams.isEmpty {
VideoListContent(listStyle: listStyle) {
ForEach(Array(filteredStreams.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == filteredStreams.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
VideoRowView(video: video, style: rowStyle)
.tappableVideo(
video,
queueSource: streamsQueueSource,
sourceLabel: channel?.name,
videoList: filteredStreams,
videoIndex: index,
loadMoreVideos: loadMoreStreamsCallback
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
LoadMoreTrigger(
isLoading: isLoadingMore && selectedTab == .streams,
hasMore: streamsContinuation != nil
) {
Task { await loadMoreStreams() }
}
}
}
}
@ViewBuilder
private var streamsGridContent: some View {
if !streamsLoaded && filteredStreams.isEmpty && streams.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task {
await loadStreams()
}
}
}
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredStreams.enumerated()), id: \.element.id) { index, video in
VideoCardView(video: video, isCompact: gridConfig.isCompactCards)
.tappableVideo(
video,
queueSource: streamsQueueSource,
sourceLabel: channel?.name,
videoList: filteredStreams,
videoIndex: index,
loadMoreVideos: loadMoreStreamsCallback
)
// Load more trigger
if video.id == filteredStreams.last?.id && streamsContinuation != nil && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMoreStreams()
}
}
}
}
}
if streamsLoaded && filteredStreams.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noStreams"), systemImage: "video")
}
.padding(.vertical, 40)
}
if isLoadingMore && selectedTab == .streams {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Search Results
/// Queue source for search results (no continuation since channel search uses pages)
private var searchQueueSource: QueueSource {
.search(query: searchText, continuation: nil)
}
@ViewBuilder
private var searchResultsContent: some View {
if isSearchLoading && searchResults.items.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else if searchResults.items.isEmpty {
ContentUnavailableView.search(text: searchText)
.padding(.vertical, 40)
} else {
switch layout {
case .list:
searchResultsListContent
case .grid:
searchResultsGridContent
}
}
}
@ViewBuilder
private var searchResultsListContent: some View {
VideoListContent(listStyle: listStyle) {
ForEach(Array(searchResults.items.enumerated()), id: \.element.id) { index, item in
VideoListRow(
isLast: index == searchResults.items.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
switch item {
case .video(let video):
VideoRowView(video: video, style: rowStyle)
.tappableVideo(
video,
queueSource: searchQueueSource,
sourceLabel: channel?.name,
videoList: searchResultVideos,
videoIndex: searchResultVideos.firstIndex(where: { $0.id == video.id }) ?? 0,
loadMoreVideos: nil
)
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
case .playlist(let playlist):
ChannelPlaylistRow(playlist: playlist, style: rowStyle)
}
}
}
LoadMoreTrigger(
isLoading: isSearchLoading,
hasMore: searchResults.nextPage != nil
) {
Task { await loadMoreSearchResults() }
}
}
}
@ViewBuilder
private var searchResultsGridContent: some View {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(searchResults.items) { item in
switch item {
case .video(let video):
VideoCardView(video: video, isCompact: gridConfig.isCompactCards)
.tappableVideo(
video,
queueSource: searchQueueSource,
sourceLabel: channel?.name,
videoList: searchResultVideos,
videoIndex: searchResultVideos.firstIndex(where: { $0.id == video.id }) ?? 0,
loadMoreVideos: nil
)
case .playlist(let playlist):
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) {
PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
}
// Load more trigger
if item.id == searchResults.items.last?.id && searchResults.nextPage != nil && !isSearchLoading {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMoreSearchResults()
}
}
}
}
}
if isSearchLoading && !searchResults.items.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
/// Extracts just the videos from search results for queue navigation
private var searchResultVideos: [Video] {
searchResults.items.compactMap { item in
if case .video(let video) = item {
return video
}
return nil
}
}
// MARK: - Search Loading
private func performSearch() async {
guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let appEnvironment,
let instance = instanceForSource() else {
return
}
isSearchLoading = true
hasSearched = true
do {
let results = try await appEnvironment.contentService.channelSearch(
id: channelID,
query: searchText,
instance: instance,
page: 1
)
// Deduplicate items (API may return duplicates)
var seenIDs = Set<String>()
let uniqueItems = results.items.filter { seenIDs.insert($0.id).inserted }
searchResults = ChannelSearchPage(items: uniqueItems, nextPage: results.nextPage)
} catch {
// On error, show empty results
searchResults = .empty
}
isSearchLoading = false
}
private func loadMoreSearchResults() async {
guard let nextPage = searchResults.nextPage,
let appEnvironment,
let instance = instanceForSource(),
!isSearchLoading else {
return
}
isSearchLoading = true
do {
let moreResults = try await appEnvironment.contentService.channelSearch(
id: channelID,
query: searchText,
instance: instance,
page: nextPage
)
// Append new items, avoiding duplicates
let existingIDs = Set(searchResults.items.map(\.id))
let newItems = moreResults.items.filter { !existingIDs.contains($0.id) }
searchResults = ChannelSearchPage(
items: searchResults.items + newItems,
nextPage: newItems.isEmpty ? nil : moreResults.nextPage
)
} catch {
// On error, stop loading more
}
isSearchLoading = false
}
private func clearSearch() {
isSearchActive = false
hasSearched = false
searchResults = .empty
searchText = ""
}
// MARK: - Error View
private func errorView(_ error: String) -> some View {
ContentUnavailableView {
Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button(String(localized: "common.retry")) {
Task {
await loadChannel()
}
}
}
}
// MARK: - Helpers
private func formatSubscribers(_ count: Int) -> String {
let formatted = CountFormatter.compact(count)
return String(localized: "channel.subscriberCount \(formatted)")
}
/// Returns the appropriate instance for the channel's content source
private func instanceForSource() -> Instance? {
appEnvironment?.instancesManager.instance(for: source)
}
// MARK: - Data Loading
private func loadChannel() async {
if isExternalChannel {
await loadExternalChannel()
} else {
await loadRegularChannel()
}
}
private func loadRegularChannel() async {
guard let appEnvironment,
let instance = instanceForSource() else {
errorMessage = "No instances configured"
isLoading = false
return
}
isLoading = true
errorMessage = nil
// Load subscription state
subscription = appEnvironment.dataManager.subscription(for: channelID)
isSubscribed = subscription != nil
// Load cached header data for immediate display
cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)
// Fetch channel and videos independently to handle partial failures gracefully
async let channelTask: Result<Channel, Error> = await {
do {
return .success(try await appEnvironment.contentService.channel(id: channelID, instance: instance))
} catch {
return .failure(error)
}
}()
async let videosTask: Result<ChannelVideosPage, Error> = await {
do {
return .success(try await appEnvironment.contentService.channelVideos(id: channelID, instance: instance, continuation: nil))
} catch {
return .failure(error)
}
}()
let (channelResult, videosResult) = await (channelTask, videosTask)
await MainActor.run {
var channelAPIFailed = false
var videosAPIFailed = false
// Handle channel result
switch channelResult {
case .success(let loadedChannel):
channel = loadedChannel
// Update subscription metadata if subscribed
appEnvironment.dataManager.updateSubscription(for: channelID, with: loadedChannel)
// Save to recent channels (unless incognito mode is enabled or recent channels disabled)
if appEnvironment.settingsManager.incognitoModeEnabled != true,
appEnvironment.settingsManager.saveRecentChannels {
appEnvironment.dataManager.addRecentChannel(loadedChannel)
}
case .failure(let error):
channelAPIFailed = true
// Channel API failed - build a Channel from cachedHeader so the view can display it
if let cached = cachedHeader {
channel = Channel(
id: ChannelID(source: source, channelID: channelID),
name: cached.name,
description: nil,
subscriberCount: cached.subscriberCount,
thumbnailURL: cached.thumbnailURL,
bannerURL: cached.bannerURL
)
}
LoggingService.shared.error("[ChannelView] Channel API failed", category: .api, details: error.localizedDescription)
}
// Handle videos result
switch videosResult {
case .success(let videosPage):
videos = videosPage.videos
videosContinuation = videosPage.continuation
videosLoaded = true
case .failure(let error):
videosAPIFailed = true
LoggingService.shared.error("[ChannelView] Videos API failed", category: .api, details: error.localizedDescription)
}
// Only show error if both APIs failed and we have no cached data
if channelAPIFailed && videosAPIFailed && channel == nil {
errorMessage = String(localized: "channelView.loadError")
}
isLoading = false
}
}
private func loadExternalChannel() async {
guard let appEnvironment,
let url = channelURL else {
errorMessage = "Invalid channel URL"
isLoading = false
return
}
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
errorMessage = String(localized: "externalChannel.noYatteeServer")
isLoading = false
return
}
isLoading = true
errorMessage = nil
do {
let (fetchedChannel, fetchedVideos, fetchedContinuation) = try await appEnvironment.contentService.extractChannel(
url: url,
page: 1,
instance: instance
)
await MainActor.run {
channel = fetchedChannel
videos = fetchedVideos
videosContinuation = fetchedContinuation
externalCurrentPage = 1
videosLoaded = true
isLoading = false
// Check subscription status using extracted channel ID
subscription = appEnvironment.dataManager.subscription(for: fetchedChannel.id.channelID)
isSubscribed = subscription != nil
}
} catch let error as APIError {
await MainActor.run {
handleExternalChannelError(error)
isLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func handleExternalChannelError(_ error: APIError) {
switch error {
case .httpError(let statusCode, let message):
if statusCode == 422 {
errorMessage = message ?? "This site doesn't support channel extraction."
} else if statusCode == 400 {
errorMessage = message ?? "Invalid channel URL."
} else {
errorMessage = message ?? "Server error (\(statusCode))."
}
default:
errorMessage = error.localizedDescription
}
}
private func refreshSubscription() {
// Use loaded channel's ID if available (important for external channels
// where navigation channelID is the URL, not the actual channel ID)
let effectiveChannelID = channel?.id.channelID ?? channelID
subscription = appEnvironment?.dataManager.subscription(for: effectiveChannelID)
isSubscribed = subscription != nil
}
private func loadMoreVideos() async {
if isExternalChannel {
await loadMoreExternalVideos()
} else {
await loadMoreRegularVideos()
}
}
private func loadMoreRegularVideos() async {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = videosContinuation,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let result = try await appEnvironment.contentService.channelVideos(
id: channelID,
instance: instance,
continuation: continuation
)
await MainActor.run {
// Filter out duplicates before appending
let existingIDs = Set(videos.map(\.id))
let newVideos = result.videos.filter { !existingIDs.contains($0.id) }
videos.append(contentsOf: newVideos)
// Stop pagination if all returned videos are duplicates
videosContinuation = newVideos.isEmpty ? nil : result.continuation
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
private func loadMoreExternalVideos() async {
guard let appEnvironment,
let url = channelURL,
let instance = appEnvironment.instancesManager.yatteeServerInstance,
videosContinuation != nil,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let nextPage = externalCurrentPage + 1
let (_, fetchedVideos, fetchedContinuation) = try await appEnvironment.contentService.extractChannel(
url: url,
page: nextPage,
instance: instance
)
await MainActor.run {
videos.append(contentsOf: fetchedVideos)
videosContinuation = fetchedContinuation
externalCurrentPage = nextPage
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
/// Callback for loading more videos via continuation (used by VideoInfoView navigation)
@Sendable
private func loadMoreVideosCallback() async throws -> ([Video], String?) {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = videosContinuation else {
throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
}
let result = try await appEnvironment.contentService.channelVideos(
id: channelID,
instance: instance,
continuation: continuation
)
return (result.videos, result.continuation)
}
/// Callback for loading more shorts via continuation
@Sendable
private func loadMoreShortsCallback() async throws -> ([Video], String?) {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = shortsContinuation else {
throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
}
let result = try await appEnvironment.contentService.channelShorts(
id: channelID,
instance: instance,
continuation: continuation
)
return (result.videos, result.continuation)
}
/// Callback for loading more streams via continuation
@Sendable
private func loadMoreStreamsCallback() async throws -> ([Video], String?) {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = streamsContinuation else {
throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
}
let result = try await appEnvironment.contentService.channelStreams(
id: channelID,
instance: instance,
continuation: continuation
)
return (result.videos, result.continuation)
}
// MARK: - Tab Loading
private func loadTabContentIfNeeded(_ tab: ChannelTab) async {
switch tab {
case .about:
// No loading needed - description is already available
break
case .videos:
// Already loaded on initial load
break
case .playlists:
if !playlistsLoaded {
await loadPlaylists()
}
case .shorts:
if !shortsLoaded {
await loadShorts()
}
case .streams:
if !streamsLoaded {
await loadStreams()
}
}
}
// MARK: - Playlists Loading
private func loadPlaylists() async {
guard let appEnvironment,
let instance = instanceForSource() else {
return
}
do {
let result = try await appEnvironment.contentService.channelPlaylists(
id: channelID,
instance: instance,
continuation: nil
)
await MainActor.run {
playlists = result.playlists
playlistsContinuation = result.continuation
playlistsLoaded = true
}
} catch {
await MainActor.run {
playlistsLoaded = true
}
}
}
private func loadMorePlaylists() async {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = playlistsContinuation,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let result = try await appEnvironment.contentService.channelPlaylists(
id: channelID,
instance: instance,
continuation: continuation
)
await MainActor.run {
playlists.append(contentsOf: result.playlists)
playlistsContinuation = result.continuation
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
// MARK: - Shorts Loading
private func loadShorts() async {
guard let appEnvironment,
let instance = instanceForSource() else {
return
}
do {
let result = try await appEnvironment.contentService.channelShorts(
id: channelID,
instance: instance,
continuation: nil
)
await MainActor.run {
shorts = result.videos
shortsContinuation = result.continuation
shortsLoaded = true
}
} catch {
await MainActor.run {
shortsLoaded = true
}
}
}
private func loadMoreShorts() async {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = shortsContinuation,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let result = try await appEnvironment.contentService.channelShorts(
id: channelID,
instance: instance,
continuation: continuation
)
await MainActor.run {
// Filter out duplicates before appending
let existingIDs = Set(shorts.map(\.id))
let newShorts = result.videos.filter { !existingIDs.contains($0.id) }
shorts.append(contentsOf: newShorts)
// Stop pagination if all returned videos are duplicates
shortsContinuation = newShorts.isEmpty ? nil : result.continuation
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
// MARK: - Streams Loading
private func loadStreams() async {
guard let appEnvironment,
let instance = instanceForSource() else {
return
}
do {
let result = try await appEnvironment.contentService.channelStreams(
id: channelID,
instance: instance,
continuation: nil
)
await MainActor.run {
streams = result.videos
streamsContinuation = result.continuation
streamsLoaded = true
}
} catch {
await MainActor.run {
streamsLoaded = true
}
}
}
private func loadMoreStreams() async {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = streamsContinuation,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let result = try await appEnvironment.contentService.channelStreams(
id: channelID,
instance: instance,
continuation: continuation
)
await MainActor.run {
// Filter out duplicates before appending
let existingIDs = Set(streams.map(\.id))
let newStreams = result.videos.filter { !existingIDs.contains($0.id) }
streams.append(contentsOf: newStreams)
// Stop pagination if all returned videos are duplicates
streamsContinuation = newStreams.isEmpty ? nil : result.continuation
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
}
// MARK: - Scroll Offset Tracking Modifier
/// Tracks scroll offset for header zoom/scale effect
private struct ChannelScrollOffsetModifier: ViewModifier {
@Binding var scrollOffset: CGFloat
var isPlayerExpanded: Bool
func body(content: Content) -> some View {
content
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { _, newValue in
// Skip updates when player is expanded to avoid multiple updates per frame
guard !isPlayerExpanded else { return }
if scrollOffset != newValue {
scrollOffset = newValue
}
}
}
}
// MARK: - Background Extension Modifier
/// Applies backgroundExtensionEffect on iOS 26+ for Liquid Glass sidebar (iPad/Mac only)
private struct BackgroundExtensionModifier: ViewModifier {
let useBackgroundExtension: Bool
func body(content: Content) -> some View {
if useBackgroundExtension {
if #available(iOS 26, macOS 26, tvOS 26, *) {
content.backgroundExtensionEffect()
} else {
content
}
} else {
content
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
ChannelView(channelID: "UCxxxxxx", source: .global(provider: ContentSource.youtubeProvider))
}
.appEnvironment(.preview)
}