diff --git a/Apple TV/AddToPlaylistView.swift b/Apple TV/AddToPlaylistView.swift new file mode 100644 index 00000000..b8768f8e --- /dev/null +++ b/Apple TV/AddToPlaylistView.swift @@ -0,0 +1,97 @@ +import Defaults +import Siesta +import SwiftUI + +struct AddToPlaylistView: View { + @ObservedObject private var store = Store<[Playlist]>() + + @State private var selectedPlaylist: Playlist? + + @Default(.videoIDToAddToPlaylist) private var videoID + + @Environment(\.dismiss) private var dismiss + + var resource: Resource { + InvidiousAPI.shared.playlists + } + + init() { + resource.addObserver(store) + } + + var body: some View { + HStack { + Spacer() + + VStack { + Spacer() + + if !resource.isLoading && store.collection.isEmpty { + CoverSectionView("You have no Playlists", inline: true) { + Text("Open \"Playlists\" tab to create new one") + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + Button("Go back") { + dismiss() + } + .padding() + } else if !store.collection.isEmpty { + CoverSectionView("Add to Playlist", inline: true) { selectPlaylistButton } + + CoverSectionRowView { + Button("Add", action: addToPlaylist) + .disabled(currentPlaylist == nil) + } + } + + Spacer() + } + .frame(maxWidth: 1200) + + Spacer() + } + .background(.thinMaterial) + .onAppear { + resource.loadIfNeeded()?.onSuccess { _ in + selectedPlaylist = store.collection.first + } + } + } + + var selectPlaylistButton: some View { + Button(currentPlaylist?.title ?? "Select playlist") { + guard currentPlaylist != nil else { + return + } + + self.selectedPlaylist = store.collection.next(after: currentPlaylist!) + } + .contextMenu { + ForEach(store.collection) { playlist in + Button(playlist.title) { + self.selectedPlaylist = playlist + } + } + } + } + + var currentPlaylist: Playlist? { + selectedPlaylist ?? store.collection.first + } + + func addToPlaylist() { + guard currentPlaylist != nil else { + return + } + + let resource = InvidiousAPI.shared.playlistVideos(currentPlaylist!.id) + let body = ["videoId": videoID] + + resource.request(.post, json: body).onSuccess { _ in + Defaults.reset(.videoIDToAddToPlaylist) + InvidiousAPI.shared.playlists.load() + dismiss() + } + } +} diff --git a/Apple TV/PlaylistsView.swift b/Apple TV/PlaylistsView.swift index 1d48a532..ded0ec7e 100644 --- a/Apple TV/PlaylistsView.swift +++ b/Apple TV/PlaylistsView.swift @@ -6,7 +6,6 @@ struct PlaylistsView: View { @ObservedObject private var store = Store<[Playlist]>() @Default(.selectedPlaylistID) private var selectedPlaylistID - @State private var selectedPlaylist: Playlist? @State private var showingNewPlaylist = false @State private var createdPlaylist: Playlist? @@ -26,18 +25,37 @@ struct PlaylistsView: View { Section { VStack(alignment: .center, spacing: 2) { HStack { - selectPlaylistButton + if store.collection.isEmpty { + Text("No Playlists") + .foregroundColor(.secondary) + } else { + Text("Current Playlist") + .foregroundColor(.secondary) + + selectPlaylistButton + } if currentPlaylist != nil { editPlaylistButton } newPlaylistButton + .padding(.leading, 40) } .scaleEffect(0.85) if currentPlaylist != nil { - VideosView(videos: currentPlaylist!.videos) + if currentPlaylist!.videos.isEmpty { + Spacer() + + Text("Playlist is empty\n\nTap and hold on a video and then tap \"Add to Playlist\"") + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Spacer() + } else { + VideosView(videos: currentPlaylist!.videos) + } } else { Spacer() } @@ -57,7 +75,6 @@ struct PlaylistsView: View { } func selectPlaylist(_ id: String?) { - selectedPlaylist = store.collection.first { $0.id == id } selectedPlaylistID = id } @@ -86,7 +103,7 @@ struct PlaylistsView: View { } var currentPlaylist: Playlist? { - selectedPlaylist ?? store.collection.first + store.collection.first { $0.id == selectedPlaylistID } ?? store.collection.first } var selectPlaylistButton: some View { @@ -111,13 +128,19 @@ struct PlaylistsView: View { self.editedPlaylist = self.currentPlaylist self.showingEditPlaylist = true }) { - Image(systemName: "pencil") + HStack(spacing: 8) { + Image(systemName: "pencil") + Text("Edit") + } } } var newPlaylistButton: some View { Button(action: { self.showingNewPlaylist = true }) { - Image(systemName: "plus") + HStack(spacing: 8) { + Image(systemName: "plus") + Text("New Playlist") + } } } } diff --git a/Apple TV/TrendingView.swift b/Apple TV/TrendingView.swift index 7bc95a1e..5dfc1e95 100644 --- a/Apple TV/TrendingView.swift +++ b/Apple TV/TrendingView.swift @@ -20,7 +20,14 @@ struct TrendingView: View { Section { VStack(alignment: .center, spacing: 2) { HStack { + Text("Category") + .foregroundColor(.secondary) + categoryButton + + Text("Country") + .foregroundColor(.secondary) + countryFlag countryButton } diff --git a/Apple TV/VideoContextMenuView.swift b/Apple TV/VideoContextMenuView.swift index 59c82ffa..2f962775 100644 --- a/Apple TV/VideoContextMenuView.swift +++ b/Apple TV/VideoContextMenuView.swift @@ -9,6 +9,9 @@ struct VideoContextMenuView: View { @Default(.openVideoID) var openVideoID @Default(.showingVideoDetails) var showDetails + @Default(.showingAddToPlaylist) var showingAddToPlaylist + @Default(.videoIDToAddToPlaylist) var videoIDToAddToPlaylist + var body: some View { if tabSelection == .channel { closeChannelButton(from: video) @@ -16,9 +19,12 @@ struct VideoContextMenuView: View { openChannelButton(from: video) } - Button("Open video details") { - openVideoID = video.id - showDetails = true + openVideoDetailsButton + + if tabSelection == .playlists { + removeFromPlaylistButton + } else { + addToPlaylistButton } } @@ -34,4 +40,27 @@ struct VideoContextMenuView: View { Defaults.reset(.openChannel) } } + + var openVideoDetailsButton: some View { + Button("Open video details") { + openVideoID = video.id + showDetails = true + } + } + + var addToPlaylistButton: some View { + Button("Add to playlist...") { + videoIDToAddToPlaylist = video.id + showingAddToPlaylist = true + } + } + + var removeFromPlaylistButton: some View { + Button("Remove from playlist", role: .destructive) { + let resource = InvidiousAPI.shared.playlistVideo(Defaults[.selectedPlaylistID]!, video.indexID!) + resource.request(.delete).onSuccess { _ in + InvidiousAPI.shared.playlists.load() + } + } + } } diff --git a/Apple TV/VideoDetailsView.swift b/Apple TV/VideoDetailsView.swift index dbe4d5b1..208712a3 100644 --- a/Apple TV/VideoDetailsView.swift +++ b/Apple TV/VideoDetailsView.swift @@ -40,7 +40,7 @@ struct VideoDetailsView: View { HStack { NavigationLink(destination: PlayerView(id: video.id)) { - HStack(spacing: 10) { + HStack(spacing: 8) { Image(systemName: "play.rectangle.fill") Text("Play") diff --git a/Apple TV/VideosView.swift b/Apple TV/VideosView.swift index dd996b15..d72f3994 100644 --- a/Apple TV/VideosView.swift +++ b/Apple TV/VideosView.swift @@ -7,15 +7,20 @@ struct VideosView: View { @Default(.layout) var layout @Default(.tabSelection) var tabSelection + @Default(.showingAddToPlaylist) var showingAddToPlaylist + var videos: [Video] var body: some View { - Group { + VStack { if layout == .cells { VideosCellsView(videos: videos, columns: self.profile.cellsColumns) } else { VideosListView(videos: videos) } } + .fullScreenCover(isPresented: $showingAddToPlaylist) { + AddToPlaylistView() + } } } diff --git a/Model/InvidiousAPI.swift b/Model/InvidiousAPI.swift index a18ea055..5ef82e5f 100644 --- a/Model/InvidiousAPI.swift +++ b/Model/InvidiousAPI.swift @@ -112,6 +112,14 @@ final class InvidiousAPI: Service { resource("/auth/playlists/\(id)") } + func playlistVideos(_ id: String) -> Resource { + playlist(id).child("videos") + } + + func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource { + playlist(playlistID).child("videos").child(videoID) + } + func search(_ query: SearchQuery) -> Resource { var resource = resource("/search") .withParam("q", searchQuery(query.query)) diff --git a/Model/Playlist.swift b/Model/Playlist.swift index 40d348e9..c7bfbb3c 100644 --- a/Model/Playlist.swift +++ b/Model/Playlist.swift @@ -6,17 +6,20 @@ struct Playlist: Identifiable, Equatable, Hashable { var title: String var visibility: PlaylistVisibility + var updated: TimeInterval + var videos = [Video]() init(_ json: JSON) { id = json["playlistId"].stringValue title = json["title"].stringValue visibility = json["isListed"].boolValue ? .public : .private + updated = json["updated"].doubleValue videos = json["videos"].arrayValue.map { Video($0) } } static func == (lhs: Playlist, rhs: Playlist) -> Bool { - lhs.id == rhs.id && lhs.title == rhs.title && lhs.visibility == rhs.visibility + lhs.id == rhs.id && lhs.updated == rhs.updated } func hash(into hasher: inout Hasher) { diff --git a/Model/PlaylistVideo.swift b/Model/PlaylistVideo.swift new file mode 100644 index 00000000..71f2f4e1 --- /dev/null +++ b/Model/PlaylistVideo.swift @@ -0,0 +1,8 @@ +// +// PlaylistVideo.swift +// Pearvidious +// +// Created by Arkadiusz Fal on 09/07/2021. +// + +import Foundation diff --git a/Model/Video.swift b/Model/Video.swift index 6dc85122..1605a7e6 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -15,10 +15,21 @@ struct Video: Identifiable { var description: String var genre: String + let indexID: String? + var streams = [Stream]() init(_ json: JSON) { - id = json["videoId"].stringValue + let videoID = json["videoId"].stringValue + + if let id = json["indexId"].string { + indexID = id + self.id = videoID + id + } else { + indexID = nil + id = videoID + } + title = json["title"].stringValue author = json["author"].stringValue length = json["lengthSeconds"].doubleValue diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index aee06b7e..bbed9765 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -60,6 +60,9 @@ 373CFAEB26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */; }; 373CFAEC26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */; }; 373CFAED26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */; }; + 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; + 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; + 373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; 3741B5302676213400125C5E /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3741B52F2676213400125C5E /* PlayerViewController.swift */; }; 376578852685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; }; 376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; }; @@ -231,6 +234,7 @@ 373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.swift; sourceTree = ""; }; 373CFAE226974812003CB2C6 /* PlaylistVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistVisibility.swift; sourceTree = ""; }; 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFormView.swift; sourceTree = ""; }; + 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToPlaylistView.swift; sourceTree = ""; }; 3741B52F2676213400125C5E /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = ""; }; 376578842685429C00D4EA09 /* CaseIterable+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaseIterable+Next.swift"; sourceTree = ""; }; 376578882685471400D4EA09 /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = ""; }; @@ -420,6 +424,7 @@ 37D4B159267164AE00C925CA /* Apple TV */ = { isa = PBXGroup; children = ( + 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */, 37AAF2892673AB89007FC770 /* ChannelView.swift */, 373CFAC126966159003CB2C6 /* CoverSectionRowView.swift */, 373CFABD26966115003CB2C6 /* CoverSectionView.swift */, @@ -442,8 +447,8 @@ 37F4AE7126828F0900BD60EA /* VideosCellsView.swift */, 37AAF29926740A01007FC770 /* VideosListView.swift */, 371231832683E62F0000B307 /* VideosView.swift */, - 37D4B15E267164AF00C925CA /* Assets.xcassets */, 37D4B1AE26729DEB00C925CA /* Info.plist */, + 37D4B15E267164AF00C925CA /* Assets.xcassets */, ); path = "Apple TV"; sourceTree = ""; @@ -757,6 +762,7 @@ 37C7A1DC267CE9D90010EAD6 /* Profile.swift in Sources */, 373CFAC026966149003CB2C6 /* CoverSectionView.swift in Sources */, 3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */, + 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoListRowView.swift in Sources */, 37AAF29026740715007FC770 /* Channel.swift in Sources */, 37AAF2942674086B007FC770 /* TabSelection.swift in Sources */, @@ -843,6 +849,7 @@ 37B17DA1268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, 373CFAD4269662AB003CB2C6 /* SearchDate.swift in Sources */, 373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */, + 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -890,6 +897,7 @@ 3741B5302676213400125C5E /* PlayerViewController.swift in Sources */, 373CFABE26966148003CB2C6 /* CoverSectionView.swift in Sources */, 37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */, + 373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 37D4B18E26717B3800C925CA /* VideoListRowView.swift in Sources */, 37AAF27E26737323007FC770 /* PopularVideosView.swift in Sources */, 37AAF29A26740A01007FC770 /* VideosListView.swift in Sources */, diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 3d5dc459..4e85162b 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -7,10 +7,12 @@ extension Defaults.Keys { static let openChannel = Key("openChannel") static let searchSortOrder = Key("searchSortOrder", default: .relevance) - static let searchDate = Key("searchDate", default: nil) - static let searchDuration = Key("searchDuration", default: nil) + static let searchDate = Key("searchDate") + static let searchDuration = Key("searchDuration") static let openVideoID = Key("videoID", default: "") static let showingVideoDetails = Key("showingVideoDetails", default: false) static let selectedPlaylistID = Key("selectedPlaylistID") + static let showingAddToPlaylist = Key("showingAddToPlaylist", default: false) + static let videoIDToAddToPlaylist = Key("videoIDToAddToPlaylist") }