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