Files
yattee/Yattee/Views/Channel/ChannelView.swift

2059 lines
73 KiB
Swift

//
// ChannelView.swift
// Yattee
//
// Channel view with zoom/scale header and video grid.
//
import SwiftUI
import NukeUI
/// Cached channel data for showing header immediately while loading.
private struct CachedChannelHeader {
let name: String
let thumbnailURL: URL?
let bannerURL: URL?
init(from subscription: Subscription) {
name = subscription.name
thumbnailURL = subscription.avatarURL
bannerURL = subscription.bannerURL
}
init(from recentChannel: RecentChannel) {
name = recentChannel.name
thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) }
bannerURL = nil // RecentChannel doesn't store banner
}
}
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: CachedChannelHeader?
// 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: CachedChannelHeader) -> some View {
GeometryReader { geometry in
ScrollView {
LazyVStack(spacing: 0) {
header(name: cached.name, thumbnailURL: cached.thumbnailURL, bannerURL: cached.bannerURL)
.id("channelTop")
// Centered spinner for content area
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
}
.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)
}
}
.modifier(ChannelScrollOffsetModifier(
scrollOffset: $scrollOffset,
isPlayerExpanded: appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
))
}
// 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
if let subscription {
cachedHeader = CachedChannelHeader(from: subscription)
} else if let recentChannel = appEnvironment.dataManager.recentChannelEntry(forChannelID: channelID) {
cachedHeader = CachedChannelHeader(from: recentChannel)
}
// 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: nil,
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)
}