import Defaults import SDWebImageSwiftUI import Siesta import SwiftUI struct ChannelVideosView: View { var channel: Channel? var showCloseButton = false var inNavigationView = true @State private var presentingShareSheet = false @State private var shareURL: URL? @State private var subscriptionToggleButtonDisabled = false @State private var page: ChannelPage? @State private var contentType = Channel.ContentType.videos @StateObject private var contentTypeItems = Store<[ContentItem]>() @State private var descriptionExpanded = false @StateObject private var store = Store() @Environment(\.colorScheme) private var colorScheme @ObservedObject private var accounts = AccountsModel.shared @ObservedObject private var feed = FeedModel.shared @ObservedObject private var navigation = NavigationModel.shared @ObservedObject private var recents = RecentsModel.shared @ObservedObject private var subscriptions = SubscribedChannelsModel.shared @Namespace private var focusNamespace @Default(.channelPlaylistListingStyle) private var channelPlaylistListingStyle @Default(.expandChannelDescription) private var expandChannelDescription var presentedChannel: Channel? { store.item?.channel ?? channel ?? recents.presentedChannel } var contentItems: [ContentItem] { contentTypeItems.collection } var body: some View { let content = VStack { #if os(tvOS) VStack { HStack(spacing: 24) { thumbnail Text(navigationTitle) .font(.headline) .frame(alignment: .leading) Spacer() subscriptionsLabel viewsLabel subscriptionToggleButton favoriteButton .labelStyle(.iconOnly) } contentTypePicker .pickerStyle(.automatic) } .frame(maxWidth: .infinity) #endif VerticalCells(items: contentItems, isLoading: resource?.isLoading ?? false, edgesIgnoringSafeArea: verticalCellsEdgesIgnoringSafeArea) { if let description = presentedChannel?.description, !description.isEmpty { Button { withAnimation(.spring()) { descriptionExpanded.toggle() } } label: { VStack(alignment: .leading) { banner ZStack(alignment: .topTrailing) { Text(description) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(descriptionExpanded ? 50 : 1) .multilineTextAlignment(.leading) #if os(tvOS) .foregroundColor(.primary) #else .foregroundColor(.secondary) #endif } } .padding(.bottom, 10) } .buttonStyle(.plain) } else { banner } } .environment(\.loadMoreContentHandler) { loadNextPage() } .environment(\.inChannelView, true) .environment(\.listingStyle, channelPlaylistListingStyle) #if os(tvOS) .prefersDefaultFocus(in: focusNamespace) #endif } #if !os(tvOS) .toolbar { #if os(iOS) ToolbarItem(placement: .principal) { channelMenu } #endif ToolbarItem(placement: .cancellationAction) { if showCloseButton { Button { withAnimation(Constants.overlayAnimation) { navigation.presentingChannel = false navigation.presentingChannelSheet = false } } label: { Label("Close", systemImage: "xmark") } #if !os(macOS) .buttonStyle(.plain) #endif } } #if os(macOS) ToolbarItem(placement: .navigation) { thumbnail } ToolbarItemGroup { if !inNavigationView { Text(navigationTitle) .fontWeight(.bold) } ListingStyleButtons(listingStyle: $channelPlaylistListingStyle) HideWatchedButtons() HideShortsButtons() contentTypePicker } ToolbarItemGroup { HStack(spacing: 3) { subscriptionsLabel viewsLabel } if let contentItem = presentedChannel?.contentItem { ShareButton(contentItem: contentItem) } subscriptionToggleButton .layoutPriority(2) favoriteButton .labelStyle(.iconOnly) toggleWatchedButton .labelStyle(.iconOnly) } #endif } #endif .onAppear { descriptionExpanded = expandChannelDescription if let channel, let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey), store.item.isNil { store.replace(cache) } load() } .onChange(of: contentType) { _ in load() } #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif #if !os(tvOS) .navigationTitle(navigationTitle) #endif return Group { if #available(macOS 12.0, *) { content #if os(tvOS) .background(Color.background(scheme: colorScheme)) #endif #if !os(iOS) .focusScope(focusNamespace) #endif } else { content } } } var verticalCellsEdgesIgnoringSafeArea: Edge.Set { #if os(tvOS) return .horizontal #else return .init() #endif } @ViewBuilder var favoriteButton: some View { if let presentedChannel { FavoriteButton(item: FavoriteItem(section: .channel(accounts.app.appType.rawValue, presentedChannel.id, presentedChannel.name))) } } var thumbnail: some View { ChannelAvatarView(channel: store.item?.channel) #if os(tvOS) .frame(width: 80, height: 80, alignment: .trailing) #else .frame(width: 30, height: 30, alignment: .trailing) #endif } @ViewBuilder var banner: some View { if let banner = presentedChannel?.bannerURL { WebImage(url: banner) .resizable() .placeholder { Color.clear.frame(height: 0) } .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 3)) } } var subscriptionsLabel: some View { Group { if let subscribers = store.item?.channel?.subscriptionsString { HStack(spacing: 0) { Image(systemName: "person.2.fill") Text(subscribers) } } else if store.item.isNil { HStack(spacing: 0) { Image(systemName: "person.2.fill") Text("1234") .redacted(reason: .placeholder) } } } .imageScale(.small) .foregroundColor(.secondary) } var viewsLabel: some View { HStack(spacing: 0) { if let views = store.item?.channel?.totalViewsString { Image(systemName: "eye.fill") .imageScale(.small) Text(views) } } .foregroundColor(.secondary) } #if !os(tvOS) var channelMenu: some View { Menu { if let channel = presentedChannel { contentTypePicker Section { subscriptionToggleButton FavoriteButton(item: FavoriteItem(section: .channel(accounts.app.appType.rawValue, channel.id, channel.name))) } if subscriptions.isSubscribing(channel.id) { toggleWatchedButton } ListingStyleButtons(listingStyle: $channelPlaylistListingStyle) Section { HideWatchedButtons() HideShortsButtons() } } } label: { HStack(spacing: 12) { thumbnail VStack(alignment: .leading) { Text(presentedChannel?.name ?? "Channel") .font(.headline) .foregroundColor(.primary) .layoutPriority(1) .frame(minWidth: 160, alignment: .leading) Group { HStack(spacing: 12) { subscriptionsLabel if presentedChannel?.verified ?? false { Image(systemName: "checkmark.seal.fill") .imageScale(.small) } viewsLabel } .frame(minWidth: 160, alignment: .leading) } .font(.caption2.bold()) .foregroundColor(.secondary) } Image(systemName: "chevron.down.circle.fill") .foregroundColor(.accentColor) .imageScale(.small) } .frame(maxWidth: 320) } } #endif private var contentTypePicker: some View { Picker("Content type", selection: $contentType) { if presentedChannel != nil { ForEach(Channel.ContentType.allCases, id: \.self) { type in if typeAvailable(type) { Label(type.description, systemImage: type.systemImage).tag(type) } } } } .labelsHidden() } private func typeAvailable(_ type: Channel.ContentType) -> Bool { type.alwaysAvailable || (presentedChannel?.hasData(for: type) ?? false) } private var resource: Resource? { guard let channel = presentedChannel else { return nil } let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil let resource = accounts.api.channel(channel.id, contentType: contentType, data: data) if contentType == .videos { resource.addObserver(store) } resource.addObserver(contentTypeItems) return resource } @ViewBuilder private var subscriptionToggleButton: some View { if let channel = presentedChannel { Group { if accounts.app.supportsSubscriptions && accounts.signedIn { if subscriptions.isSubscribing(channel.id) { Button { subscriptionToggleButtonDisabled = true subscriptions.unsubscribe(channel.id) { subscriptionToggleButtonDisabled = false } } label: { Label("Unsubscribe", systemImage: "xmark.circle") #if os(iOS) .labelStyle(.automatic) #else .labelStyle(.titleOnly) #endif } } else { Button { subscriptionToggleButtonDisabled = true subscriptions.subscribe(channel.id) { subscriptionToggleButtonDisabled = false navigation.sidebarSectionChanged.toggle() } } label: { Label("Subscribe", systemImage: "circle") #if os(iOS) .labelStyle(.automatic) #else .labelStyle(.titleOnly) #endif } } } } .disabled(subscriptionToggleButtonDisabled) } } private var navigationTitle: String { presentedChannel?.name ?? "No channel" } @ViewBuilder var toggleWatchedButton: some View { if let channel = presentedChannel { if feed.canMarkChannelAsWatched(channel.id) { markChannelAsWatchedButton } else { markChannelAsUnwatchedButton } } } var markChannelAsWatchedButton: some View { Button { guard let channel = presentedChannel else { return } feed.markChannelAsWatched(channel.id) } label: { Label("Mark channel feed as watched", systemImage: "checkmark.circle.fill") } .disabled(!feed.canMarkAllFeedAsWatched) } var markChannelAsUnwatchedButton: some View { Button { guard let channel = presentedChannel else { return } feed.markChannelAsUnwatched(channel.id) } label: { Label("Mark channel feed as unwatched", systemImage: "checkmark.circle") } } func load() { resource? .load() .onSuccess { response in if let page: ChannelPage = response.typedContent() { if let channel = page.channel { ChannelsCacheModel.shared.store(channel) } self.page = page self.contentTypeItems.replace(page.results) } } .onFailure { error in navigation.presentAlert(title: "Could not load channel data", message: error.userMessage) } } func loadNextPage() { guard let channel = presentedChannel, let pageToLoad = page, !pageToLoad.last else { return } var next = pageToLoad.nextPage if contentType == .videos, !pageToLoad.last { next = next ?? "" } let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil accounts.api.channel(channel.id, contentType: contentType, data: data, page: next).load().onSuccess { response in if let page: ChannelPage = response.typedContent() { self.page = page let keys = self.contentTypeItems.collection.map(\.cacheKey) let items = self.contentTypeItems.collection + page.results.filter { !keys.contains($0.cacheKey) } self.contentTypeItems.replace(items) } } } } struct ChannelVideosView_Previews: PreviewProvider { static var previews: some View { #if os(macOS) ChannelVideosView(channel: Video.fixture.channel, showCloseButton: true, inNavigationView: false) .environment(\.navigationStyle, .sidebar) #else NavigationView { ChannelVideosView(channel: Video.fixture.channel) } #endif } }