mirror of
https://github.com/yattee/yattee.git
synced 2025-12-15 12:38:15 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86be252bd5 | ||
|
|
20f96fb9d6 | ||
|
|
e539fb0067 | ||
|
|
03d5eefab0 | ||
|
|
0bc4a677d4 | ||
|
|
b374f82da4 | ||
|
|
b70697e1be | ||
|
|
db5765a84b | ||
|
|
8d36f57271 | ||
|
|
836057578f | ||
|
|
e39f4373bb | ||
|
|
1490437537 | ||
|
|
4f1b52826d | ||
|
|
15e62468bb |
@@ -3,6 +3,6 @@ import AppKit
|
|||||||
extension NSTextField {
|
extension NSTextField {
|
||||||
override open var focusRingType: NSFocusRingType {
|
override open var focusRingType: NSFocusRingType {
|
||||||
get { .none }
|
get { .none }
|
||||||
set {} // swiftlint:disable:this unused_setter_value
|
set {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension Video {
|
extension Video {
|
||||||
static var fixtureID: Video.ID {
|
static var fixtureID: Video.ID = "video-fixture"
|
||||||
"FIXTURE"
|
static var fixtureChannelID: Channel.ID = "channel-fixture"
|
||||||
}
|
|
||||||
|
|
||||||
static var fixture: Video {
|
static var fixture: Video {
|
||||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||||
@@ -18,7 +17,7 @@ extension Video {
|
|||||||
description: "Some relaxing live piano music",
|
description: "Some relaxing live piano music",
|
||||||
genre: "Music",
|
genre: "Music",
|
||||||
channel: Channel(
|
channel: Channel(
|
||||||
id: "AbCdEFgHI",
|
id: fixtureChannelID,
|
||||||
name: "The Channel",
|
name: "The Channel",
|
||||||
thumbnailURL: URL(string: thumbnailURL)!,
|
thumbnailURL: URL(string: thumbnailURL)!,
|
||||||
subscriptionsCount: 2300,
|
subscriptionsCount: 2300,
|
||||||
|
|||||||
@@ -92,15 +92,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||||
let results = content.json.arrayValue.compactMap { json -> ContentItem in
|
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
|
||||||
let type = json.dictionaryValue["type"]?.stringValue
|
let type = json.dictionaryValue["type"]?.stringValue
|
||||||
|
|
||||||
if type == "channel" {
|
if type == "channel" {
|
||||||
return ContentItem(channel: self.extractChannel(from: json))
|
return ContentItem(channel: self.extractChannel(from: json))
|
||||||
} else if type == "playlist" {
|
} else if type == "playlist" {
|
||||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||||
|
} else if type == "video" {
|
||||||
|
return ContentItem(video: self.extractVideo(from: json))
|
||||||
}
|
}
|
||||||
return ContentItem(video: self.extractVideo(from: json))
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return SearchPage(results: results, last: results.isEmpty)
|
return SearchPage(results: results, last: results.isEmpty)
|
||||||
@@ -236,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)"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Siesta
|
|||||||
import SwiftyJSON
|
import SwiftyJSON
|
||||||
|
|
||||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
|
||||||
|
|
||||||
@Published var account: Account!
|
@Published var account: Account!
|
||||||
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
|
||||||
|
content.json.arrayValue.map { self.extractUserPlaylist(from: $0)! }
|
||||||
|
}
|
||||||
|
|
||||||
if account.token.isNil {
|
if account.token.isNil {
|
||||||
updateToken()
|
updateToken()
|
||||||
}
|
}
|
||||||
@@ -166,7 +175,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
var home: Resource? { nil }
|
var home: Resource? { nil }
|
||||||
var popular: Resource? { nil }
|
var popular: Resource? { nil }
|
||||||
var playlists: Resource? { nil }
|
var playlists: Resource? {
|
||||||
|
resource(baseURL: account.instance.apiURL, path: "user/playlists")
|
||||||
|
}
|
||||||
|
|
||||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||||
@@ -180,10 +191,79 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
.onCompletion { _ in onCompletion() }
|
.onCompletion { _ in onCompletion() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func playlist(_: String) -> Resource? { nil }
|
func playlist(_ id: String) -> Resource? {
|
||||||
|
channelPlaylist(id)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -280,7 +360,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
return ChannelPlaylist(
|
return ChannelPlaylist(
|
||||||
id: id,
|
id: id,
|
||||||
title: details["name"]!.stringValue,
|
title: details["name"]?.stringValue ?? "",
|
||||||
thumbnailURL: thumbnailURL,
|
thumbnailURL: thumbnailURL,
|
||||||
channel: extractChannel(from: json)!,
|
channel: extractChannel(from: json)!,
|
||||||
videos: videos,
|
videos: videos,
|
||||||
@@ -310,6 +390,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||||
|
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
|
||||||
|
|
||||||
let uploaded = details["uploaded"]?.doubleValue
|
let uploaded = details["uploaded"]?.doubleValue
|
||||||
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||||
@@ -327,7 +408,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
published: published!,
|
published: published!,
|
||||||
views: details["views"]!.intValue,
|
views: details["views"]!.intValue,
|
||||||
description: extractDescription(from: content),
|
description: extractDescription(from: content),
|
||||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
|
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
|
||||||
thumbnails: thumbnails,
|
thumbnails: thumbnails,
|
||||||
live: live,
|
live: live,
|
||||||
likes: details["likes"]?.int,
|
likes: details["likes"]?.int,
|
||||||
@@ -359,6 +440,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
)!
|
)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func extractUserPlaylist(from json: JSON) -> Playlist? {
|
||||||
|
let id = json["id"].stringValue
|
||||||
|
let title = json["name"].stringValue
|
||||||
|
let visibility = Playlist.Visibility.private
|
||||||
|
|
||||||
|
return Playlist(id: id, title: title, visibility: visibility)
|
||||||
|
}
|
||||||
|
|
||||||
private func extractDescription(from content: JSON) -> String? {
|
private func extractDescription(from content: JSON) -> String? {
|
||||||
guard var description = content.dictionaryValue["description"]?.string else {
|
guard var description = content.dictionaryValue["description"]?.string else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ enum VideosApp: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var supportsUserPlaylists: Bool {
|
var supportsUserPlaylists: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||||
|
self == .invidious
|
||||||
|
}
|
||||||
|
|
||||||
|
var userPlaylistsHaveVisibility: Bool {
|
||||||
|
self == .invidious
|
||||||
|
}
|
||||||
|
|
||||||
|
var userPlaylistsAreEditable: Bool {
|
||||||
self == .invidious
|
self == .invidious
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ final class NavigationModel: ObservableObject {
|
|||||||
navigationStyle: NavigationStyle,
|
navigationStyle: NavigationStyle,
|
||||||
delay: Bool = true
|
delay: Bool = true
|
||||||
) {
|
) {
|
||||||
|
guard channel.id != Video.fixtureChannelID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let recent = RecentItem(from: channel)
|
let recent = RecentItem(from: channel)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Windows.main.open()
|
Windows.main.open()
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
||||||
@Published var restoredSegments = [Segment]()
|
@Published var restoredSegments = [Segment]()
|
||||||
|
|
||||||
@Published var channelWithDetails: Channel?
|
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Published var motionManager: CMMotionManager!
|
@Published var motionManager: CMMotionManager!
|
||||||
@Published var lockedOrientation: UIInterfaceOrientation?
|
@Published var lockedOrientation: UIInterfaceOrientation?
|
||||||
@@ -210,11 +208,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
self?.sponsorBlock.loadSegments(
|
self?.sponsorBlock.loadSegments(
|
||||||
videoID: video.videoID,
|
videoID: video.videoID,
|
||||||
categories: Defaults[.sponsorBlockCategories]
|
categories: Defaults[.sponsorBlockCategories]
|
||||||
) { [weak self] in
|
)
|
||||||
if Defaults[.showChannelSubscribers] {
|
|
||||||
self?.loadCurrentItemChannelDetails()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,36 +700,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
|
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCurrentItemChannelDetails() {
|
|
||||||
guard let video = currentVideo,
|
|
||||||
!video.channel.detailsLoaded
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if restoreLoadedChannel() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
accounts.api.channel(video.channel.id).load().onSuccess { [weak self] response in
|
|
||||||
if let channel: Channel = response.typedContent() {
|
|
||||||
self?.channelWithDetails = channel
|
|
||||||
withAnimation {
|
|
||||||
self?.currentItem?.video.channel = channel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult func restoreLoadedChannel() -> Bool {
|
|
||||||
if !currentVideo.isNil, channelWithDetails?.id == currentVideo!.channel.id {
|
|
||||||
currentItem.video.channel = channelWithDetails!
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func rateLabel(_ rate: Float) -> String {
|
func rateLabel(_ rate: Float) -> String {
|
||||||
let formatter = NumberFormatter()
|
let formatter = NumberFormatter()
|
||||||
formatter.minimumFractionDigits = 0
|
formatter.minimumFractionDigits = 0
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
preservedTime = currentItem.playbackTime
|
preservedTime = currentItem.playbackTime
|
||||||
restoreLoadedChannel()
|
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let video = self?.currentVideo else {
|
guard let video = self?.currentVideo else {
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
|||||||
var title: String
|
var title: String
|
||||||
var visibility: Visibility
|
var visibility: Visibility
|
||||||
|
|
||||||
var updated: TimeInterval
|
var updated: TimeInterval?
|
||||||
|
|
||||||
var videos = [Video]()
|
var videos = [Video]()
|
||||||
|
|
||||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval, videos: [Video] = []) {
|
init(id: String, title: String, visibility: Visibility, updated: TimeInterval? = nil, videos: [Video] = []) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.visibility = visibility
|
self.visibility = visibility
|
||||||
|
|||||||
@@ -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,20 @@ 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) {
|
||||||
|
self.reloadPlaylists.toggle()
|
||||||
resource?
|
|
||||||
.request(.post, json: body)
|
|
||||||
.onSuccess { _ in
|
|
||||||
self.load(force: true)
|
|
||||||
onSuccess()
|
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()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ final class SearchModel: ObservableObject {
|
|||||||
|
|
||||||
resource?.removeObservers(ownedBy: store)
|
resource?.removeObservers(ownedBy: store)
|
||||||
|
|
||||||
resource = accounts.api.search(query, page: page?.nextPage)
|
resource = accounts.api.search(query, page: pageToLoad.nextPage)
|
||||||
resource.addObserver(store)
|
resource.addObserver(store)
|
||||||
|
|
||||||
resource
|
resource
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -19,10 +19,10 @@
|
|||||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||||
* Stream quality selection
|
* Stream quality selection
|
||||||
|
|
||||||
### Features in alpha testing for iOS and macOS
|
### Features in alpha testing
|
||||||
* New player component with custom controls, gestures and support for 4K playback
|
* New player component with custom controls, gestures and support for 4K playback
|
||||||
|
|
||||||
You can leave your feedback in [discussion on v1.4 release](https://github.com/yattee/yattee/discussions/75).
|
You can leave your feedback in [discussion on v1.4 release](https://github.com/yattee/yattee/discussions/93) or join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat. Thanks!
|
||||||
|
|
||||||
### Availability
|
### Availability
|
||||||
|| Invidious | Piped |
|
|| Invidious | Piped |
|
||||||
@@ -30,7 +30,7 @@ You can leave your feedback in [discussion on v1.4 release](https://github.com/y
|
|||||||
| User Accounts | ✅ | ✅ |
|
| User Accounts | ✅ | ✅ |
|
||||||
| Subscriptions | ✅ | ✅ |
|
| Subscriptions | ✅ | ✅ |
|
||||||
| Popular | ✅ | 🔴 |
|
| Popular | ✅ | 🔴 |
|
||||||
| User Playlists | ✅ | 🔴 |
|
| User Playlists | ✅ | ✅ |
|
||||||
| Trending | ✅ | ✅ |
|
| Trending | ✅ | ✅ |
|
||||||
| Channels | ✅ | ✅ |
|
| Channels | ✅ | ✅ |
|
||||||
| Channel Playlists | ✅ | ✅ |
|
| Channel Playlists | ✅ | ✅ |
|
||||||
@@ -53,6 +53,7 @@ You can browse and use accounts from one app and play videos with another (for e
|
|||||||
## Contributing
|
## Contributing
|
||||||
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
|
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
|
||||||
|
|
||||||
|
Join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat if you need an advice or want to discuss the project.
|
||||||
## License and Liability
|
## License and Liability
|
||||||
|
|
||||||
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ extension Defaults.Keys {
|
|||||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
||||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||||
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
|
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
|
||||||
static let showChannelSubscribers = Key<Bool>("showChannelSubscribers", default: true)
|
|
||||||
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
|
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
|
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ struct AppSidebarPlaylists: View {
|
|||||||
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
|
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
|
||||||
LazyView(PlaylistVideosView(playlist))
|
LazyView(PlaylistVideosView(playlist))
|
||||||
} label: {
|
} label: {
|
||||||
Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
playlistLabel(playlist)
|
||||||
.backport
|
|
||||||
.badge(Text("\(playlist.videos.count)"))
|
|
||||||
}
|
}
|
||||||
.id(playlist.id)
|
.id(playlist.id)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
@@ -34,6 +32,18 @@ struct AppSidebarPlaylists: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func playlistLabel(_ playlist: Playlist) -> some View {
|
||||||
|
let label = Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||||
|
|
||||||
|
if player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||||
|
label
|
||||||
|
.backport
|
||||||
|
.badge(Text("\(playlist.videos.count)"))
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var newPlaylistButton: some View {
|
var newPlaylistButton: some View {
|
||||||
Button(action: { navigation.presentNewPlaylistForm() }) {
|
Button(action: { navigation.presentNewPlaylistForm() }) {
|
||||||
Label("New Playlist", systemImage: "plus.circle")
|
Label("New Playlist", systemImage: "plus.circle")
|
||||||
|
|||||||
@@ -103,8 +103,14 @@ struct PlaybackBar: View {
|
|||||||
return "loading..."
|
return "loading..."
|
||||||
}
|
}
|
||||||
|
|
||||||
let videoLengthAtRate = player.currentVideo!.length / Double(player.currentRate)
|
guard let video = player.currentVideo,
|
||||||
let remainingSeconds = videoLengthAtRate - player.time!.seconds
|
let time = player.time
|
||||||
|
else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoLengthAtRate = video.length / Double(player.currentRate)
|
||||||
|
let remainingSeconds = videoLengthAtRate - time.seconds
|
||||||
|
|
||||||
if remainingSeconds < 60 {
|
if remainingSeconds < 60 {
|
||||||
return "less than a minute"
|
return "less than a minute"
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ struct VideoDetails: View {
|
|||||||
@EnvironmentObject<RecentsModel> private var recents
|
@EnvironmentObject<RecentsModel> private var recents
|
||||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||||
|
|
||||||
@Default(.showChannelSubscribers) private var showChannelSubscribers
|
|
||||||
@Default(.showKeywords) private var showKeywords
|
@Default(.showKeywords) private var showKeywords
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -208,15 +207,13 @@ struct VideoDetails: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.bold()
|
.bold()
|
||||||
|
|
||||||
if showChannelSubscribers {
|
Group {
|
||||||
Group {
|
if let subscribers = video!.channel.subscriptionsString {
|
||||||
if let subscribers = video!.channel.subscriptionsString {
|
Text("\(subscribers) subscribers")
|
||||||
Text("\(subscribers) subscribers")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.font(.caption2)
|
|
||||||
}
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct AddToPlaylistView: View {
|
|||||||
|
|
||||||
@State private var error = ""
|
@State private var error = ""
|
||||||
@State private var presentingErrorAlert = false
|
@State private var presentingErrorAlert = false
|
||||||
|
@State private var submitButtonDisabled = false
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@Environment(\.presentationMode) private var presentationMode
|
@Environment(\.presentationMode) private var presentationMode
|
||||||
@@ -122,7 +123,7 @@ struct AddToPlaylistView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Add to Playlist", action: addToPlaylist)
|
Button("Add to Playlist", action: addToPlaylist)
|
||||||
.disabled(selectedPlaylist.isNil)
|
.disabled(submitButtonDisabled || selectedPlaylist.isNil)
|
||||||
.padding(.top, 30)
|
.padding(.top, 30)
|
||||||
.alert(isPresented: $presentingErrorAlert) {
|
.alert(isPresented: $presentingErrorAlert) {
|
||||||
Alert(
|
Alert(
|
||||||
@@ -165,6 +166,8 @@ struct AddToPlaylistView: View {
|
|||||||
|
|
||||||
Defaults[.lastUsedPlaylistID] = id
|
Defaults[.lastUsedPlaylistID] = id
|
||||||
|
|
||||||
|
submitButtonDisabled = true
|
||||||
|
|
||||||
model.addVideo(
|
model.addVideo(
|
||||||
playlistID: id,
|
playlistID: id,
|
||||||
videoID: video.videoID,
|
videoID: video.videoID,
|
||||||
@@ -174,6 +177,7 @@ struct AddToPlaylistView: View {
|
|||||||
onFailure: { requestError in
|
onFailure: { requestError in
|
||||||
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
||||||
presentingErrorAlert = true
|
presentingErrorAlert = true
|
||||||
|
submitButtonDisabled = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,20 +122,24 @@ 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 {
|
if accounts.app.userPlaylistsHaveVisibility {
|
||||||
Text("Visibility")
|
HStack {
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
Text("Visibility")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
visibilityFormItem
|
visibilityFormItem
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
}
|
}
|
||||||
.padding(.top, 10)
|
|
||||||
|
|
||||||
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 +179,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 +231,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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ struct PlaylistsView: View {
|
|||||||
@State private var showingEditPlaylist = false
|
@State private var showingEditPlaylist = false
|
||||||
@State private var editedPlaylist: Playlist?
|
@State private var editedPlaylist: Playlist?
|
||||||
|
|
||||||
|
@StateObject private var store = Store<ChannelPlaylist>()
|
||||||
|
|
||||||
@EnvironmentObject<AccountsModel> private var accounts
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
@EnvironmentObject<PlayerModel> private var player
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
@EnvironmentObject<PlaylistsModel> private var model
|
@EnvironmentObject<PlaylistsModel> private var model
|
||||||
@@ -18,7 +20,36 @@ struct PlaylistsView: View {
|
|||||||
@Namespace private var focusNamespace
|
@Namespace private var focusNamespace
|
||||||
|
|
||||||
var items: [ContentItem] {
|
var items: [ContentItem] {
|
||||||
ContentItem.array(of: currentPlaylist?.videos ?? [])
|
var videos = currentPlaylist?.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? {
|
||||||
|
guard !player.accounts.app.userPlaylistsEndpointIncludesVideos,
|
||||||
|
let playlist = currentPlaylist
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource = player.accounts.api.playlist(playlist.id)
|
||||||
|
resource?.addObserver(store)
|
||||||
|
|
||||||
|
return resource
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -112,10 +143,17 @@ struct PlaylistsView: View {
|
|||||||
#endif
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
model.load()
|
model.load()
|
||||||
|
resource?.load()
|
||||||
}
|
}
|
||||||
.onChange(of: accounts.current) { _ in
|
.onChange(of: accounts.current) { _ in
|
||||||
model.load(force: true)
|
model.load(force: true)
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedPlaylistID) { _ in
|
||||||
|
resource?.load()
|
||||||
|
}
|
||||||
|
.onChange(of: model.reloadPlaylists) { _ in
|
||||||
|
resource?.load()
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ struct PlayerSettings: View {
|
|||||||
@Default(.playerSidebar) private var playerSidebar
|
@Default(.playerSidebar) private var playerSidebar
|
||||||
@Default(.showHistoryInPlayer) private var showHistory
|
@Default(.showHistoryInPlayer) private var showHistory
|
||||||
@Default(.showKeywords) private var showKeywords
|
@Default(.showKeywords) private var showKeywords
|
||||||
@Default(.showChannelSubscribers) private var channelSubscribers
|
|
||||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||||
@@ -83,7 +82,6 @@ struct PlayerSettings: View {
|
|||||||
|
|
||||||
keywordsToggle
|
keywordsToggle
|
||||||
showHistoryToggle
|
showHistoryToggle
|
||||||
channelSubscribersToggle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: SettingsHeader(text: "Picture in Picture")) {
|
Section(header: SettingsHeader(text: "Picture in Picture")) {
|
||||||
@@ -196,10 +194,6 @@ struct PlayerSettings: View {
|
|||||||
Toggle("Show history", isOn: $showHistory)
|
Toggle("Show history", isOn: $showHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var channelSubscribersToggle: some View {
|
|
||||||
Toggle("Show subscribers count", isOn: $channelSubscribers)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var pauseOnHidingPlayerToggle: some View {
|
private var pauseOnHidingPlayerToggle: some View {
|
||||||
Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer)
|
Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,35 @@ 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>()
|
||||||
|
|
||||||
var contentItems: [ContentItem] {
|
var contentItems: [ContentItem] {
|
||||||
ContentItem.array(of: 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? {
|
||||||
|
let resource = player.accounts.api.playlist(playlist.id)
|
||||||
|
resource?.addObserver(store)
|
||||||
|
|
||||||
|
return resource
|
||||||
}
|
}
|
||||||
|
|
||||||
var videos: [Video] {
|
var videos: [Video] {
|
||||||
@@ -22,6 +48,14 @@ struct PlaylistVideosView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
PlayerControlsView {
|
PlayerControlsView {
|
||||||
VerticalCells(items: contentItems)
|
VerticalCells(items: contentItems)
|
||||||
|
.onAppear {
|
||||||
|
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2474,7 +2474,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -2487,7 +2487,7 @@
|
|||||||
"@executable_path/../../../../Frameworks",
|
"@executable_path/../../../../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"-framework",
|
"-framework",
|
||||||
SafariServices,
|
SafariServices,
|
||||||
@@ -2508,7 +2508,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -2521,7 +2521,7 @@
|
|||||||
"@executable_path/../../../../Frameworks",
|
"@executable_path/../../../../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"-framework",
|
"-framework",
|
||||||
SafariServices,
|
SafariServices,
|
||||||
@@ -2540,7 +2540,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||||
@@ -2552,7 +2552,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"-framework",
|
"-framework",
|
||||||
SafariServices,
|
SafariServices,
|
||||||
@@ -2572,7 +2572,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||||
@@ -2584,7 +2584,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"-framework",
|
"-framework",
|
||||||
SafariServices,
|
SafariServices,
|
||||||
@@ -2735,7 +2735,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -2751,7 +2751,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2767,7 +2767,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -2783,7 +2783,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2803,7 +2803,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -2818,7 +2818,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -2836,7 +2836,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -2851,7 +2851,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -2967,7 +2967,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -2982,7 +2982,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
@@ -2999,7 +2999,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -3014,7 +3014,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/Alamofire/Alamofire.git",
|
"location" : "https://github.com/Alamofire/Alamofire.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864",
|
"revision" : "354dda32d89fc8cd4f5c46487f64957d355f53d8",
|
||||||
"version" : "5.5.0"
|
"version" : "5.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "2.x",
|
"branch" : "2.x",
|
||||||
"revision" : "f250bead4b943ef9711c61274a1f52e380afa0e8"
|
"revision" : "503830bf24f679b7a9199be85ee7c8d012528d09"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user