mirror of
https://github.com/yattee/yattee.git
synced 2025-12-13 11:38:15 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86be252bd5 | ||
|
|
20f96fb9d6 | ||
|
|
e539fb0067 | ||
|
|
03d5eefab0 | ||
|
|
0bc4a677d4 | ||
|
|
b374f82da4 | ||
|
|
b70697e1be | ||
|
|
db5765a84b | ||
|
|
8d36f57271 | ||
|
|
836057578f | ||
|
|
e39f4373bb | ||
|
|
1490437537 | ||
|
|
4f1b52826d | ||
|
|
15e62468bb | ||
|
|
1380036c44 | ||
|
|
c893e5dc38 | ||
|
|
8b4838dca5 | ||
|
|
1c520831d1 | ||
|
|
8770bfb56d | ||
|
|
ae4796a4c5 | ||
|
|
70b55ec2b2 | ||
|
|
c14a4a153d | ||
|
|
c8fa972a61 | ||
|
|
cc7bb83e74 | ||
|
|
6a65123876 | ||
|
|
aa42551c7c | ||
|
|
9d8a2607ab | ||
|
|
b4a0835a43 | ||
|
|
066e048022 | ||
|
|
d825cd8b20 | ||
|
|
bb988764b4 | ||
|
|
f7789c73d5 | ||
|
|
1085bf0e9a |
@@ -3,6 +3,6 @@ import AppKit
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {} // swiftlint:disable:this unused_setter_value
|
||||
set {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ extension Thumbnail {
|
||||
}
|
||||
|
||||
private static var fixturesHost: String {
|
||||
"https://invidious.home.arekf.net"
|
||||
"https://invidious.snopyta.org"
|
||||
}
|
||||
|
||||
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
static var fixtureID: Video.ID = "video-fixture"
|
||||
static var fixtureChannelID: Channel.ID = "channel-fixture"
|
||||
|
||||
static var fixture: Video {
|
||||
let id = "D2sxamzaHkM"
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
|
||||
return Video(
|
||||
videoID: UUID().uuidString,
|
||||
title: "Relaxing Piano Music that will make you feel amazingly good",
|
||||
videoID: fixtureID,
|
||||
title: "Relaxing Piano Music to feel good",
|
||||
author: "Fancy Videotuber",
|
||||
length: 582,
|
||||
published: "7 years ago",
|
||||
@@ -15,13 +17,13 @@ extension Video {
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
id: "AbCdEFgHI",
|
||||
id: fixtureChannelID,
|
||||
name: "The Channel",
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
subscriptionsCount: 2300,
|
||||
videos: []
|
||||
),
|
||||
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
|
||||
thumbnails: [],
|
||||
live: false,
|
||||
upcoming: false,
|
||||
publishedAt: Date(),
|
||||
|
||||
@@ -92,15 +92,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: self.extractChannel(from: json))
|
||||
} else if type == "playlist" {
|
||||
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)
|
||||
@@ -236,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)"))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -81,6 +86,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
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 {
|
||||
updateToken()
|
||||
}
|
||||
@@ -166,7 +175,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
var home: 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 = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
@@ -180,10 +191,79 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_: String) -> Resource? { nil }
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
channelPlaylist(id)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -232,6 +312,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -278,7 +360,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
return ChannelPlaylist(
|
||||
id: id,
|
||||
title: details["name"]!.stringValue,
|
||||
title: details["name"]?.stringValue ?? "",
|
||||
thumbnailURL: thumbnailURL,
|
||||
channel: extractChannel(from: json)!,
|
||||
videos: videos,
|
||||
@@ -308,9 +390,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
|
||||
|
||||
let uploaded = details["uploaded"]?.doubleValue
|
||||
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
if published.isNil {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? ""
|
||||
}
|
||||
|
||||
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
|
||||
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
|
||||
let live = details["livestream"]?.boolValue ?? (details["duration"]?.intValue == -1)
|
||||
|
||||
return Video(
|
||||
@@ -318,10 +405,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
title: details["title"]!.stringValue,
|
||||
author: author,
|
||||
length: details["duration"]!.doubleValue,
|
||||
published: published,
|
||||
published: published!,
|
||||
views: details["views"]!.intValue,
|
||||
description: extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
likes: details["likes"]?.int,
|
||||
@@ -353,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? {
|
||||
guard var description = content.dictionaryValue["description"]?.string else {
|
||||
return nil
|
||||
|
||||
@@ -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)
|
||||
@@ -74,6 +102,8 @@ extension VideosAPI {
|
||||
case .playlist:
|
||||
urlComponents.path = "/playlist"
|
||||
queryItems.append(.init(name: "list", value: item.playlist.id))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if !time.isNil, time!.seconds.isFinite {
|
||||
|
||||
@@ -32,6 +32,18 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsAreEditable: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
struct ContentItem: Identifiable {
|
||||
enum ContentType: String {
|
||||
case video, playlist, channel
|
||||
case video, playlist, channel, placeholder
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
@@ -35,6 +35,6 @@ struct ContentItem: Identifiable {
|
||||
}
|
||||
|
||||
var contentType: ContentType {
|
||||
video.isNil ? (channel.isNil ? .playlist : .channel) : .video
|
||||
video.isNil ? (channel.isNil ? (playlist.isNil ? .placeholder : .playlist) : .channel) : .video
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,31 @@ final class NavigationModel: ObservableObject {
|
||||
case nowPlaying
|
||||
case search
|
||||
|
||||
var stringValue: String {
|
||||
switch self {
|
||||
case .favorites:
|
||||
return "favorites"
|
||||
case .subscriptions:
|
||||
return "subscriptions"
|
||||
case .popular:
|
||||
return "popular"
|
||||
case .trending:
|
||||
return "trending"
|
||||
case .playlists:
|
||||
return "playlists"
|
||||
case let .channel(string):
|
||||
return "channel\(string)"
|
||||
case let .playlist(string):
|
||||
return "playlist\(string)"
|
||||
case .recentlyOpened:
|
||||
return "recentlyOpened"
|
||||
case .search:
|
||||
return "search"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var playlistID: Playlist.ID? {
|
||||
if case let .playlist(id) = self {
|
||||
return id
|
||||
@@ -49,6 +74,10 @@ final class NavigationModel: ObservableObject {
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = true
|
||||
) {
|
||||
guard channel.id != Video.fixtureChannelID else {
|
||||
return
|
||||
}
|
||||
|
||||
let recent = RecentItem(from: channel)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
@@ -141,6 +170,12 @@ final class NavigationModel: ObservableObject {
|
||||
channelToUnsubscribe = channel
|
||||
presentingUnsubscribeAlert = channelToUnsubscribe != nil
|
||||
}
|
||||
|
||||
func hideKeyboard() {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
typealias TabSelection = NavigationModel.TabSelection
|
||||
|
||||
@@ -45,8 +45,6 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
||||
@Published var restoredSegments = [Segment]()
|
||||
|
||||
@Published var channelWithDetails: Channel?
|
||||
|
||||
#if os(iOS)
|
||||
@Published var motionManager: CMMotionManager!
|
||||
@Published var lockedOrientation: UIInterfaceOrientation?
|
||||
@@ -210,11 +208,7 @@ final class PlayerModel: ObservableObject {
|
||||
self?.sponsorBlock.loadSegments(
|
||||
videoID: video.videoID,
|
||||
categories: Defaults[.sponsorBlockCategories]
|
||||
) { [weak self] in
|
||||
if Defaults[.showChannelSubscribers] {
|
||||
self?.loadCurrentItemChannelDetails()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,8 +550,6 @@ final class PlayerModel: ObservableObject {
|
||||
controller?.playerView.dismiss(animated: false) { [weak self] in
|
||||
self?.controller?.dismiss(animated: true)
|
||||
}
|
||||
#else
|
||||
hide()
|
||||
#endif
|
||||
} else {
|
||||
advanceToNextItem()
|
||||
@@ -708,36 +700,6 @@ final class PlayerModel: ObservableObject {
|
||||
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 {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.minimumFractionDigits = 0
|
||||
|
||||
@@ -74,7 +74,6 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
preservedTime = currentItem.playbackTime
|
||||
restoreLoadedChannel()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let video = self?.currentVideo else {
|
||||
|
||||
@@ -18,11 +18,11 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
||||
var title: String
|
||||
var visibility: Visibility
|
||||
|
||||
var updated: TimeInterval
|
||||
var updated: TimeInterval?
|
||||
|
||||
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.title = title
|
||||
self.visibility = visibility
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
final class PlaylistsModel: ObservableObject {
|
||||
@Published var playlists = [Playlist]()
|
||||
@Published var reloadPlaylists = false
|
||||
|
||||
var accounts = AccountsModel()
|
||||
|
||||
@@ -58,24 +59,20 @@ 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)
|
||||
accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
|
||||
self.load(force: true) {
|
||||
self.reloadPlaylists.toggle()
|
||||
onSuccess()
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ final class SearchModel: ObservableObject {
|
||||
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
|
||||
resource = accounts.api.search(query, page: page?.nextPage)
|
||||
resource = accounts.api.search(query, page: pageToLoad.nextPage)
|
||||
resource.addObserver(store)
|
||||
|
||||
resource
|
||||
|
||||
@@ -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
|
||||
|
||||
10
README.md
10
README.md
@@ -19,13 +19,18 @@
|
||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||
* Stream quality selection
|
||||
|
||||
### Features in alpha testing
|
||||
* 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/93) or join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat. Thanks!
|
||||
|
||||
### Availability
|
||||
| Feature | Invidious | Piped |
|
||||
|| Invidious | Piped |
|
||||
| - | - | - |
|
||||
| User Accounts | ✅ | ✅ |
|
||||
| Subscriptions | ✅ | ✅ |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | ✅ |
|
||||
| Trending | ✅ | ✅ |
|
||||
| Channels | ✅ | ✅ |
|
||||
| Channel Playlists | ✅ | ✅ |
|
||||
@@ -48,6 +53,7 @@ You can browse and use accounts from one app and play videos with another (for e
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
|
||||
38
Shared/Defaults+Workaround.swift
Normal file
38
Shared/Defaults+Workaround.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
extension Defaults.Serializable where Self: Codable {
|
||||
static var bridge: Defaults.TopLevelCodableBridge<Self> { Defaults.TopLevelCodableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & NSSecureCoding {
|
||||
static var bridge: Defaults.CodableNSSecureCodingBridge<Self> { Defaults.CodableNSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding {
|
||||
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & RawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableCodableBridge<Self> { Defaults.RawRepresentableCodableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: RawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: NSSecureCoding {
|
||||
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.CollectionSerializable where Element: Defaults.Serializable {
|
||||
static var bridge: Defaults.CollectionBridge<Self> { Defaults.CollectionBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable {
|
||||
static var bridge: Defaults.SetAlgebraBridge<Self> { Defaults.SetAlgebraBridge() }
|
||||
}
|
||||
@@ -48,7 +48,6 @@ extension Defaults.Keys {
|
||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
||||
static let showKeywords = Key<Bool>("showKeywords", 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)
|
||||
#if !os(tvOS)
|
||||
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
|
||||
|
||||
@@ -11,10 +11,18 @@ struct DropFavorite: DropDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
let from = favorites.firstIndex(of: current!)!
|
||||
let to = favorites.firstIndex(of: item)!
|
||||
guard let current = current else {
|
||||
return
|
||||
}
|
||||
|
||||
guard favorites[to].id != current!.id else {
|
||||
let from = favorites.firstIndex(of: current)
|
||||
let to = favorites.firstIndex(of: item)
|
||||
|
||||
guard let from = from, let to = to else {
|
||||
return
|
||||
}
|
||||
|
||||
guard favorites[to].id != current.id else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,29 +12,29 @@ struct MenuCommands: Commands {
|
||||
private var navigationMenu: some Commands {
|
||||
CommandGroup(before: .windowSize) {
|
||||
Button("Favorites") {
|
||||
model.navigation?.tabSelection = .favorites
|
||||
setTabSelection(.favorites)
|
||||
}
|
||||
.keyboardShortcut("1")
|
||||
|
||||
Button("Subscriptions") {
|
||||
model.navigation?.tabSelection = .subscriptions
|
||||
setTabSelection(.subscriptions)
|
||||
}
|
||||
.disabled(subscriptionsDisabled)
|
||||
.keyboardShortcut("2")
|
||||
|
||||
Button("Popular") {
|
||||
model.navigation?.tabSelection = .popular
|
||||
setTabSelection(.popular)
|
||||
}
|
||||
.disabled(!(model.accounts?.app.supportsPopular ?? false))
|
||||
.keyboardShortcut("3")
|
||||
|
||||
Button("Trending") {
|
||||
model.navigation?.tabSelection = .trending
|
||||
setTabSelection(.trending)
|
||||
}
|
||||
.keyboardShortcut("4")
|
||||
|
||||
Button("Search") {
|
||||
model.navigation?.tabSelection = .search
|
||||
setTabSelection(.search)
|
||||
}
|
||||
.keyboardShortcut("f")
|
||||
|
||||
@@ -42,6 +42,15 @@ struct MenuCommands: Commands {
|
||||
}
|
||||
}
|
||||
|
||||
private func setTabSelection(_ tabSelection: NavigationModel.TabSelection) {
|
||||
guard let navigation = model.navigation else {
|
||||
return
|
||||
}
|
||||
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = tabSelection
|
||||
}
|
||||
|
||||
private var subscriptionsDisabled: Bool {
|
||||
!(
|
||||
(model.accounts?.app.supportsSubscriptions ?? false) && model.accounts?.signedIn ?? false
|
||||
|
||||
@@ -11,9 +11,7 @@ struct AppSidebarPlaylists: View {
|
||||
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
|
||||
LazyView(PlaylistVideosView(playlist))
|
||||
} label: {
|
||||
Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
playlistLabel(playlist)
|
||||
}
|
||||
.id(playlist.id)
|
||||
.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 {
|
||||
Button(action: { navigation.presentNewPlaylistForm() }) {
|
||||
Label("New Playlist", systemImage: "plus.circle")
|
||||
|
||||
@@ -45,6 +45,7 @@ struct Sidebar: View {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.id("favorites")
|
||||
}
|
||||
if visibleSections.contains(.subscriptions),
|
||||
accounts.app.supportsSubscriptions && accounts.signedIn
|
||||
@@ -53,6 +54,7 @@ struct Sidebar: View {
|
||||
Label("Subscriptions", systemImage: "star.circle")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
.id("subscriptions")
|
||||
}
|
||||
|
||||
if visibleSections.contains(.popular), accounts.app.supportsPopular {
|
||||
@@ -60,6 +62,7 @@ struct Sidebar: View {
|
||||
Label("Popular", systemImage: "arrow.up.right.circle")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
.id("popular")
|
||||
}
|
||||
|
||||
if visibleSections.contains(.trending) {
|
||||
@@ -67,12 +70,14 @@ struct Sidebar: View {
|
||||
Label("Trending", systemImage: "chart.bar")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
.id("trending")
|
||||
}
|
||||
|
||||
NavigationLink(destination: LazyView(SearchView()), tag: TabSelection.search, selection: $navigation.tabSelection) {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
.accessibility(label: Text("Search"))
|
||||
}
|
||||
.id("search")
|
||||
.keyboardShortcut("f")
|
||||
}
|
||||
}
|
||||
@@ -80,8 +85,12 @@ struct Sidebar: View {
|
||||
private func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) {
|
||||
if case .recentlyOpened = selection {
|
||||
scrollView.scrollTo("recentlyOpened")
|
||||
return
|
||||
} else if case let .playlist(id) = selection {
|
||||
scrollView.scrollTo(id)
|
||||
return
|
||||
}
|
||||
|
||||
scrollView.scrollTo(selection.stringValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +103,14 @@ struct PlaybackBar: View {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
let videoLengthAtRate = player.currentVideo!.length / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - player.time!.seconds
|
||||
guard let video = player.currentVideo,
|
||||
let time = player.time
|
||||
else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let videoLengthAtRate = video.length / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - time.seconds
|
||||
|
||||
if remainingSeconds < 60 {
|
||||
return "less than a minute"
|
||||
|
||||
@@ -12,6 +12,7 @@ struct VideoDetails: View {
|
||||
@Binding var fullScreen: Bool
|
||||
|
||||
@State private var subscribed = false
|
||||
@State private var subscriptionToggleButtonDisabled = false
|
||||
@State private var presentingUnsubscribeAlert = false
|
||||
@State private var presentingAddToPlaylist = false
|
||||
@State private var presentingShareSheet = false
|
||||
@@ -29,7 +30,6 @@ struct VideoDetails: View {
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.showChannelSubscribers) private var showChannelSubscribers
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
|
||||
init(
|
||||
@@ -207,15 +207,13 @@ struct VideoDetails: View {
|
||||
.font(.system(size: 14))
|
||||
.bold()
|
||||
|
||||
if showChannelSubscribers {
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
}
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +234,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
}
|
||||
|
||||
if accounts.app.supportsSubscriptions {
|
||||
if accounts.app.supportsSubscriptions, accounts.signedIn {
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
@@ -254,10 +252,13 @@ struct VideoDetails: View {
|
||||
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(video!.channel.id)
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
withAnimation {
|
||||
subscribed.toggle()
|
||||
subscriptions.unsubscribe(video!.channel.id) {
|
||||
withAnimation {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
subscribed.toggle()
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
@@ -265,16 +266,20 @@ struct VideoDetails: View {
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptions.subscribe(video!.channel.id)
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
withAnimation {
|
||||
subscribed.toggle()
|
||||
subscriptions.subscribe(video!.channel.id) {
|
||||
withAnimation {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
subscribed.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.backport
|
||||
.tint(.blue)
|
||||
.tint(subscriptionToggleButtonDisabled ? .gray : .blue)
|
||||
}
|
||||
}
|
||||
.disabled(subscriptionToggleButtonDisabled)
|
||||
.font(.system(size: 13))
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct AddToPlaylistView: View {
|
||||
|
||||
@State private var error = ""
|
||||
@State private var presentingErrorAlert = false
|
||||
@State private var submitButtonDisabled = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@@ -122,7 +123,7 @@ struct AddToPlaylistView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Add to Playlist", action: addToPlaylist)
|
||||
.disabled(selectedPlaylist.isNil)
|
||||
.disabled(submitButtonDisabled || selectedPlaylist.isNil)
|
||||
.padding(.top, 30)
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
@@ -165,6 +166,8 @@ struct AddToPlaylistView: View {
|
||||
|
||||
Defaults[.lastUsedPlaylistID] = id
|
||||
|
||||
submitButtonDisabled = true
|
||||
|
||||
model.addVideo(
|
||||
playlistID: id,
|
||||
videoID: video.videoID,
|
||||
@@ -174,6 +177,7 @@ struct AddToPlaylistView: View {
|
||||
onFailure: { requestError in
|
||||
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
submitButtonDisabled = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,20 +122,24 @@ struct PlaylistFormView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
TextField("Playlist Name", text: $name, onCommit: validate)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Visibility")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if accounts.app.userPlaylistsHaveVisibility {
|
||||
HStack {
|
||||
Text("Visibility")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
visibilityFormItem
|
||||
visibilityFormItem
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm).disabled(!valid)
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
}
|
||||
.padding(.top, 40)
|
||||
|
||||
@@ -172,27 +179,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 +231,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ struct PlaylistsView: View {
|
||||
@State private var showingEditPlaylist = false
|
||||
@State private var editedPlaylist: Playlist?
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
@@ -18,7 +20,36 @@ struct PlaylistsView: View {
|
||||
@Namespace private var focusNamespace
|
||||
|
||||
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 {
|
||||
@@ -112,10 +143,17 @@ struct PlaylistsView: View {
|
||||
#endif
|
||||
.onAppear {
|
||||
model.load()
|
||||
resource?.load()
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
model.load(force: true)
|
||||
}
|
||||
.onChange(of: selectedPlaylistID) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
.onChange(of: model.reloadPlaylists) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
#endif
|
||||
|
||||
@@ -29,7 +29,10 @@ struct SearchTextField: View {
|
||||
.opacity(0.8)
|
||||
#endif
|
||||
TextField("Search...", text: $state.queryText) {
|
||||
state.changeQuery { query in query.query = state.queryText }
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
navigation.hideKeyboard()
|
||||
}
|
||||
recents.addQuery(state.queryText, navigation: navigation)
|
||||
}
|
||||
.onChange(of: state.queryText) { _ in
|
||||
|
||||
@@ -75,6 +75,7 @@ struct SearchSuggestions: View {
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
state.fieldIsFocused = false
|
||||
navigation.hideKeyboard()
|
||||
}
|
||||
|
||||
recents.addQuery(state.queryText, navigation: navigation)
|
||||
|
||||
@@ -234,7 +234,7 @@ struct SearchView: View {
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#else
|
||||
VerticalCells(items: items)
|
||||
VerticalCells(items: items, allowEmpty: state.query.isEmpty)
|
||||
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
|
||||
#endif
|
||||
|
||||
@@ -353,7 +353,7 @@ struct SearchView: View {
|
||||
|
||||
private var removeAllButton: some View {
|
||||
Button {
|
||||
recents.clearQueries()
|
||||
recents.clear()
|
||||
recentsChanged.toggle()
|
||||
} label: {
|
||||
Label("Remove All", systemImage: "trash.fill")
|
||||
|
||||
@@ -14,7 +14,6 @@ struct PlayerSettings: View {
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showHistoryInPlayer) private var showHistory
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.showChannelSubscribers) private var channelSubscribers
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
#if os(iOS)
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@@ -83,7 +82,6 @@ struct PlayerSettings: View {
|
||||
|
||||
keywordsToggle
|
||||
showHistoryToggle
|
||||
channelSubscribersToggle
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Picture in Picture")) {
|
||||
@@ -196,10 +194,6 @@ struct PlayerSettings: View {
|
||||
Toggle("Show history", isOn: $showHistory)
|
||||
}
|
||||
|
||||
private var channelSubscribersToggle: some View {
|
||||
Toggle("Show subscribers count", isOn: $channelSubscribers)
|
||||
}
|
||||
|
||||
private var pauseOnHidingPlayerToggle: some View {
|
||||
Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ struct HorizontalCells: View {
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 20) {
|
||||
ForEach(items) { item in
|
||||
ForEach(contentItems) { item in
|
||||
ContentItemView(item: item)
|
||||
.environment(\.horizontalCells, true)
|
||||
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
|
||||
@@ -36,6 +36,14 @@ struct HorizontalCells: View {
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
items.isEmpty ? placeholders : items
|
||||
}
|
||||
|
||||
var placeholders: [ContentItem] {
|
||||
(0 ..< 9).map { _ in .init() }
|
||||
}
|
||||
|
||||
func loadMoreContentItemsIfNeeded(current item: ContentItem) {
|
||||
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
|
||||
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
|
||||
|
||||
@@ -9,11 +9,12 @@ struct VerticalCells: View {
|
||||
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
|
||||
|
||||
var items = [ContentItem]()
|
||||
var allowEmpty = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical, showsIndicators: scrollViewShowsIndicators) {
|
||||
LazyVGrid(columns: columns, alignment: .center) {
|
||||
ForEach(items.sorted { $0 < $1 }) { item in
|
||||
ForEach(contentItems) { item in
|
||||
ContentItemView(item: item)
|
||||
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
|
||||
}
|
||||
@@ -27,6 +28,14 @@ struct VerticalCells: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
items.isEmpty ? (allowEmpty ? items : placeholders) : items.sorted { $0 < $1 }
|
||||
}
|
||||
|
||||
var placeholders: [ContentItem] {
|
||||
(0 ..< 9).map { _ in .init() }
|
||||
}
|
||||
|
||||
func loadMoreContentItemsIfNeeded(current item: ContentItem) {
|
||||
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
|
||||
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
|
||||
@@ -36,7 +45,7 @@ struct VerticalCells: View {
|
||||
|
||||
var columns: [GridItem] {
|
||||
#if os(tvOS)
|
||||
items.count < 3 ? Array(repeating: GridItem(.fixed(500)), count: [items.count, 1].max()!) : adaptiveItem
|
||||
contentItems.count < 3 ? Array(repeating: GridItem(.fixed(500)), count: [contentItems.count, 1].max()!) : adaptiveItem
|
||||
#else
|
||||
adaptiveItem
|
||||
#endif
|
||||
|
||||
@@ -6,6 +6,7 @@ struct VideoCell: View {
|
||||
private var video: Video
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@@ -13,7 +14,9 @@ struct VideoCell: View {
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnails
|
||||
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@@ -59,6 +62,10 @@ struct VideoCell: View {
|
||||
}
|
||||
|
||||
private func playAction() {
|
||||
guard video.videoID != Video.fixtureID else {
|
||||
return
|
||||
}
|
||||
|
||||
if watchingNow {
|
||||
if !player.playingInPictureInPicture {
|
||||
player.show()
|
||||
@@ -147,9 +154,7 @@ struct VideoCell: View {
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if !channelOnThumbnail {
|
||||
Text(video.channel.name)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
channelButton(badge: false)
|
||||
}
|
||||
|
||||
if additionalDetailsAvailable {
|
||||
@@ -231,9 +236,7 @@ struct VideoCell: View {
|
||||
.frame(minHeight: 40, alignment: .top)
|
||||
#endif
|
||||
if !channelOnThumbnail {
|
||||
Text(video.channel.name)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
channelButton(badge: false)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
@@ -289,6 +292,29 @@ struct VideoCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func channelButton(badge: Bool = true) -> some View {
|
||||
Button {
|
||||
NavigationModel.openChannel(
|
||||
video.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
} label: {
|
||||
if badge {
|
||||
DetailBadge(text: video.author, style: .prominent)
|
||||
.foregroundColor(.primary)
|
||||
} else {
|
||||
Text(video.channel.name)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("\(video.channel.name) Channel")
|
||||
}
|
||||
|
||||
private var additionalDetailsAvailable: Bool {
|
||||
video.publishedDate != nil || video.views != 0 ||
|
||||
(!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
|
||||
@@ -325,7 +351,7 @@ struct VideoCell: View {
|
||||
Spacer()
|
||||
|
||||
if channelOnThumbnail {
|
||||
DetailBadge(text: video.author, style: .prominent)
|
||||
channelButton()
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
@@ -415,7 +441,7 @@ struct VideoCell: View {
|
||||
stoppedAt.isFinite,
|
||||
let stoppedAtFormatted = stoppedAt.formattedAsPlaybackTime()
|
||||
{
|
||||
if watch?.videoDuration ?? 0 > 0 {
|
||||
if (watch?.videoDuration ?? 0) > 0 {
|
||||
videoTime = watch!.videoDuration.formattedAsPlaybackTime() ?? "?"
|
||||
}
|
||||
return "\(stoppedAtFormatted) / \(videoTime)"
|
||||
|
||||
@@ -6,6 +6,7 @@ struct ChannelVideosView: View {
|
||||
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
@State private var subscriptionToggleButtonDisabled = false
|
||||
|
||||
@StateObject private var store = Store<Channel>()
|
||||
|
||||
@@ -146,17 +147,25 @@ struct ChannelVideosView: View {
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn {
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
Button("Unsubscribe") {
|
||||
navigation.presentUnsubscribeAlert(channel)
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
subscriptions.unsubscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
subscriptions.subscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(subscriptionToggleButtonDisabled)
|
||||
}
|
||||
|
||||
private var contentItem: ContentItem {
|
||||
|
||||
@@ -11,8 +11,10 @@ struct ContentItemView: View {
|
||||
ChannelPlaylistCell(playlist: item.playlist)
|
||||
case .channel:
|
||||
ChannelCell(channel: item.channel)
|
||||
default:
|
||||
case .video:
|
||||
VideoCell(video: item.video)
|
||||
default:
|
||||
PlaceholderCell()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
Shared/Views/PlaceholderCell.swift
Normal file
16
Shared/Views/PlaceholderCell.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlaceholderCell: View {
|
||||
var body: some View {
|
||||
VideoCell(video: .fixture)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaceholderCell_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlaceholderCell()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,35 @@ 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)
|
||||
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] {
|
||||
@@ -22,6 +48,14 @@ struct PlaylistVideosView: View {
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
VerticalCells(items: contentItems)
|
||||
.onAppear {
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
resource?.load()
|
||||
}
|
||||
}
|
||||
.onChange(of: model.reloadPlaylists) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("\(playlist.title) Playlist")
|
||||
#endif
|
||||
|
||||
@@ -33,6 +33,12 @@ struct VideoContextMenuView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if video.videoID != Video.fixtureID {
|
||||
contextMenu
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var contextMenu: some View {
|
||||
if saveHistory {
|
||||
Section {
|
||||
if let watchedAtString = watchedAtString {
|
||||
@@ -195,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")
|
||||
}
|
||||
|
||||
@@ -514,6 +514,9 @@
|
||||
37D526E02720AC4400ED2F5E /* VideosAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */; };
|
||||
37D526E32720B4BE00ED2F5E /* View+SwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */; };
|
||||
37D526E42720B4BE00ED2F5E /* View+SwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */; };
|
||||
37D6F3A127ECA1FF006FE38B /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D6F3A027ECA1FF006FE38B /* Defaults+Workaround.swift */; };
|
||||
37D6F3A227ECA1FF006FE38B /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D6F3A027ECA1FF006FE38B /* Defaults+Workaround.swift */; };
|
||||
37D6F3A327ECA1FF006FE38B /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D6F3A027ECA1FF006FE38B /* Defaults+Workaround.swift */; };
|
||||
37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; };
|
||||
37DD87C8271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; };
|
||||
37DD87C9271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; };
|
||||
@@ -587,6 +590,9 @@
|
||||
37FD43E42704847C0073EE42 /* View+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD43E22704847C0073EE42 /* View+Fixtures.swift */; };
|
||||
37FD43E52704847C0073EE42 /* View+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD43E22704847C0073EE42 /* View+Fixtures.swift */; };
|
||||
37FD43F02704A9C00073EE42 /* RecentsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */; };
|
||||
37FEF11327EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
|
||||
37FEF11427EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
|
||||
37FEF11527EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
|
||||
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
37FFC441272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
37FFC442272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
@@ -791,6 +797,7 @@
|
||||
37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
37D526DD2720AC4400ED2F5E /* VideosAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosAPI.swift; sourceTree = "<group>"; };
|
||||
37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+SwipeGesture.swift"; sourceTree = "<group>"; };
|
||||
37D6F3A027ECA1FF006FE38B /* Defaults+Workaround.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Defaults+Workaround.swift"; sourceTree = "<group>"; };
|
||||
37D9169A27388A81002B1BAA /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
|
||||
37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStreams.swift; sourceTree = "<group>"; };
|
||||
37DD9DA22785BBC900539416 /* NoCommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCommentsView.swift; sourceTree = "<group>"; };
|
||||
@@ -818,6 +825,7 @@
|
||||
37FB285D272225E800A57617 /* ContentItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentItemView.swift; sourceTree = "<group>"; };
|
||||
37FD43DB270470B70073EE42 /* InstancesSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesSettings.swift; sourceTree = "<group>"; };
|
||||
37FD43E22704847C0073EE42 /* View+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Fixtures.swift"; sourceTree = "<group>"; };
|
||||
37FEF11227EFD8580033912F /* PlaceholderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderCell.swift; sourceTree = "<group>"; };
|
||||
37FFC43F272734C3009FFD26 /* Throttle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttle.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -1001,6 +1009,7 @@
|
||||
37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */,
|
||||
37E70922271CD43000D34DDE /* WelcomeScreen.swift */,
|
||||
3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */,
|
||||
37FEF11227EFD8580033912F /* PlaceholderCell.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -1257,6 +1266,7 @@
|
||||
371AAE2826CEC7D900901972 /* Views */,
|
||||
375168D52700FAFF008F96A6 /* Debounce.swift */,
|
||||
372915E52687E3B900F5A35B /* Defaults.swift */,
|
||||
37D6F3A027ECA1FF006FE38B /* Defaults+Workaround.swift */,
|
||||
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */,
|
||||
3729037D2739E47400EA99F6 /* MenuCommands.swift */,
|
||||
37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */,
|
||||
@@ -2009,12 +2019,14 @@
|
||||
37484C2526FC83E000287258 /* InstanceForm.swift in Sources */,
|
||||
37DD9DBD2785D60300539416 /* ScrollViewMatcher.swift in Sources */,
|
||||
37B767DB2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
|
||||
37D6F3A127ECA1FF006FE38B /* Defaults+Workaround.swift in Sources */,
|
||||
3788AC2726F6840700F6BAA9 /* FavoriteItemView.swift in Sources */,
|
||||
375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */,
|
||||
37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */,
|
||||
37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */,
|
||||
373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */,
|
||||
37141673267A8E10006CA35D /* Country.swift in Sources */,
|
||||
37FEF11327EFD8580033912F /* PlaceholderCell.swift in Sources */,
|
||||
37B2631A2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
|
||||
3748186E26A769D60084E870 /* DetailBadge.swift in Sources */,
|
||||
376BE50B27349108009AD608 /* BrowsingSettings.swift in Sources */,
|
||||
@@ -2099,6 +2111,7 @@
|
||||
37484C3226FCB8F900287258 /* AccountValidator.swift in Sources */,
|
||||
378AE944274EF00A006A4EE1 /* Color+Background.swift in Sources */,
|
||||
37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
|
||||
37D6F3A227ECA1FF006FE38B /* Defaults+Workaround.swift in Sources */,
|
||||
37EAD870267B9ED100D9E01B /* Segment.swift in Sources */,
|
||||
3788AC2826F6840700F6BAA9 /* FavoriteItemView.swift in Sources */,
|
||||
378AE93A274EDFAF006A4EE1 /* Badge+Backport.swift in Sources */,
|
||||
@@ -2160,6 +2173,7 @@
|
||||
3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */,
|
||||
3784B23E2728B85300B09468 /* ShareButton.swift in Sources */,
|
||||
37BE0BDA26A214630092E2DB /* PlayerViewController.swift in Sources */,
|
||||
37FEF11427EFD8580033912F /* PlaceholderCell.swift in Sources */,
|
||||
37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
|
||||
374108D1272B11B2006C5CC8 /* PictureInPictureDelegate.swift in Sources */,
|
||||
37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
|
||||
@@ -2379,11 +2393,13 @@
|
||||
37CEE4C32677B697005A1EFE /* Stream.swift in Sources */,
|
||||
37F64FE626FE70A60081B69E /* RedrawOnModifier.swift in Sources */,
|
||||
37B2631C2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
|
||||
37D6F3A327ECA1FF006FE38B /* Defaults+Workaround.swift in Sources */,
|
||||
37484C2B26FC83FF00287258 /* AccountForm.swift in Sources */,
|
||||
37FB2860272225E800A57617 /* ContentItemView.swift in Sources */,
|
||||
374C053727242D9F009BDDBE /* SponsorBlockSettings.swift in Sources */,
|
||||
37C069802725C8D400F7F6CB /* CMTime+DefaultTimescale.swift in Sources */,
|
||||
3711404126B206A6005B3555 /* SearchModel.swift in Sources */,
|
||||
37FEF11527EFD8580033912F /* PlaceholderCell.swift in Sources */,
|
||||
37FD43F02704A9C00073EE42 /* RecentsModel.swift in Sources */,
|
||||
379775952689365600DD52A8 /* Array+Next.swift in Sources */,
|
||||
3705B180267B4DFB00704544 /* TrendingCountry.swift in Sources */,
|
||||
@@ -2458,7 +2474,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -2471,7 +2487,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -2492,7 +2508,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -2505,7 +2521,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -2524,7 +2540,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
@@ -2536,7 +2552,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -2556,7 +2572,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
@@ -2568,7 +2584,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -2719,7 +2735,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -2735,7 +2751,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2751,7 +2767,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -2767,7 +2783,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -2787,7 +2803,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -2802,7 +2818,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = macosx;
|
||||
@@ -2820,7 +2836,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -2835,7 +2851,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = macosx;
|
||||
@@ -2951,7 +2967,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -2966,7 +2982,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = appletvos;
|
||||
@@ -2983,7 +2999,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -2998,7 +3014,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = appletvos;
|
||||
|
||||
@@ -1,133 +1,131 @@
|
||||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "Alamofire",
|
||||
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864",
|
||||
"version": "5.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Defaults",
|
||||
"repositoryURL": "https://github.com/sindresorhus/Defaults",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "55f3302c3ab30a8760f10042d0ebc0a6907f865a",
|
||||
"version": "6.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "libwebp",
|
||||
"repositoryURL": "https://github.com/SDWebImage/libwebp-Xcode.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "2b3b43faaef54d1b897482428428357b7f7cd08b",
|
||||
"version": "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "PINCache",
|
||||
"repositoryURL": "https://github.com/pinterest/PINCache",
|
||||
"state": {
|
||||
"branch": "master",
|
||||
"revision": "9ca06045b5aff12ee8c0ef5880aa8469c4896144",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "PINOperation",
|
||||
"repositoryURL": "https://github.com/pinterest/PINOperation.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "44d8ca154a4e75a028a5548c31ff3a53b90cef15",
|
||||
"version": "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SDWebImage",
|
||||
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "0fff0d7505b5306348263ea64fcc561253bbeb21",
|
||||
"version": "5.12.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SDWebImagePINPlugin",
|
||||
"repositoryURL": "https://github.com/SDWebImage/SDWebImagePINPlugin.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "bd73a4fb30352ec311303d811559c9c46df4caa4",
|
||||
"version": "0.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SDWebImageSwiftUI",
|
||||
"repositoryURL": "https://github.com/SDWebImage/SDWebImageSwiftUI.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "cd8625b7cf11a97698e180d28bb7d5d357196678",
|
||||
"version": "2.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SDWebImageWebPCoder",
|
||||
"repositoryURL": "https://github.com/SDWebImage/SDWebImageWebPCoder.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "95a6838df13bc08d8064cf7e048b787b6e52348d",
|
||||
"version": "0.8.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Siesta",
|
||||
"repositoryURL": "https://github.com/bustoutsolutions/siesta",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "43f34046ebb5beb6802200353c473af303bbc31e",
|
||||
"version": "1.5.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Sparkle",
|
||||
"repositoryURL": "https://github.com/sparkle-project/Sparkle",
|
||||
"state": {
|
||||
"branch": "2.x",
|
||||
"revision": "71fc8d7b1182d24879edacefccb06151e99c34fe",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-log",
|
||||
"repositoryURL": "https://github.com/apple/swift-log.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
|
||||
"version": "1.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Introspect",
|
||||
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2",
|
||||
"version": "0.1.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftyJSON",
|
||||
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
|
||||
"version": "5.0.1"
|
||||
}
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "alamofire",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Alamofire/Alamofire.git",
|
||||
"state" : {
|
||||
"revision" : "354dda32d89fc8cd4f5c46487f64957d355f53d8",
|
||||
"version" : "5.6.1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"identity" : "defaults",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sindresorhus/Defaults",
|
||||
"state" : {
|
||||
"revision" : "119f654d44f7b90f00dc11f7dd1c94a36f12576b",
|
||||
"version" : "6.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "libwebp-xcode",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/libwebp-Xcode.git",
|
||||
"state" : {
|
||||
"revision" : "2b3b43faaef54d1b897482428428357b7f7cd08b",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "pincache",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pinterest/PINCache",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "9ca06045b5aff12ee8c0ef5880aa8469c4896144"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "pinoperation",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pinterest/PINOperation.git",
|
||||
"state" : {
|
||||
"revision" : "44d8ca154a4e75a028a5548c31ff3a53b90cef15",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage.git",
|
||||
"state" : {
|
||||
"revision" : "2e63d0061da449ad0ed130768d05dceb1496de44",
|
||||
"version" : "5.12.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimagepinplugin",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImagePINPlugin.git",
|
||||
"state" : {
|
||||
"revision" : "bd73a4fb30352ec311303d811559c9c46df4caa4",
|
||||
"version" : "0.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimageswiftui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git",
|
||||
"state" : {
|
||||
"revision" : "cd8625b7cf11a97698e180d28bb7d5d357196678",
|
||||
"version" : "2.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimagewebpcoder",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git",
|
||||
"state" : {
|
||||
"revision" : "95a6838df13bc08d8064cf7e048b787b6e52348d",
|
||||
"version" : "0.8.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "siesta",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/bustoutsolutions/siesta",
|
||||
"state" : {
|
||||
"revision" : "43f34046ebb5beb6802200353c473af303bbc31e",
|
||||
"version" : "1.5.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"branch" : "2.x",
|
||||
"revision" : "503830bf24f679b7a9199be85ee7c8d012528d09"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
|
||||
"version" : "1.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-introspect",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/siteline/SwiftUI-Introspect.git",
|
||||
"state" : {
|
||||
"revision" : "f2616860a41f9d9932da412a8978fec79c06fe24",
|
||||
"version" : "0.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftyjson",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SwiftyJSON/SwiftyJSON.git",
|
||||
"state" : {
|
||||
"revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
|
||||
"version" : "5.0.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user