mirror of
https://github.com/yattee/yattee.git
synced 2024-11-10 00:08:21 +00:00
e35f8b7892
This gives a huge increase in perceived performance. The UI is now much more responsive since some tasks are run in the background and don't block the UI anymore.
505 lines
17 KiB
Swift
505 lines
17 KiB
Swift
import Defaults
|
|
import Siesta
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
struct FavoriteItemView: View {
|
|
var item: FavoriteItem
|
|
|
|
@Environment(\.navigationStyle) private var navigationStyle
|
|
@StateObject private var store = FavoriteResourceObserver()
|
|
|
|
@ObservedObject private var accounts = AccountsModel.shared
|
|
private var playlists = PlaylistsModel.shared
|
|
private var favoritesModel = FavoritesModel.shared
|
|
private var navigation = NavigationModel.shared
|
|
@ObservedObject private var player = PlayerModel.shared
|
|
@ObservedObject private var watchModel = WatchModel.shared
|
|
|
|
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
|
var watches: FetchedResults<Watch>
|
|
@State private var visibleWatches = [Watch]()
|
|
|
|
@Default(.hideShorts) private var hideShorts
|
|
@Default(.hideWatched) private var hideWatched
|
|
@Default(.widgetsSettings) private var widgetsSettings
|
|
@Default(.visibleSections) private var visibleSections
|
|
|
|
init(item: FavoriteItem) {
|
|
self.item = item
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isVisible {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
itemControl
|
|
.contextMenu { contextMenu }
|
|
.contentShape(Rectangle())
|
|
#if os(tvOS)
|
|
.padding(.leading, 40)
|
|
#else
|
|
.padding(.leading, 15)
|
|
#endif
|
|
|
|
if limitedItems.isEmpty, !(resource?.isLoading ?? false) {
|
|
VStack(alignment: .leading) {
|
|
Text(emptyItemsText)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.foregroundColor(.secondary)
|
|
|
|
if hideShorts || hideWatched {
|
|
AccentButton(text: "Disable filters", maxWidth: nil, verticalPadding: 0, minHeight: 30) {
|
|
hideShorts = false
|
|
hideWatched = false
|
|
reloadVisibleWatches()
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 10)
|
|
#if os(tvOS)
|
|
.padding(.horizontal, 40)
|
|
#else
|
|
.padding(.horizontal, 15)
|
|
#endif
|
|
} else {
|
|
Group {
|
|
switch widgetListingStyle {
|
|
case .horizontalCells:
|
|
HorizontalCells(items: limitedItems)
|
|
case .list:
|
|
ListView(items: limitedItems)
|
|
.padding(.vertical, 10)
|
|
#if os(tvOS)
|
|
.padding(.leading, 40)
|
|
#else
|
|
.padding(.horizontal, 15)
|
|
#endif
|
|
}
|
|
}
|
|
.environment(\.inChannelView, inChannelView)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onAppear {
|
|
if item.section == .history {
|
|
reloadVisibleWatches()
|
|
} else {
|
|
resource?.addObserver(store)
|
|
loadCacheAndResource()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
resource?.removeObservers(ownedBy: store)
|
|
}
|
|
.onChange(of: player.currentVideo) { _ in reloadVisibleWatches() }
|
|
.onChange(of: hideShorts) { _ in reloadVisibleWatches() }
|
|
.onChange(of: hideWatched) { _ in reloadVisibleWatches() }
|
|
}
|
|
}
|
|
.id(watchModel.historyToken)
|
|
.onChange(of: accounts.current) { _ in
|
|
resource?.removeObservers(ownedBy: store)
|
|
resource?.addObserver(store)
|
|
loadCacheAndResource(force: true)
|
|
}
|
|
.onChange(of: watchModel.historyToken) { _ in
|
|
reloadVisibleWatches()
|
|
}
|
|
}
|
|
|
|
var emptyItemsText: String {
|
|
var filterText = ""
|
|
if hideShorts && hideWatched {
|
|
filterText = "(watched and shorts hidden)"
|
|
} else if hideShorts {
|
|
filterText = "(shorts hidden)"
|
|
} else if hideWatched {
|
|
filterText = "(watched hidden)"
|
|
}
|
|
|
|
return "No videos to show".localized() + " " + filterText.localized()
|
|
}
|
|
|
|
var contextMenu: some View {
|
|
Group {
|
|
if item.section == .history {
|
|
Section {
|
|
Button {
|
|
navigation.presentAlert(
|
|
Alert(
|
|
title: Text("Are you sure you want to clear history of watched videos?"),
|
|
message: Text("This cannot be reverted"),
|
|
primaryButton: .destructive(Text("Clear All")) {
|
|
PlayerModel.shared.removeHistory()
|
|
visibleWatches = []
|
|
},
|
|
secondaryButton: .cancel()
|
|
)
|
|
)
|
|
} label: {
|
|
Label("Clear History", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
favoritesModel.remove(item)
|
|
} label: {
|
|
Label("Remove from Favorites", systemImage: "trash")
|
|
}
|
|
|
|
#if os(tvOS)
|
|
Button("Cancel", role: .cancel) {}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
func reloadVisibleWatches() {
|
|
guard item.section == .history else { return }
|
|
|
|
visibleWatches = []
|
|
|
|
let watches = Array(
|
|
watches
|
|
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
|
|
.prefix(favoritesModel.limit(item))
|
|
)
|
|
let last = watches.last
|
|
|
|
for watch in watches {
|
|
player.loadHistoryVideoDetails(watch) {
|
|
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
|
|
visibleWatches.append(watch)
|
|
|
|
if watch == last {
|
|
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var limitedItems: [ContentItem] {
|
|
var items: [ContentItem]
|
|
if item.section == .history {
|
|
items = visibleWatches.map { ContentItem(video: player.historyVideo($0.videoID) ?? $0.video) }
|
|
} else {
|
|
items = store.contentItems.filter { itemVisible($0) }
|
|
}
|
|
return Array(items.prefix(favoritesModel.limit(item)))
|
|
}
|
|
|
|
func itemVisible(_ item: ContentItem) -> Bool {
|
|
if hideWatched, watch(item)?.finished ?? false {
|
|
return false
|
|
}
|
|
|
|
guard hideShorts, item.contentType == .video, let video = item.video else {
|
|
return true
|
|
}
|
|
|
|
return !video.short
|
|
}
|
|
|
|
func watch(_ item: ContentItem) -> Watch? {
|
|
guard let id = item.video?.videoID else { return nil }
|
|
return watches.first { $0.videoID == id }
|
|
}
|
|
|
|
var widgetListingStyle: WidgetListingStyle {
|
|
favoritesModel.listingStyle(item)
|
|
}
|
|
|
|
func loadCacheAndResource(force: Bool = false) {
|
|
guard let resource else { return }
|
|
|
|
var onSuccess: (Entity<Any>) -> Void = { _ in }
|
|
var contentItems = [ContentItem]()
|
|
|
|
switch item.section {
|
|
case .subscriptions:
|
|
let feed = FeedCacheModel.shared.retrieveFeed(account: accounts.current)
|
|
contentItems = ContentItem.array(of: feed)
|
|
|
|
onSuccess = { response in
|
|
if let videos: [Video] = response.typedContent() {
|
|
FeedCacheModel.shared.storeFeed(account: accounts.current, videos: videos)
|
|
}
|
|
}
|
|
case let .channel(_, id, name):
|
|
var channel = Channel(app: .invidious, id: id, name: name)
|
|
if let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey),
|
|
let cacheChannel = cache.channel,
|
|
!cacheChannel.videos.isEmpty
|
|
{
|
|
contentItems = ContentItem.array(of: cacheChannel.videos)
|
|
}
|
|
|
|
onSuccess = { response in
|
|
if let channel: Channel = response.typedContent() {
|
|
ChannelsCacheModel.shared.store(channel)
|
|
store.contentItems = ContentItem.array(of: channel.videos)
|
|
} else if let videos: [Video] = response.typedContent() {
|
|
channel.videos = videos
|
|
ChannelsCacheModel.shared.store(channel)
|
|
store.contentItems = ContentItem.array(of: videos)
|
|
} else if let channelPage: ChannelPage = response.typedContent() {
|
|
if let channel = channelPage.channel {
|
|
ChannelsCacheModel.shared.store(channel)
|
|
}
|
|
|
|
store.contentItems = channelPage.results
|
|
}
|
|
}
|
|
case let .channelPlaylist(_, id, title):
|
|
if let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(.init(id: id, title: title)),
|
|
!cache.videos.isEmpty
|
|
{
|
|
contentItems = ContentItem.array(of: cache.videos)
|
|
}
|
|
|
|
onSuccess = { response in
|
|
if let playlist: ChannelPlaylist = response.typedContent() {
|
|
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist)
|
|
}
|
|
}
|
|
case let .playlist(_, id):
|
|
let playlists = PlaylistsCacheModel.shared.retrievePlaylists(account: accounts.current)
|
|
|
|
if let playlist = playlists.first(where: { $0.id == id }) {
|
|
contentItems = ContentItem.array(of: playlist.videos)
|
|
}
|
|
default:
|
|
contentItems = []
|
|
}
|
|
|
|
if !contentItems.isEmpty {
|
|
store.contentItems = contentItems
|
|
}
|
|
|
|
if force {
|
|
resource.load().onSuccess(onSuccess)
|
|
} else {
|
|
resource.loadIfNeeded()?.onSuccess(onSuccess)
|
|
}
|
|
}
|
|
|
|
var navigatableItem: Bool {
|
|
switch item.section {
|
|
case .history:
|
|
return false
|
|
case .trending:
|
|
return visibleSections.contains(.trending)
|
|
case .subscriptions:
|
|
return visibleSections.contains(.subscriptions) && accounts.signedIn
|
|
case .popular:
|
|
return visibleSections.contains(.popular) && accounts.app.supportsPopular
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
var inChannelView: Bool {
|
|
switch item.section {
|
|
case .channel:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var itemControl: some View {
|
|
VStack {
|
|
if navigatableItem {
|
|
#if os(tvOS)
|
|
itemButton
|
|
#else
|
|
if itemIsNavigationLink {
|
|
itemNavigationLink
|
|
} else {
|
|
itemButton
|
|
}
|
|
#endif
|
|
} else {
|
|
itemLabel
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
var itemButton: some View {
|
|
Button(action: itemButtonAction) {
|
|
itemLabel
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
#if !os(tvOS)
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
}
|
|
|
|
var itemNavigationLink: some View {
|
|
NavigationLink(destination: itemNavigationLinkDestination) {
|
|
itemLabel
|
|
}
|
|
}
|
|
|
|
var itemIsNavigationLink: Bool {
|
|
switch item.section {
|
|
case .channel:
|
|
return navigationStyle == .tab
|
|
case .channelPlaylist:
|
|
return navigationStyle == .tab
|
|
case .playlist:
|
|
return navigationStyle == .tab
|
|
case .subscriptions:
|
|
return navigationStyle == .tab
|
|
case .popular:
|
|
return navigationStyle == .tab
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
@ViewBuilder var itemNavigationLinkDestination: some View {
|
|
switch item.section {
|
|
case let .channel(_, id, name):
|
|
ChannelVideosView(channel: .init(app: .invidious, id: id, name: name))
|
|
case let .channelPlaylist(_, id, title):
|
|
ChannelPlaylistView(playlist: .init(id: id, title: title))
|
|
case let .playlist(_, id):
|
|
ChannelPlaylistView(playlist: .init(id: id, title: label))
|
|
case .subscriptions:
|
|
SubscriptionsView()
|
|
case .popular:
|
|
PopularView()
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
func itemButtonAction() {
|
|
switch item.section {
|
|
case let .channel(_, id, name):
|
|
NavigationModel.shared.openChannel(.init(app: .invidious, id: id, name: name), navigationStyle: navigationStyle)
|
|
case let .channelPlaylist(_, id, title):
|
|
NavigationModel.shared.openChannelPlaylist(.init(id: id, title: title), navigationStyle: navigationStyle)
|
|
case .subscriptions:
|
|
navigation.hideViewsAboveBrowser()
|
|
navigation.tabSelection = .subscriptions
|
|
case .popular:
|
|
navigation.hideViewsAboveBrowser()
|
|
navigation.tabSelection = .popular
|
|
case let .trending(country, category):
|
|
navigation.hideViewsAboveBrowser()
|
|
Defaults[.trendingCountry] = .init(rawValue: country) ?? .us
|
|
Defaults[.trendingCategory] = category.isNil ? .default : (.init(rawValue: category!) ?? .default)
|
|
navigation.tabSelection = .trending
|
|
case let .searchQuery(text, _, _, _):
|
|
navigation.hideViewsAboveBrowser()
|
|
navigation.openSearchQuery(text)
|
|
case let .playlist(_, id):
|
|
navigation.tabSelection = .playlist(id)
|
|
case .history:
|
|
print("should not happen")
|
|
}
|
|
}
|
|
|
|
var itemLabel: some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.title3.bold())
|
|
if navigatableItem {
|
|
Image(systemName: "chevron.right")
|
|
.imageScale(.small)
|
|
}
|
|
}
|
|
.lineLimit(1)
|
|
.padding(.trailing, 10)
|
|
}
|
|
|
|
private var isVisible: Bool {
|
|
switch item.section {
|
|
case .subscriptions:
|
|
return accounts.app.supportsSubscriptions && !accounts.isEmpty && !accounts.current.anonymous
|
|
case .popular:
|
|
return accounts.app.supportsPopular
|
|
case let .channel(appType, _, _):
|
|
guard let appType = VideosApp.AppType(rawValue: appType) else { return false }
|
|
return accounts.app.appType == appType
|
|
case let .channelPlaylist(appType, _, _):
|
|
guard let appType = VideosApp.AppType(rawValue: appType) else { return false }
|
|
return accounts.app.appType == appType
|
|
case let .playlist(accountID, _):
|
|
return accounts.current?.id == accountID
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var resource: Resource? {
|
|
switch item.section {
|
|
case .history:
|
|
return nil
|
|
case .subscriptions:
|
|
if accounts.app.supportsSubscriptions {
|
|
return accounts.api.feed(1)
|
|
}
|
|
|
|
case .popular:
|
|
if accounts.app.supportsPopular {
|
|
return accounts.api.popular
|
|
}
|
|
|
|
case let .trending(country, category):
|
|
let trendingCountry = Country(rawValue: country)!
|
|
let trendingCategory = category.isNil ? nil : TrendingCategory(rawValue: category!)
|
|
|
|
return accounts.api.trending(country: trendingCountry, category: trendingCategory)
|
|
|
|
case let .channel(_, id, _):
|
|
return accounts.api.channelVideos(id)
|
|
|
|
case let .channelPlaylist(_, id, _):
|
|
return accounts.api.channelPlaylist(id)
|
|
|
|
case let .playlist(_, id):
|
|
return accounts.api.playlist(id)
|
|
|
|
case let .searchQuery(text, date, duration, order):
|
|
return accounts.api.search(
|
|
.init(
|
|
query: text,
|
|
sortBy: SearchQuery.SortOrder(rawValue: order) ?? .uploadDate,
|
|
date: SearchQuery.Date(rawValue: date),
|
|
duration: SearchQuery.Duration(rawValue: duration)
|
|
),
|
|
page: nil
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private var label: String {
|
|
switch item.section {
|
|
case let .playlist(_, id):
|
|
return playlists.find(id: id)?.title ?? "Playlist".localized()
|
|
default:
|
|
return item.section.label.localized()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FavoriteItemView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
NavigationView {
|
|
VStack {
|
|
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Search: resistance body upper band workout")))
|
|
.environment(\.navigationStyle, .tab)
|
|
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Marques")))
|
|
.environment(\.navigationStyle, .sidebar)
|
|
}
|
|
}
|
|
}
|
|
}
|