mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 13:33:42 +00:00
Create/delete Piped playlists and add/remove videos to Piped playlists
This commit is contained in:
parent
b374f82da4
commit
0bc4a677d4
@ -239,6 +239,66 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
playlist(playlistID)?.child("videos").child(videoID)
|
||||
}
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = playlistVideo(playlistID, index)
|
||||
|
||||
resource?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["title": name, "privacy": visibility]
|
||||
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
|
||||
|
||||
resource?
|
||||
.request(!playlist.isNil ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
self.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
|
@ -43,6 +43,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
@ -193,6 +198,72 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
|
||||
let body = ["videoId": videoID, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
|
||||
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["name": name]
|
||||
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
} else {
|
||||
onSuccess(nil)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
|
||||
let body = ["playlistId": playlist.id]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
|
@ -27,6 +27,34 @@ protocol VideosAPI {
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
)
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
|
@ -39,6 +39,14 @@ enum VideosApp: String, CaseIterable {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsAreEditable: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var hasFrontendURL: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
final class PlaylistsModel: ObservableObject {
|
||||
@Published var playlists = [Playlist]()
|
||||
@Published var reloadPlaylists = false
|
||||
|
||||
var accounts = AccountsModel()
|
||||
|
||||
@ -58,24 +59,17 @@ final class PlaylistsModel: ObservableObject {
|
||||
onSuccess: @escaping () -> Void = {},
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in }
|
||||
) {
|
||||
let resource = accounts.api.playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
|
||||
self.load(force: true, onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
|
||||
|
||||
resource?.request(.delete).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
func removeVideo(index: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.removeVideoFromPlaylist(index, playlistID, onFailure: { _ in }) {
|
||||
self.load(force: true) {
|
||||
self.reloadPlaylists.toggle()
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
var genre: String?
|
||||
|
||||
// index used when in the Playlist
|
||||
let indexID: String?
|
||||
var indexID: String?
|
||||
|
||||
var live: Bool
|
||||
var upcoming: Bool
|
||||
|
@ -43,9 +43,12 @@ struct PlaylistFormView: View {
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.frame(maxWidth: 450)
|
||||
.padding(.leading, 10)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
if accounts.app.userPlaylistsHaveVisibility {
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
@ -59,7 +62,7 @@ struct PlaylistFormView: View {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error when accessing playlist"),
|
||||
@ -75,7 +78,7 @@ struct PlaylistFormView: View {
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: 150)
|
||||
.frame(width: 400, height: accounts.app.userPlaylistsHaveVisibility ? 150 : 120)
|
||||
#endif
|
||||
|
||||
#else
|
||||
@ -119,6 +122,7 @@ struct PlaylistFormView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
TextField("Playlist Name", text: $name, onCommit: validate)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
}
|
||||
|
||||
HStack {
|
||||
@ -132,7 +136,8 @@ struct PlaylistFormView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm).disabled(!valid)
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
}
|
||||
.padding(.top, 40)
|
||||
|
||||
@ -172,27 +177,15 @@ struct PlaylistFormView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let body = ["title": name, "privacy": visibility.rawValue]
|
||||
accounts.api.playlistForm(name, visibility.rawValue, playlist: playlist, onFailure: { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}) { modifiedPlaylist in
|
||||
self.playlist = modifiedPlaylist
|
||||
playlists.load(force: true)
|
||||
|
||||
resource?
|
||||
.request(editing ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
playlist = modifiedPlaylist
|
||||
}
|
||||
|
||||
playlists.load(force: true)
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.onFailure { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
var resource: Resource? {
|
||||
editing ? accounts.api.playlist(playlist.id) : accounts.api.playlists
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
var visibilityFormItem: some View {
|
||||
@ -236,17 +229,14 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
|
||||
func deletePlaylistAndDismiss() {
|
||||
accounts.api.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.onFailure { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
accounts.api.deletePlaylist(playlist, onFailure: { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||
presentingErrorAlert = true
|
||||
}) {
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,11 +6,28 @@ struct PlaylistVideosView: View {
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
ContentItem.array(of: playlist.videos.isEmpty ? (store.item?.videos ?? []) : playlist.videos)
|
||||
var videos = playlist.videos
|
||||
|
||||
if videos.isEmpty {
|
||||
videos = store.item?.videos ?? []
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
var i = 0
|
||||
|
||||
for index in videos.indices {
|
||||
var video = videos[index]
|
||||
video.indexID = "\(i)"
|
||||
i += 1
|
||||
videos[index] = video
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContentItem.array(of: videos)
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
@ -33,9 +50,12 @@ struct PlaylistVideosView: View {
|
||||
VerticalCells(items: contentItems)
|
||||
.onAppear {
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
resource?.loadIfNeeded()
|
||||
resource?.load()
|
||||
}
|
||||
}
|
||||
.onChange(of: model.reloadPlaylists) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("\(playlist.title) Playlist")
|
||||
#endif
|
||||
|
@ -201,7 +201,7 @@ struct VideoContextMenuView: View {
|
||||
|
||||
func removeFromPlaylistButton(playlistID: String) -> some View {
|
||||
Button {
|
||||
playlists.removeVideo(videoIndexID: video.indexID!, playlistID: playlistID)
|
||||
playlists.removeVideo(index: video.indexID!, playlistID: playlistID)
|
||||
} label: {
|
||||
Label("Remove from playlist", systemImage: "text.badge.minus")
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user