Managing Favorites sections

This commit is contained in:
Arkadiusz Fal
2021-11-01 22:56:18 +01:00
parent f11125a399
commit 8df452752a
35 changed files with 665 additions and 203 deletions

View File

@@ -35,6 +35,10 @@ extension Defaults.Keys {
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
static let favorites = Key<[FavoriteItem]>("favorites", default: [
.init(section: .trending("US", nil))
])
static let quality = Key<Stream.ResolutionSetting>("quality", default: .hd720pFirstThenBest)
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])

View File

@@ -0,0 +1,35 @@
import Foundation
import SwiftUI
struct DropFavorite: DropDelegate {
let item: FavoriteItem
@Binding var favorites: [FavoriteItem]
@Binding var current: FavoriteItem?
func dropEntered(info _: DropInfo) {
guard item != current else {
return
}
let from = favorites.firstIndex(of: current!)!
let to = favorites.firstIndex(of: item)!
guard favorites[to].id != current!.id else {
return
}
favorites.move(
fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to
)
}
func dropUpdated(info _: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info _: DropInfo) -> Bool {
current = nil
return true
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
import SwiftUI
struct DropFavoriteOutside: DropDelegate {
@Binding var current: FavoriteItem?
func performDrop(info _: DropInfo) -> Bool {
current = nil
return true
}
}

View File

@@ -0,0 +1,93 @@
import Defaults
import Siesta
import SwiftUI
import UniformTypeIdentifiers
final class FavoriteResourceObserver: ObservableObject, ResourceObserver {
@Published var videos = [Video]()
func resourceChanged(_ resource: Resource, event _: ResourceEvent) {
if let videos: [Video] = resource.typedContent() {
self.videos = videos
} else if let channel: Channel = resource.typedContent() {
videos = channel.videos
} else if let playlist: ChannelPlaylist = resource.typedContent() {
videos = playlist.videos
} else if let playlist: Playlist = resource.typedContent() {
videos = playlist.videos
}
}
}
struct FavoriteItemView: View {
let item: FavoriteItem
let resource: Resource?
@StateObject private var store = FavoriteResourceObserver()
@Binding private var favorites: [FavoriteItem]
@Binding private var dragging: FavoriteItem?
@EnvironmentObject<PlaylistsModel> private var playlistsModel
init(
item: FavoriteItem,
resource: Resource?,
favorites: Binding<[FavoriteItem]>,
dragging: Binding<FavoriteItem?>
) {
self.item = item
self.resource = resource
_favorites = favorites
_dragging = dragging
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.title3.bold())
.foregroundColor(.secondary)
.contextMenu {
Button {
FavoritesModel.shared.remove(item)
} label: {
Label("Remove from Favorites", systemImage: "trash")
}
}
.contentShape(Rectangle())
#if os(tvOS)
.padding(.leading, 40)
#else
.padding(.leading, 15)
#endif
HorizontalCells(items: store.videos.map { ContentItem(video: $0) })
}
.contentShape(Rectangle())
.opacity(dragging?.id == item.id ? 0.5 : 1)
.onAppear {
resource?.addObserver(store)
resource?.loadIfNeeded()
}
#if !os(tvOS)
.onDrag {
dragging = item
return NSItemProvider(object: item.id as NSString)
}
.onDrop(
of: [UTType.text],
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
)
#endif
}
var label: String {
if case let .playlist(id) = item.section {
return playlistsModel.find(id: id)?.title ?? "Unknown Playlist"
}
return item.section.label
}
}

View File

@@ -0,0 +1,91 @@
import Defaults
import Siesta
import SwiftUI
import UniformTypeIdentifiers
struct FavoritesView: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlaylistsModel> private var playlists
@State private var dragging: FavoriteItem?
@State private var presentingEditFavorites = false
@Default(.favorites) private var favorites
var body: some View {
PlayerControlsView {
ScrollView(.vertical, showsIndicators: false) {
if !accounts.current.isNil {
VStack(alignment: .leading, spacing: 0) {
ForEach(favorites) { item in
VStack {
if let resource = resource(item) {
FavoriteItemView(item: item, resource: resource, favorites: $favorites, dragging: $dragging)
}
}
}
}
#if os(tvOS)
Button {
presentingEditFavorites = true
} label: {
Text("Edit Favorites...")
}
#endif
}
}
#if os(tvOS)
.sheet(isPresented: $presentingEditFavorites) {
EditFavorites()
}
.edgesIgnoringSafeArea(.horizontal)
#else
.onDrop(of: [UTType.text], delegate: DropFavoriteOutside(current: $dragging))
.navigationTitle("Favorites")
#endif
#if os(macOS)
.background()
.frame(minWidth: 360)
#endif
}
}
func resource(_ item: FavoriteItem) -> Resource? {
switch item.section {
case .subscriptions:
if accounts.app.supportsSubscriptions {
return accounts.api.feed
}
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)
}
return nil
}
}
struct Favorites_Previews: PreviewProvider {
static var previews: some View {
FavoritesView()
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -11,14 +11,14 @@ struct AppTabNavigation: View {
var body: some View {
TabView(selection: navigation.tabSelectionBinding) {
NavigationView {
LazyView(WatchNowView())
LazyView(FavoritesView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Watch Now", systemImage: "play.circle")
.accessibility(label: Text("Subscriptions"))
Label("Favorites", systemImage: "heart")
.accessibility(label: Text("Favorites"))
}
.tag(TabSelection.watchNow)
.tag(TabSelection.favorites)
if accounts.app.supportsSubscriptions {
NavigationView {

View File

@@ -28,9 +28,9 @@ struct Sidebar: View {
var mainNavigationLinks: some View {
Section("Videos") {
NavigationLink(destination: LazyView(WatchNowView()), tag: TabSelection.watchNow, selection: $navigation.tabSelection) {
Label("Watch Now", systemImage: "play.circle")
.accessibility(label: Text("Watch Now"))
NavigationLink(destination: LazyView(FavoritesView()), tag: TabSelection.favorites, selection: $navigation.tabSelection) {
Label("Favorites", systemImage: "heart")
.accessibility(label: Text("Favorites"))
}
if accounts.app.supportsSubscriptions && accounts.signedIn {
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {

View File

@@ -13,13 +13,16 @@ struct VideoPlayerView: View {
#endif
}
@State private var playerSize: CGSize = .zero
@State private var fullScreen = false
#if os(iOS)
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
private var idiom: UIUserInterfaceIdiom {
UIDevice.current.userInterfaceIdiom
}
#endif
@EnvironmentObject<PlayerModel> private var player
@@ -75,12 +78,6 @@ struct VideoPlayerView: View {
#endif
.background(.black)
.onAppear {
self.playerSize = geometry.size
}
.onChange(of: geometry.size) { size in
self.playerSize = size
}
Group {
#if os(iOS)
@@ -134,7 +131,7 @@ struct VideoPlayerView: View {
#if os(iOS)
var sidebarQueue: Bool {
horizontalSizeClass == .regular && playerSize.width > 750
horizontalSizeClass == .regular && idiom == .pad
}
var sidebarQueueBinding: Binding<Bool> {

View File

@@ -75,6 +75,8 @@ struct PlaylistsView: View {
editPlaylistButton
}
#endif
FavoriteButton(item: FavoriteItem(section: .playlist(selectedPlaylistID)))
newPlaylistButton
}
@@ -139,6 +141,11 @@ struct PlaylistsView: View {
editPlaylistButton
}
if let playlist = currentPlaylist {
FavoriteButton(item: FavoriteItem(section: .playlist(playlist.id)))
.labelStyle(.iconOnly)
}
Spacer()
newPlaylistButton

View File

@@ -95,6 +95,8 @@ struct ServicesSettings: View {
struct ServicesSettings_Previews: PreviewProvider {
static var previews: some View {
ServicesSettings()
VStack {
ServicesSettings()
}
}
}

View File

@@ -11,9 +11,11 @@ struct TrendingView: View {
@State private var presentingCountrySelection = false
@State private var favoriteItem: FavoriteItem?
@EnvironmentObject<AccountsModel> private var accounts
var popular: [ContentItem] {
var trending: [ContentItem] {
ContentItem.array(of: store.collection)
}
@@ -36,12 +38,12 @@ struct TrendingView: View {
VStack(alignment: .center, spacing: 0) {
#if os(tvOS)
toolbar
HorizontalCells(items: popular)
HorizontalCells(items: trending)
.padding(.top, 40)
Spacer()
#else
VerticalCells(items: popular)
VerticalCells(items: trending)
#endif
}
}
@@ -62,6 +64,11 @@ struct TrendingView: View {
.toolbar {
#if os(macOS)
ToolbarItemGroup {
if let favoriteItem = favoriteItem {
FavoriteButton(item: favoriteItem)
.id(favoriteItem.id)
}
if accounts.app.supportsTrendingCategories {
categoryButton
}
@@ -70,8 +77,8 @@ struct TrendingView: View {
#elseif os(iOS)
ToolbarItemGroup(placement: .bottomBar) {
Group {
if accounts.app.supportsTrendingCategories {
HStack {
HStack {
if accounts.app.supportsTrendingCategories {
Text("Category")
.foregroundColor(.secondary)
@@ -80,7 +87,14 @@ struct TrendingView: View {
// force redraw of the view when it changes
.id(UUID())
}
} else {
}
Spacer()
if let favoriteItem = favoriteItem {
FavoriteButton(item: favoriteItem)
.id(favoriteItem.id)
Spacer()
}
@@ -96,6 +110,7 @@ struct TrendingView: View {
}
.onChange(of: resource) { _ in
resource.load()
updateFavoriteItem()
}
.onAppear {
if videos.isEmpty {
@@ -104,10 +119,12 @@ struct TrendingView: View {
} else {
store.replace(videos)
}
updateFavoriteItem()
}
}
var toolbar: some View {
private var toolbar: some View {
HStack {
if accounts.app.supportsTrendingCategories {
HStack {
@@ -128,17 +145,25 @@ struct TrendingView: View {
countryButton
}
#if os(tvOS)
if let favoriteItem = favoriteItem {
FavoriteButton(item: favoriteItem)
.id(favoriteItem.id)
.labelStyle(.iconOnly)
}
#endif
}
}
var categoryButton: some View {
private var categoryButton: some View {
#if os(tvOS)
Button(category.name) {
self.category = category.next()
}
.contextMenu {
ForEach(TrendingCategory.allCases) { category in
Button(category.name) { self.category = category }
Button(category.controlLabel) { self.category = category }
}
Button("Cancel", role: .cancel) {}
@@ -147,13 +172,13 @@ struct TrendingView: View {
#else
Picker("Category", selection: $category) {
ForEach(TrendingCategory.allCases) { category in
Text(category.name).tag(category)
Text(category.controlLabel).tag(category)
}
}
#endif
}
var countryButton: some View {
private var countryButton: some View {
Button(action: {
presentingCountrySelection.toggle()
resource.removeObservers(ownedBy: store)
@@ -161,6 +186,10 @@ struct TrendingView: View {
Text("\(country.flag) \(country.id)")
}
}
private func updateFavoriteItem() {
favoriteItem = FavoriteItem(section: .trending(country.rawValue, category.rawValue))
}
}
struct TrendingView_Previews: PreviewProvider {

View File

@@ -143,24 +143,18 @@ struct VideoCell: View {
#endif
.padding(.bottom, 4)
Group {
if additionalDetailsAvailable {
HStack(spacing: 8) {
if let date = video.publishedDate {
Image(systemName: "calendar")
Text(date)
}
HStack(spacing: 8) {
if let date = video.publishedDate {
Image(systemName: "calendar")
Text(date)
}
if video.views > 0 {
Image(systemName: "eye")
Text(video.viewsCount!)
}
}
.foregroundColor(.secondary)
} else {
Spacer()
if video.views > 0 {
Image(systemName: "eye")
Text(video.viewsCount!)
}
}
.foregroundColor(.secondary)
.frame(minHeight: 30, alignment: .top)
#if os(tvOS)
.padding(.bottom, 10)

View File

@@ -40,9 +40,16 @@ struct ChannelPlaylistView: View {
var content: some View {
VStack(alignment: .leading) {
#if os(tvOS)
Text(playlist.title)
.font(.title2)
.frame(alignment: .leading)
HStack {
Text(playlist.title)
.font(.title2)
.frame(alignment: .leading)
Spacer()
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
.labelStyle(.iconOnly)
}
#endif
VerticalCells(items: items)
}
@@ -66,12 +73,8 @@ struct ChannelPlaylistView: View {
)
}
ToolbarItem(placement: .cancellationAction) {
if inNavigationView {
Button("Done") {
dismiss()
}
}
ToolbarItem {
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
}
}
.navigationTitle(playlist.title)

View File

@@ -51,6 +51,9 @@ struct ChannelVideosView: View {
Spacer()
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
.labelStyle(.iconOnly)
if let subscribers = store.item?.subscriptionsString {
Text("**\(subscribers)** subscribers")
.foregroundColor(.secondary)
@@ -87,14 +90,8 @@ struct ChannelVideosView: View {
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
subscriptionToggleButton
}
}
ToolbarItem(placement: .cancellationAction) {
if inNavigationView {
Button("Done") {
dismiss()
}
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
}
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
import SwiftUI
struct FavoriteButton: View {
let item: FavoriteItem
let favorites = FavoritesModel.shared
@State private var isFavorite = false
var body: some View {
Button {
favorites.toggle(item)
isFavorite.toggle()
} label: {
if isFavorite {
Label("Remove from Favorites", systemImage: "heart.fill")
} else {
Label("Add to Favorites", systemImage: "heart")
}
}
.onAppear {
isFavorite = favorites.contains(item)
}
}
}

View File

@@ -19,5 +19,10 @@ struct PlaylistVideosView: View {
.navigationTitle("\(playlist.title) Playlist")
#endif
}
.toolbar {
ToolbarItem {
FavoriteButton(item: FavoriteItem(section: .playlist(playlist.id)))
}
}
}
}

View File

@@ -25,5 +25,10 @@ struct PopularView: View {
.navigationTitle("Popular")
#endif
}
.toolbar {
ToolbarItem(placement: .automatic) {
FavoriteButton(item: FavoriteItem(section: .popular))
}
}
}
}

View File

@@ -26,6 +26,11 @@ struct SubscriptionsView: View {
}
}
}
.toolbar {
ToolbarItem(placement: .automatic) {
FavoriteButton(item: FavoriteItem(section: .subscriptions))
}
}
.refreshable {
loadResources(force: true)
}

View File

@@ -1,28 +0,0 @@
import Defaults
import Siesta
import SwiftUI
struct WatchNowSection: View {
let resource: Resource?
let label: String
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<AccountsModel> private var accounts
init(resource: Resource?, label: String) {
self.resource = resource
self.label = label
}
var body: some View {
WatchNowSectionBody(label: label, videos: store.collection)
.onAppear {
resource?.addObserver(store)
resource?.loadIfNeeded()
}
.onChange(of: accounts.current) { _ in
resource?.load()
}
}
}

View File

@@ -1,21 +0,0 @@
import SwiftUI
struct WatchNowSectionBody: View {
let label: String
let videos: [Video]
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.title3.bold())
.foregroundColor(.secondary)
#if os(tvOS)
.padding(.leading, 40)
#else
.padding(.leading, 15)
#endif
HorizontalCells(items: ContentItem.array(of: videos))
}
}
}

View File

@@ -1,51 +0,0 @@
import Defaults
import Siesta
import SwiftUI
struct WatchNowView: View {
@EnvironmentObject<AccountsModel> private var accounts
var body: some View {
PlayerControlsView {
ScrollView(.vertical, showsIndicators: false) {
if !accounts.current.isNil {
VStack(alignment: .leading, spacing: 0) {
if accounts.api.signedIn {
WatchNowSection(resource: accounts.api.feed, label: "Subscriptions")
}
if accounts.app.supportsPopular {
WatchNowSection(resource: accounts.api.popular, label: "Popular")
}
WatchNowSection(resource: accounts.api.trending(country: .pl, category: .default), label: "Trending")
if accounts.app.supportsTrendingCategories {
WatchNowSection(resource: accounts.api.trending(country: .pl, category: .movies), label: "Movies")
WatchNowSection(resource: accounts.api.trending(country: .pl, category: .music), label: "Music")
}
// TODO: adding sections to view
// ===================
// WatchNowPlaylistSection(id: "IVPLmRFYLGYZpq61SpujNw3EKbzzGNvoDmH")
// WatchNowSection(resource: api.channelVideos("UCBJycsmduvYEL83R_U4JriQ"), label: "MKBHD")
}
}
}
.id(UUID())
#if os(tvOS)
.edgesIgnoringSafeArea(.horizontal)
#else
.navigationTitle("Watch Now")
#endif
#if os(macOS)
.background()
.frame(minWidth: 360)
#endif
}
}
}
struct WatchNowView_Previews: PreviewProvider {
static var previews: some View {
WatchNowView()
.injectFixtureEnvironmentObjects()
}
}