mirror of
https://github.com/yattee/yattee.git
synced 2025-12-15 04:28:14 +00:00
Compare commits
14 Commits
v1.2
...
v1.3-beta.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6e1f8148c | ||
|
|
23e2e216db | ||
|
|
d7058b46d3 | ||
|
|
c4ca5eb4c7 | ||
|
|
02e66e4520 | ||
|
|
de09f9dd52 | ||
|
|
4fab7c2c16 | ||
|
|
f609ed1ed4 | ||
|
|
201e91a3cc | ||
|
|
923f0c0356 | ||
|
|
008cd1553d | ||
|
|
8d49934fe8 | ||
|
|
a4c43d9a3a | ||
|
|
310ed3b12b |
@@ -25,11 +25,17 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
func setAccount(_ account: Account) {
|
func setAccount(_ account: Account) {
|
||||||
self.account = account
|
self.account = account
|
||||||
|
signedIn = false
|
||||||
|
|
||||||
|
if account.anonymous {
|
||||||
|
validInstance = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
validInstance = false
|
validInstance = false
|
||||||
signedIn = !account.anonymous
|
|
||||||
|
|
||||||
configure()
|
configure()
|
||||||
|
validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() {
|
func validate() {
|
||||||
@@ -80,11 +86,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||||
content.json.arrayValue.map(InvidiousAPI.extractVideo)
|
content.json.arrayValue.map(self.extractVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||||
content.json.arrayValue.map(InvidiousAPI.extractVideo)
|
content.json.arrayValue.map(self.extractVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
|
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
|
||||||
@@ -92,11 +98,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
let type = $0.dictionaryValue["type"]?.stringValue
|
let type = $0.dictionaryValue["type"]?.stringValue
|
||||||
|
|
||||||
if type == "channel" {
|
if type == "channel" {
|
||||||
return ContentItem(channel: InvidiousAPI.extractChannel(from: $0))
|
return ContentItem(channel: self.extractChannel(from: $0))
|
||||||
} else if type == "playlist" {
|
} else if type == "playlist" {
|
||||||
return ContentItem(playlist: InvidiousAPI.extractChannelPlaylist(from: $0))
|
return ContentItem(playlist: self.extractChannelPlaylist(from: $0))
|
||||||
}
|
}
|
||||||
return ContentItem(video: InvidiousAPI.extractVideo(from: $0))
|
return ContentItem(video: self.extractVideo(from: $0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +115,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
|
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
|
||||||
content.json.arrayValue.map(Playlist.init)
|
content.json.arrayValue.map(self.extractPlaylist)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
||||||
Playlist(content.json)
|
self.extractPlaylist(from: content.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
||||||
@@ -123,30 +129,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||||
if let feedVideos = content.json.dictionaryValue["videos"] {
|
if let feedVideos = content.json.dictionaryValue["videos"] {
|
||||||
return feedVideos.arrayValue.map(InvidiousAPI.extractVideo)
|
return feedVideos.arrayValue.map(self.extractVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
||||||
content.json.arrayValue.map(InvidiousAPI.extractChannel)
|
content.json.arrayValue.map(self.extractChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
|
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
|
||||||
InvidiousAPI.extractChannel(from: content.json)
|
self.extractChannel(from: content.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||||
content.json.arrayValue.map(InvidiousAPI.extractVideo)
|
content.json.arrayValue.map(self.extractVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
||||||
InvidiousAPI.extractChannelPlaylist(from: content.json)
|
self.extractChannelPlaylist(from: content.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
||||||
InvidiousAPI.extractVideo(from: content.json)
|
self.extractVideo(from: content.json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +297,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return AVURLAsset(url: url)
|
return AVURLAsset(url: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func extractVideo(from json: JSON) -> Video {
|
func extractVideo(from json: JSON) -> Video {
|
||||||
let indexID: String?
|
let indexID: String?
|
||||||
var id: Video.ID
|
var id: Video.ID
|
||||||
var publishedAt: Date?
|
var publishedAt: Date?
|
||||||
@@ -334,8 +340,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func extractChannel(from json: JSON) -> Channel {
|
func extractChannel(from json: JSON) -> Channel {
|
||||||
let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")"
|
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? ""
|
||||||
|
|
||||||
|
// append https protocol to unproxied thumbnail URL if it's missing
|
||||||
|
if thumbnailURL.count > 2,
|
||||||
|
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//"
|
||||||
|
{
|
||||||
|
thumbnailURL = "https:\(thumbnailURL)"
|
||||||
|
}
|
||||||
|
|
||||||
return Channel(
|
return Channel(
|
||||||
id: json["authorId"].stringValue,
|
id: json["authorId"].stringValue,
|
||||||
@@ -343,33 +356,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
thumbnailURL: URL(string: thumbnailURL),
|
thumbnailURL: URL(string: thumbnailURL),
|
||||||
subscriptionsCount: json["subCount"].int,
|
subscriptionsCount: json["subCount"].int,
|
||||||
subscriptionsText: json["subCountText"].string,
|
subscriptionsText: json["subCountText"].string,
|
||||||
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(InvidiousAPI.extractVideo) ?? []
|
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
||||||
let details = json.dictionaryValue
|
let details = json.dictionaryValue
|
||||||
return ChannelPlaylist(
|
return ChannelPlaylist(
|
||||||
id: details["playlistId"]!.stringValue,
|
id: details["playlistId"]!.stringValue,
|
||||||
title: details["title"]!.stringValue,
|
title: details["title"]!.stringValue,
|
||||||
thumbnailURL: details["playlistThumbnail"]?.url,
|
thumbnailURL: details["playlistThumbnail"]?.url,
|
||||||
channel: extractChannel(from: json),
|
channel: extractChannel(from: json),
|
||||||
videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? []
|
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||||
details["videoThumbnails"].arrayValue.map { json in
|
details["videoThumbnails"].arrayValue.map { json in
|
||||||
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
|
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractStreams(from json: JSON) -> [Stream] {
|
private func extractStreams(from json: JSON) -> [Stream] {
|
||||||
extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
|
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||||
streams.map {
|
streams.map {
|
||||||
SingleAssetStream(
|
SingleAssetStream(
|
||||||
avAsset: AVURLAsset(url: $0["url"].url!),
|
avAsset: AVURLAsset(url: $0["url"].url!),
|
||||||
@@ -380,7 +393,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||||
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
|
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||||
guard audioAssetURL != nil else {
|
guard audioAssetURL != nil else {
|
||||||
return []
|
return []
|
||||||
@@ -399,10 +412,20 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractRelated(from content: JSON) -> [Video] {
|
private func extractRelated(from content: JSON) -> [Video] {
|
||||||
content
|
content
|
||||||
.dictionaryValue["recommendedVideos"]?
|
.dictionaryValue["recommendedVideos"]?
|
||||||
.arrayValue
|
.arrayValue
|
||||||
.compactMap(extractVideo(from:)) ?? []
|
.compactMap(extractVideo(from:)) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func extractPlaylist(from content: JSON) -> Playlist {
|
||||||
|
.init(
|
||||||
|
id: content["playlistId"].stringValue,
|
||||||
|
title: content["title"].stringValue,
|
||||||
|
visibility: content["isListed"].boolValue ? .public : .private,
|
||||||
|
updated: content["updated"].doubleValue,
|
||||||
|
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,23 +40,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
||||||
PipedAPI.extractChannel(from: content.json)
|
self.extractChannel(from: content.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
|
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
|
||||||
PipedAPI.extractChannelPlaylist(from: content.json)
|
self.extractChannelPlaylist(from: content.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||||
PipedAPI.extractVideo(from: content.json)
|
self.extractVideo(from: content.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
|
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
|
||||||
PipedAPI.extractVideos(from: content.json)
|
self.extractVideos(from: content.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
|
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
|
||||||
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
|
self.extractContentItems(from: content.json.dictionaryValue["items"]!)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
|
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
|
||||||
@@ -64,16 +64,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
||||||
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
|
content.json.arrayValue.map { self.extractChannel(from: $0)! }
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||||
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
|
content.json.arrayValue.map { self.extractVideo(from: $0)! }
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||||
let details = content.json.dictionaryValue
|
let details = content.json.dictionaryValue
|
||||||
let comments = details["comments"]?.arrayValue.map { PipedAPI.extractComment(from: $0)! } ?? []
|
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
|
||||||
let nextPage = details["nextpage"]?.stringValue
|
let nextPage = details["nextpage"]?.stringValue
|
||||||
let disabled = details["disabled"]?.boolValue ?? false
|
let disabled = details["disabled"]?.boolValue ?? false
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func needsAuthorization(_ url: URL) -> Bool {
|
func needsAuthorization(_ url: URL) -> Bool {
|
||||||
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateToken() {
|
func updateToken() {
|
||||||
@@ -190,7 +190,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
"**\(path)"
|
"**\(path)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractContentItem(from content: JSON) -> ContentItem? {
|
private func extractContentItem(from content: JSON) -> ContentItem? {
|
||||||
let details = content.dictionaryValue
|
let details = content.dictionaryValue
|
||||||
let url: String! = details["url"]?.string
|
let url: String! = details["url"]?.string
|
||||||
|
|
||||||
@@ -210,17 +210,17 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case .video:
|
case .video:
|
||||||
if let video = PipedAPI.extractVideo(from: content) {
|
if let video = extractVideo(from: content) {
|
||||||
return ContentItem(video: video)
|
return ContentItem(video: video)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .playlist:
|
case .playlist:
|
||||||
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
|
if let playlist = extractChannelPlaylist(from: content) {
|
||||||
return ContentItem(playlist: playlist)
|
return ContentItem(playlist: playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .channel:
|
case .channel:
|
||||||
if let channel = PipedAPI.extractChannel(from: content) {
|
if let channel = extractChannel(from: content) {
|
||||||
return ContentItem(channel: channel)
|
return ContentItem(channel: channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,11 +228,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractContentItems(from content: JSON) -> [ContentItem] {
|
private func extractContentItems(from content: JSON) -> [ContentItem] {
|
||||||
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
|
content.arrayValue.compactMap { extractContentItem(from: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractChannel(from content: JSON) -> Channel? {
|
private func extractChannel(from content: JSON) -> Channel? {
|
||||||
let attributes = content.dictionaryValue
|
let attributes = content.dictionaryValue
|
||||||
guard let id = attributes["id"]?.stringValue ??
|
guard let id = attributes["id"]?.stringValue ??
|
||||||
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
|
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
|
||||||
@@ -244,25 +244,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
var videos = [Video]()
|
var videos = [Video]()
|
||||||
if let relatedStreams = attributes["relatedStreams"] {
|
if let relatedStreams = attributes["relatedStreams"] {
|
||||||
videos = PipedAPI.extractVideos(from: relatedStreams)
|
videos = extractVideos(from: relatedStreams)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? ""
|
||||||
|
let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url
|
||||||
|
|
||||||
return Channel(
|
return Channel(
|
||||||
id: id,
|
id: id,
|
||||||
name: attributes["name"]!.stringValue,
|
name: name,
|
||||||
thumbnailURL: attributes["thumbnail"]?.url,
|
thumbnailURL: thumbnailURL,
|
||||||
subscriptionsCount: subscriptionsCount,
|
subscriptionsCount: subscriptionsCount,
|
||||||
videos: videos
|
videos: videos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
|
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
|
||||||
let details = json.dictionaryValue
|
let details = json.dictionaryValue
|
||||||
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
|
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
|
||||||
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
|
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
|
||||||
var videos = [Video]()
|
var videos = [Video]()
|
||||||
if let relatedStreams = details["relatedStreams"] {
|
if let relatedStreams = details["relatedStreams"] {
|
||||||
videos = PipedAPI.extractVideos(from: relatedStreams)
|
videos = extractVideos(from: relatedStreams)
|
||||||
}
|
}
|
||||||
return ChannelPlaylist(
|
return ChannelPlaylist(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -274,7 +277,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractVideo(from content: JSON) -> Video? {
|
private func extractVideo(from content: JSON) -> Video? {
|
||||||
let details = content.dictionaryValue
|
let details = content.dictionaryValue
|
||||||
let url = details["url"]?.string
|
let url = details["url"]?.string
|
||||||
|
|
||||||
@@ -287,7 +290,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
|
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
|
||||||
|
|
||||||
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||||
if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) {
|
if let url = buildThumbnailURL(from: content, quality: $0) {
|
||||||
return Thumbnail(url: url, quality: $0)
|
return Thumbnail(url: url, quality: $0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,18 +298,20 @@ 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 published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
|
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
|
||||||
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
|
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
|
||||||
|
|
||||||
return Video(
|
return Video(
|
||||||
videoID: PipedAPI.extractID(from: content),
|
videoID: extractID(from: content),
|
||||||
title: details["title"]!.stringValue,
|
title: details["title"]!.stringValue,
|
||||||
author: author,
|
author: author,
|
||||||
length: details["duration"]!.doubleValue,
|
length: details["duration"]!.doubleValue,
|
||||||
published: published,
|
published: published,
|
||||||
views: details["views"]!.intValue,
|
views: details["views"]!.intValue,
|
||||||
description: PipedAPI.extractDescription(from: content),
|
description: extractDescription(from: content),
|
||||||
channel: Channel(id: channelId, name: author),
|
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
|
||||||
thumbnails: thumbnails,
|
thumbnails: thumbnails,
|
||||||
likes: details["likes"]?.int,
|
likes: details["likes"]?.int,
|
||||||
dislikes: details["dislikes"]?.int,
|
dislikes: details["dislikes"]?.int,
|
||||||
@@ -315,16 +320,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractID(from content: JSON) -> Video.ID {
|
private func extractID(from content: JSON) -> Video.ID {
|
||||||
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
|
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
|
||||||
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
|
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractThumbnailURL(from content: JSON) -> URL? {
|
private func extractThumbnailURL(from content: JSON) -> URL? {
|
||||||
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
|
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
|
private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
|
||||||
let thumbnailURL = extractThumbnailURL(from: content)
|
let thumbnailURL = extractThumbnailURL(from: content)
|
||||||
guard !thumbnailURL.isNil else {
|
guard !thumbnailURL.isNil else {
|
||||||
return nil
|
return nil
|
||||||
@@ -337,7 +342,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
)!
|
)!
|
||||||
}
|
}
|
||||||
|
|
||||||
private static 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
|
||||||
}
|
}
|
||||||
@@ -359,22 +364,22 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return description
|
return description
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractVideos(from content: JSON) -> [Video] {
|
private func extractVideos(from content: JSON) -> [Video] {
|
||||||
content.arrayValue.compactMap(extractVideo(from:))
|
content.arrayValue.compactMap(extractVideo(from:))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractStreams(from content: JSON) -> [Stream] {
|
private func extractStreams(from content: JSON) -> [Stream] {
|
||||||
var streams = [Stream]()
|
var streams = [Stream]()
|
||||||
|
|
||||||
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
||||||
streams.append(Stream(hlsURL: hlsURL))
|
streams.append(Stream(hlsURL: hlsURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
|
guard let audioStream = compatibleAudioStreams(from: content).first else {
|
||||||
return streams
|
return streams
|
||||||
}
|
}
|
||||||
|
|
||||||
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
|
let videoStreams = compatibleVideoStream(from: content)
|
||||||
|
|
||||||
videoStreams.forEach { videoStream in
|
videoStreams.forEach { videoStream in
|
||||||
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||||
@@ -397,14 +402,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return streams
|
return streams
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractRelated(from content: JSON) -> [Video] {
|
private func extractRelated(from content: JSON) -> [Video] {
|
||||||
content
|
content
|
||||||
.dictionaryValue["relatedStreams"]?
|
.dictionaryValue["relatedStreams"]?
|
||||||
.arrayValue
|
.arrayValue
|
||||||
.compactMap(extractVideo(from:)) ?? []
|
.compactMap(extractVideo(from:)) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
private func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
||||||
content
|
content
|
||||||
.dictionaryValue["audioStreams"]?
|
.dictionaryValue["audioStreams"]?
|
||||||
.arrayValue
|
.arrayValue
|
||||||
@@ -414,14 +419,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
} ?? []
|
} ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
|
private func compatibleVideoStream(from content: JSON) -> [JSON] {
|
||||||
content
|
content
|
||||||
.dictionaryValue["videoStreams"]?
|
.dictionaryValue["videoStreams"]?
|
||||||
.arrayValue
|
.arrayValue
|
||||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractComment(from content: JSON) -> Comment? {
|
private func extractComment(from content: JSON) -> Comment? {
|
||||||
let details = content.dictionaryValue
|
let details = content.dictionaryValue
|
||||||
let author = details["author"]?.stringValue ?? ""
|
let author = details["author"]?.stringValue ?? ""
|
||||||
let commentorUrl = details["commentorUrl"]?.stringValue
|
let commentorUrl = details["commentorUrl"]?.stringValue
|
||||||
|
|||||||
@@ -55,11 +55,12 @@ extension VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||||
guard let frontendHost = frontendHost ?? account.instance.frontendHost else {
|
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||||
|
var urlComponents = account?.instance?.urlComponents
|
||||||
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var urlComponents = account.instance.urlComponents
|
|
||||||
urlComponents.host = frontendHost
|
urlComponents.host = frontendHost
|
||||||
|
|
||||||
var queryItems = [URLQueryItem]()
|
var queryItems = [URLQueryItem]()
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ struct Channel: Identifiable, Hashable {
|
|||||||
self.videos = videos
|
self.videos = videos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var detailsLoaded: Bool {
|
||||||
|
!subscriptionsString.isNil
|
||||||
|
}
|
||||||
|
|
||||||
var subscriptionsString: String? {
|
var subscriptionsString: String? {
|
||||||
if subscriptionsCount != nil, subscriptionsCount! > 0 {
|
if subscriptionsCount != nil, subscriptionsCount! > 0 {
|
||||||
return subscriptionsCount!.formattedAsAbbreviation()
|
return subscriptionsCount!.formattedAsAbbreviation()
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ final class CommentsModel: ObservableObject {
|
|||||||
@Published var nextPage: String?
|
@Published var nextPage: String?
|
||||||
@Published var firstPage = true
|
@Published var firstPage = true
|
||||||
|
|
||||||
@Published var loaded = true
|
@Published var loading = false
|
||||||
|
@Published var loaded = false
|
||||||
@Published var disabled = false
|
@Published var disabled = false
|
||||||
|
|
||||||
@Published var replies = [Comment]()
|
@Published var replies = [Comment]()
|
||||||
@@ -18,16 +19,16 @@ final class CommentsModel: ObservableObject {
|
|||||||
var accounts: AccountsModel!
|
var accounts: AccountsModel!
|
||||||
var player: PlayerModel!
|
var player: PlayerModel!
|
||||||
|
|
||||||
var instance: Instance? {
|
static var instance: Instance? {
|
||||||
InstancesModel.find(Defaults[.commentsInstanceID])
|
InstancesModel.find(Defaults[.commentsInstanceID])
|
||||||
}
|
}
|
||||||
|
|
||||||
var api: VideosAPI? {
|
var api: VideosAPI? {
|
||||||
instance.isNil ? nil : PipedAPI(account: instance!.anonymousAccount)
|
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var enabled: Bool {
|
static var enabled: Bool {
|
||||||
!Defaults[.commentsInstanceID].isNil && !Defaults[.commentsInstanceID]!.isEmpty
|
!instance.isNil
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -41,13 +42,15 @@ final class CommentsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func load(page: String? = nil) {
|
func load(page: String? = nil) {
|
||||||
guard Self.enabled else {
|
guard Self.enabled, !loading else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
guard !instance.isNil,
|
loading = true
|
||||||
|
|
||||||
|
guard !Self.instance.isNil,
|
||||||
!(player?.currentVideo.isNil ?? true)
|
!(player?.currentVideo.isNil ?? true)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
@@ -65,6 +68,7 @@ final class CommentsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onCompletion { [weak self] _ in
|
.onCompletion { [weak self] _ in
|
||||||
|
self?.loading = false
|
||||||
self?.loaded = true
|
self?.loaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,9 +95,10 @@ final class CommentsModel: ObservableObject {
|
|||||||
.onSuccess { [weak self] response in
|
.onSuccess { [weak self] response in
|
||||||
if let page: CommentsPage = response.typedContent() {
|
if let page: CommentsPage = response.typedContent() {
|
||||||
self?.replies = page.comments
|
self?.replies = page.comments
|
||||||
|
self?.repliesLoaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onCompletion { [weak self] _ in
|
.onFailure { [weak self] _ in
|
||||||
self?.repliesLoaded = true
|
self?.repliesLoaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +109,7 @@ final class CommentsModel: ObservableObject {
|
|||||||
firstPage = true
|
firstPage = true
|
||||||
nextPage = nil
|
nextPage = nil
|
||||||
loaded = false
|
loaded = false
|
||||||
|
loading = false
|
||||||
replies = []
|
replies = []
|
||||||
repliesLoaded = false
|
repliesLoaded = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,32 @@ final class NavigationModel: ObservableObject {
|
|||||||
@Published var presentingSettings = false
|
@Published var presentingSettings = false
|
||||||
@Published var presentingWelcomeScreen = false
|
@Published var presentingWelcomeScreen = false
|
||||||
|
|
||||||
|
static func openChannel(
|
||||||
|
_ channel: Channel,
|
||||||
|
player: PlayerModel,
|
||||||
|
recents: RecentsModel,
|
||||||
|
navigation: NavigationModel,
|
||||||
|
navigationStyle: NavigationStyle
|
||||||
|
) {
|
||||||
|
let recent = RecentItem(from: channel)
|
||||||
|
player.presentingPlayer = false
|
||||||
|
|
||||||
|
let openRecent = {
|
||||||
|
recents.add(recent)
|
||||||
|
navigation.presentingChannel = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if navigationStyle == .tab {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
openRecent()
|
||||||
|
}
|
||||||
|
} else if navigationStyle == .sidebar {
|
||||||
|
openRecent()
|
||||||
|
navigation.sidebarSectionChanged.toggle()
|
||||||
|
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var tabSelectionBinding: Binding<TabSelection> {
|
var tabSelectionBinding: Binding<TabSelection> {
|
||||||
Binding<TabSelection>(
|
Binding<TabSelection>(
|
||||||
get: {
|
get: {
|
||||||
|
|||||||
@@ -33,15 +33,17 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
|
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
|
||||||
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
|
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
|
||||||
|
|
||||||
@Published var savedTime: CMTime?
|
@Published var preservedTime: CMTime?
|
||||||
|
|
||||||
@Published var playerNavigationLinkActive = false
|
@Published var playerNavigationLinkActive = false { didSet { pauseOnChannelPlayerDismiss() } }
|
||||||
|
|
||||||
@Published var sponsorBlock = SponsorBlockAPI()
|
@Published var sponsorBlock = SponsorBlockAPI()
|
||||||
@Published var segmentRestorationTime: CMTime?
|
@Published var segmentRestorationTime: CMTime?
|
||||||
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
||||||
@Published var restoredSegments = [Segment]()
|
@Published var restoredSegments = [Segment]()
|
||||||
|
|
||||||
|
@Published var channelWithDetails: Channel?
|
||||||
|
|
||||||
var accounts: AccountsModel
|
var accounts: AccountsModel
|
||||||
var comments: CommentsModel
|
var comments: CommentsModel
|
||||||
|
|
||||||
@@ -128,19 +130,29 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
func upgradeToStream(_ stream: Stream) {
|
func upgradeToStream(_ stream: Stream) {
|
||||||
if !self.stream.isNil, self.stream != stream {
|
if !self.stream.isNil, self.stream != stream {
|
||||||
playStream(stream, of: currentVideo!, preservingTime: true)
|
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playStream(
|
func playStream(
|
||||||
_ stream: Stream,
|
_ stream: Stream,
|
||||||
of video: Video,
|
of video: Video,
|
||||||
preservingTime: Bool = false
|
preservingTime: Bool = false,
|
||||||
|
upgrading: Bool = false
|
||||||
) {
|
) {
|
||||||
playerError = nil
|
playerError = nil
|
||||||
resetSegments()
|
if !upgrading {
|
||||||
sponsorBlock.loadSegments(videoID: video.videoID, categories: Defaults[.sponsorBlockCategories])
|
resetSegments()
|
||||||
comments.load()
|
|
||||||
|
sponsorBlock.loadSegments(
|
||||||
|
videoID: video.videoID,
|
||||||
|
categories: Defaults[.sponsorBlockCategories]
|
||||||
|
) { [weak self] in
|
||||||
|
if Defaults[.showChannelSubscribers] {
|
||||||
|
self?.loadCurrentItemChannelDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let url = stream.singleAssetURL {
|
if let url = stream.singleAssetURL {
|
||||||
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||||
@@ -154,7 +166,9 @@ final class PlayerModel: ObservableObject {
|
|||||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCurrentArtwork()
|
if !upgrading {
|
||||||
|
updateCurrentArtwork()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pauseOnPlayerDismiss() {
|
private func pauseOnPlayerDismiss() {
|
||||||
@@ -165,6 +179,14 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func pauseOnChannelPlayerDismiss() {
|
||||||
|
if !playingInPictureInPicture, !playerNavigationLinkActive {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func insertPlayerItem(
|
private func insertPlayerItem(
|
||||||
_ stream: Stream,
|
_ stream: Stream,
|
||||||
for video: Video,
|
for video: Video,
|
||||||
@@ -193,25 +215,47 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
if self.isAutoplaying(playerItem!) {
|
if self.isAutoplaying(playerItem!) {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||||
self?.play()
|
guard let self = self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let segment = self.sponsorBlock.segments.first,
|
||||||
|
segment.start < 3,
|
||||||
|
self.lastSkipped.isNil
|
||||||
|
{
|
||||||
|
self.player.seek(
|
||||||
|
to: segment.endTime,
|
||||||
|
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||||
|
toleranceAfter: .zero
|
||||||
|
) { finished in
|
||||||
|
guard finished else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.lastSkipped = segment
|
||||||
|
self.play()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let replaceItemAndSeek = {
|
let replaceItemAndSeek = {
|
||||||
self.player.replaceCurrentItem(with: playerItem)
|
self.player.replaceCurrentItem(with: playerItem)
|
||||||
self.seekToSavedTime { finished in
|
self.seekToPreservedTime { finished in
|
||||||
guard finished else {
|
guard finished else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.savedTime = nil
|
self.preservedTime = nil
|
||||||
|
|
||||||
startPlaying()
|
startPlaying()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if preservingTime {
|
if preservingTime {
|
||||||
if savedTime.isNil {
|
if preservedTime.isNil {
|
||||||
saveTime {
|
saveTime {
|
||||||
replaceItemAndSeek()
|
replaceItemAndSeek()
|
||||||
startPlaying()
|
startPlaying()
|
||||||
@@ -382,13 +426,13 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.savedTime = currentTime
|
self?.preservedTime = currentTime
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||||
guard let time = savedTime else {
|
guard let time = preservedTime else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +524,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileprivate func updateNowPlayingInfo() {
|
fileprivate func updateNowPlayingInfo() {
|
||||||
let duration: Int? = currentItem.video.live ? nil : Int(currentItem.videoDuration ?? 0)
|
var duration: Int?
|
||||||
|
if !currentItem.video.live {
|
||||||
|
let itemDuration = currentItem.videoDuration ?? 0
|
||||||
|
duration = itemDuration.isFinite ? Int(itemDuration) : nil
|
||||||
|
}
|
||||||
var nowPlayingInfo: [String: AnyObject] = [
|
var nowPlayingInfo: [String: AnyObject] = [
|
||||||
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
||||||
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
||||||
@@ -516,6 +564,36 @@ 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
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ extension PlayerModel {
|
|||||||
currentItem.video = video!
|
currentItem.video = video!
|
||||||
}
|
}
|
||||||
|
|
||||||
savedTime = currentItem.playbackTime
|
preservedTime = currentItem.playbackTime
|
||||||
|
restoreLoadedChannel()
|
||||||
|
|
||||||
loadAvailableStreams(currentVideo!) { streams in
|
loadAvailableStreams(currentVideo!) { streams in
|
||||||
guard let stream = self.preferredStream(streams) else {
|
guard let stream = self.preferredStream(streams) else {
|
||||||
@@ -126,7 +127,7 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||||
player.currentItem == item && presentingPlayer
|
player.currentItem == item && (presentingPlayer || playerNavigationLinkActive || playingInPictureInPicture)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult func enqueueVideo(
|
@discardableResult func enqueueVideo(
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
|||||||
|
|
||||||
var videos = [Video]()
|
var videos = [Video]()
|
||||||
|
|
||||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval) {
|
init(id: String, title: String, visibility: Visibility, updated: TimeInterval, videos: [Video] = []) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.visibility = visibility
|
self.visibility = visibility
|
||||||
self.updated = updated
|
self.updated = updated
|
||||||
|
self.videos = videos
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ json: JSON) {
|
init(_ json: JSON) {
|
||||||
@@ -34,7 +35,6 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
|||||||
title = json["title"].stringValue
|
title = json["title"].stringValue
|
||||||
visibility = json["isListed"].boolValue ? .public : .private
|
visibility = json["isListed"].boolValue ? .public : .private
|
||||||
updated = json["updated"].doubleValue
|
updated = json["updated"].doubleValue
|
||||||
videos = json["videos"].arrayValue.map { InvidiousAPI.extractVideo(from: $0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: Playlist, rhs: Playlist) -> Bool {
|
static func == (lhs: Playlist, rhs: Playlist) -> Bool {
|
||||||
|
|||||||
@@ -52,14 +52,22 @@ final class PlaylistsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addVideo(playlistID: Playlist.ID, videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
|
func addVideo(
|
||||||
|
playlistID: Playlist.ID,
|
||||||
|
videoID: Video.ID,
|
||||||
|
onSuccess: @escaping () -> Void = {},
|
||||||
|
onFailure: @escaping (RequestError) -> Void = { _ in }
|
||||||
|
) {
|
||||||
let resource = accounts.api.playlistVideos(playlistID)
|
let resource = accounts.api.playlistVideos(playlistID)
|
||||||
let body = ["videoId": videoID]
|
let body = ["videoId": videoID]
|
||||||
|
|
||||||
resource?.request(.post, json: body).onSuccess { _ in
|
resource?
|
||||||
self.load(force: true)
|
.request(.post, json: body)
|
||||||
onSuccess()
|
.onSuccess { _ in
|
||||||
}
|
self.load(force: true)
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
.onFailure(onFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import SwiftyJSON
|
|||||||
final class SponsorBlockAPI: ObservableObject {
|
final class SponsorBlockAPI: ObservableObject {
|
||||||
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
||||||
|
|
||||||
let logger = Logger(label: "net.yattee.app.sb")
|
let logger = Logger(label: "stream.yattee.app.sb")
|
||||||
|
|
||||||
@Published var videoID: String?
|
@Published var videoID: String?
|
||||||
@Published var segments = [Segment]()
|
@Published var segments = [Segment]()
|
||||||
@@ -27,22 +27,27 @@ final class SponsorBlockAPI: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSegments(videoID: String, categories: Set<String>) {
|
func loadSegments(videoID: String, categories: Set<String>, completionHandler: @escaping () -> Void = {}) {
|
||||||
guard !skipSegmentsURL.isNil, self.videoID != videoID else {
|
guard !skipSegmentsURL.isNil, self.videoID != videoID else {
|
||||||
|
completionHandler()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.videoID = videoID
|
self.videoID = videoID
|
||||||
|
|
||||||
requestSegments(categories: categories)
|
requestSegments(categories: categories, completionHandler: completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requestSegments(categories: Set<String>) {
|
private func requestSegments(categories: Set<String>, completionHandler: @escaping () -> Void = {}) {
|
||||||
guard let url = skipSegmentsURL, !categories.isEmpty else {
|
guard let url = skipSegmentsURL, !categories.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
AF.request(url, parameters: parameters(categories: categories)).responseJSON { response in
|
AF.request(url, parameters: parameters(categories: categories)).responseJSON { [weak self] response in
|
||||||
|
guard let self = self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch response.result {
|
switch response.result {
|
||||||
case let .success(value):
|
case let .success(value):
|
||||||
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
||||||
@@ -56,6 +61,8 @@ final class SponsorBlockAPI: ObservableObject {
|
|||||||
|
|
||||||
self.logger.error("failed to load SponsorBlock segments: \(error.localizedDescription)")
|
self.logger.error("failed to load SponsorBlock segments: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completionHandler()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -1,6 +1,7 @@
|
|||||||

|
<div align="center">
|
||||||
|
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
|
||||||
Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) built for iOS, tvOS and macOS.
|
<h1>Yattee</h1>
|
||||||
|
<p>Videos browser and player for <a href="https://github.com/iv-org/invidious">Invidious</a> and <a href="https://github.com/TeamPiped/Piped">Piped</a> (alternative, privacy-friendly YouTube frontends)<br />built for iOS, tvOS and macOS.</p>
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/agpl-3.0.en.html)
|
||||||
[](https://github.com/yattee/yattee/issues)
|
[](https://github.com/yattee/yattee/issues)
|
||||||
@@ -8,16 +9,15 @@ Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](ht
|
|||||||
[](https://matrix.to/#/#yattee:matrix.org)
|
[](https://matrix.to/#/#yattee:matrix.org)
|
||||||
|
|
||||||

|

|
||||||
|
</div>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/)
|
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/) with customization settings
|
||||||
* Multiple instances and accounts, fast switching
|
* Multiple instances and accounts, fast switching
|
||||||
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
|
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
|
||||||
* Player queue and history
|
* Player queue and history
|
||||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||||
* Stream quality selection
|
* Stream quality selection
|
||||||
* Favorites: customizable section of channels, playlists, trending, searches and other views
|
|
||||||
* `yattee://` URL Scheme for integrations
|
|
||||||
|
|
||||||
### Availability
|
### Availability
|
||||||
| Feature | Invidious | Piped |
|
| Feature | Invidious | Piped |
|
||||||
@@ -38,11 +38,11 @@ Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](ht
|
|||||||
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
|
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-instructions)
|
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||||
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
* [FAQ](https://github.com/yattee/yattee/wiki)
|
||||||
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
||||||
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
||||||
* [FAQ](https://github.com/yattee/yattee/wiki)
|
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
||||||
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
|
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ extension Defaults.Keys {
|
|||||||
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
|
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
|
||||||
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 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)
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ struct AppTabNavigation: View {
|
|||||||
ChannelVideosView(channel: channel)
|
ChannelVideosView(channel: channel)
|
||||||
.environment(\.inChannelView, true)
|
.environment(\.inChannelView, true)
|
||||||
.environment(\.inNavigationView, true)
|
.environment(\.inNavigationView, true)
|
||||||
|
.environmentObject(accounts)
|
||||||
|
.environmentObject(navigation)
|
||||||
|
.environmentObject(player)
|
||||||
|
.environmentObject(subscriptions)
|
||||||
|
.environmentObject(thumbnailsModel)
|
||||||
|
|
||||||
.background(playerNavigationLink)
|
.background(playerNavigationLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +73,12 @@ struct AppTabNavigation: View {
|
|||||||
NavigationView {
|
NavigationView {
|
||||||
ChannelPlaylistView(playlist: playlist)
|
ChannelPlaylistView(playlist: playlist)
|
||||||
.environment(\.inNavigationView, true)
|
.environment(\.inNavigationView, true)
|
||||||
|
.environmentObject(accounts)
|
||||||
|
.environmentObject(navigation)
|
||||||
|
.environmentObject(player)
|
||||||
.environmentObject(subscriptions)
|
.environmentObject(subscriptions)
|
||||||
|
.environmentObject(thumbnailsModel)
|
||||||
|
|
||||||
.background(playerNavigationLink)
|
.background(playerNavigationLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +87,7 @@ struct AppTabNavigation: View {
|
|||||||
.background(
|
.background(
|
||||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||||
videoPlayer
|
videoPlayer
|
||||||
.environment(\.navigationStyle, .sidebar)
|
.environment(\.navigationStyle, .tab)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -160,7 +171,7 @@ struct AppTabNavigation: View {
|
|||||||
|
|
||||||
private var playerNavigationLink: some View {
|
private var playerNavigationLink: some View {
|
||||||
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
|
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
|
||||||
VideoPlayerView()
|
videoPlayer
|
||||||
.environment(\.inNavigationView, true)
|
.environment(\.inNavigationView, true)
|
||||||
}) {
|
}) {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
|
|||||||
@@ -1,65 +1,81 @@
|
|||||||
import SDWebImageSwiftUI
|
import SDWebImageSwiftUI
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CommentView: View {
|
struct CommentView: View {
|
||||||
let comment: Comment
|
let comment: Comment
|
||||||
@Binding var repliesID: Comment.ID?
|
@Binding var repliesID: Comment.ID?
|
||||||
|
|
||||||
|
@State private var subscribed = false
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@Environment(\.navigationStyle) private var navigationStyle
|
@Environment(\.navigationStyle) private var navigationStyle
|
||||||
|
|
||||||
@EnvironmentObject<CommentsModel> private var comments
|
@EnvironmentObject<CommentsModel> private var comments
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
@EnvironmentObject<PlayerModel> private var player
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
@EnvironmentObject<RecentsModel> private var recents
|
@EnvironmentObject<RecentsModel> private var recents
|
||||||
|
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack(alignment: .center, spacing: 10) {
|
HStack(alignment: .center, spacing: 10) {
|
||||||
authorAvatar
|
HStack(spacing: 10) {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
authorAvatar
|
||||||
|
|
||||||
#if os(iOS)
|
if subscribed {
|
||||||
Group {
|
Image(systemName: "star.circle.fill")
|
||||||
if horizontalSizeClass == .regular {
|
#if os(tvOS)
|
||||||
HStack(spacing: 20) {
|
.background(Color.background(scheme: colorScheme))
|
||||||
authorAndTime
|
#else
|
||||||
|
.background(Color.background)
|
||||||
Spacer()
|
#endif
|
||||||
|
.clipShape(Circle())
|
||||||
Group {
|
.foregroundColor(.secondary)
|
||||||
statusIcons
|
|
||||||
likes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack(alignment: .center, spacing: 20) {
|
|
||||||
authorAndTime
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 8) {
|
|
||||||
likes
|
|
||||||
statusIcons
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.system(size: 15))
|
.onAppear {
|
||||||
|
subscribed = subscriptions.isSubscribing(comment.channel.id)
|
||||||
|
}
|
||||||
|
|
||||||
#else
|
authorAndTime
|
||||||
HStack(spacing: 20) {
|
}
|
||||||
authorAndTime
|
.contextMenu {
|
||||||
|
Button(action: openChannelAction) {
|
||||||
|
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
#if os(iOS)
|
||||||
|
if horizontalSizeClass == .regular {
|
||||||
|
Group {
|
||||||
|
statusIcons
|
||||||
|
likes
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
|
likes
|
||||||
|
statusIcons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
statusIcons
|
statusIcons
|
||||||
likes
|
likes
|
||||||
}
|
#endif
|
||||||
#endif
|
}
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.font(.system(size: 25).bold())
|
||||||
|
#else
|
||||||
|
.font(.system(size: 15))
|
||||||
|
#endif
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
commentText
|
commentText
|
||||||
@@ -94,23 +110,25 @@ struct CommentView: View {
|
|||||||
.retryOnAppear(false)
|
.retryOnAppear(false)
|
||||||
.indicator(.activity)
|
.indicator(.activity)
|
||||||
.mask(RoundedRectangle(cornerRadius: 60))
|
.mask(RoundedRectangle(cornerRadius: 60))
|
||||||
.frame(width: 45, height: 45, alignment: .leading)
|
|
||||||
.contextMenu {
|
|
||||||
Button(action: openChannelAction) {
|
|
||||||
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
|
.frame(width: 80, height: 80, alignment: .leading)
|
||||||
.focusable()
|
.focusable()
|
||||||
|
#else
|
||||||
|
.frame(width: 45, height: 45, alignment: .leading)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var authorAndTime: some View {
|
private var authorAndTime: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(comment.author)
|
Text(comment.author)
|
||||||
.fontWeight(.bold)
|
#if os(tvOS)
|
||||||
|
.font(.system(size: 30).bold())
|
||||||
|
#else
|
||||||
|
.font(.system(size: 14).bold())
|
||||||
|
#endif
|
||||||
|
|
||||||
Text(comment.time)
|
Text(comment.time)
|
||||||
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -125,6 +143,9 @@ struct CommentView: View {
|
|||||||
Image(systemName: "heart.fill")
|
Image(systemName: "heart.fill")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if !os(tvOS)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
#endif
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +156,9 @@ struct CommentView: View {
|
|||||||
Image(systemName: "hand.thumbsup")
|
Image(systemName: "hand.thumbsup")
|
||||||
Text("\(comment.likeCount.formattedAsAbbreviation())")
|
Text("\(comment.likeCount.formattedAsAbbreviation())")
|
||||||
}
|
}
|
||||||
|
#if !os(tvOS)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -163,6 +187,7 @@ struct CommentView: View {
|
|||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.padding(.leading, 5)
|
.padding(.leading, 5)
|
||||||
#else
|
#else
|
||||||
|
.font(.system(size: 13))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -181,7 +206,7 @@ struct CommentView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
0.4
|
0.4
|
||||||
#else
|
#else
|
||||||
0.8
|
0.6
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,17 +251,13 @@ struct CommentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openChannelAction() {
|
private func openChannelAction() {
|
||||||
player.presentingPlayer = false
|
NavigationModel.openChannel(
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
comment.channel,
|
||||||
let recent = RecentItem(from: comment.channel)
|
player: player,
|
||||||
recents.add(recent)
|
recents: recents,
|
||||||
navigation.presentingChannel = true
|
navigation: navigation,
|
||||||
|
navigationStyle: navigationStyle
|
||||||
if navigationStyle == .sidebar {
|
)
|
||||||
navigation.sidebarSectionChanged.toggle()
|
|
||||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,5 +268,7 @@ struct CommentView_Previews: PreviewProvider {
|
|||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
CommentView(comment: fixture, repliesID: .constant(fixture.id))
|
CommentView(comment: fixture, repliesID: .constant(fixture.id))
|
||||||
|
.environmentObject(SubscriptionsModel())
|
||||||
|
.padding(5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ struct CommentsView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
} else if !comments.loaded {
|
} else if !comments.loaded {
|
||||||
progressView
|
progressView
|
||||||
|
.onAppear {
|
||||||
|
comments.load()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -48,6 +51,7 @@ struct CommentsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.font(.system(size: 13))
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -56,11 +60,6 @@ struct CommentsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.onAppear {
|
|
||||||
if !comments.loaded {
|
|
||||||
comments.load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var progressView: some View {
|
private var progressView: some View {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct Player: UIViewControllerRepresentable {
|
|||||||
@EnvironmentObject<CommentsModel> private var comments
|
@EnvironmentObject<CommentsModel> private var comments
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
@EnvironmentObject<PlayerModel> private var player
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
|
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||||
|
|
||||||
var controller: PlayerViewController?
|
var controller: PlayerViewController?
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ struct Player: UIViewControllerRepresentable {
|
|||||||
controller.commentsModel = comments
|
controller.commentsModel = comments
|
||||||
controller.navigationModel = navigation
|
controller.navigationModel = navigation
|
||||||
controller.playerModel = player
|
controller.playerModel = player
|
||||||
|
controller.subscriptionsModel = subscriptions
|
||||||
player.controller = controller
|
player.controller = controller
|
||||||
|
|
||||||
return controller
|
return controller
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import AVKit
|
import AVKit
|
||||||
import Logging
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class PlayerViewController: UIViewController {
|
final class PlayerViewController: UIViewController {
|
||||||
@@ -7,6 +6,7 @@ final class PlayerViewController: UIViewController {
|
|||||||
var commentsModel: CommentsModel!
|
var commentsModel: CommentsModel!
|
||||||
var navigationModel: NavigationModel!
|
var navigationModel: NavigationModel!
|
||||||
var playerModel: PlayerModel!
|
var playerModel: PlayerModel!
|
||||||
|
var subscriptionsModel: SubscriptionsModel!
|
||||||
var playerViewController = AVPlayerViewController()
|
var playerViewController = AVPlayerViewController()
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -71,6 +71,7 @@ final class PlayerViewController: UIViewController {
|
|||||||
.frame(maxHeight: 600)
|
.frame(maxHeight: 600)
|
||||||
.environmentObject(commentsModel)
|
.environmentObject(commentsModel)
|
||||||
.environmentObject(playerModel)
|
.environmentObject(playerModel)
|
||||||
|
.environmentObject(subscriptionsModel)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Defaults
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SDWebImageSwiftUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VideoDetails: View {
|
struct VideoDetails: View {
|
||||||
@@ -20,11 +21,15 @@ struct VideoDetails: View {
|
|||||||
|
|
||||||
@Environment(\.presentationMode) private var presentationMode
|
@Environment(\.presentationMode) private var presentationMode
|
||||||
@Environment(\.inNavigationView) private var inNavigationView
|
@Environment(\.inNavigationView) private var inNavigationView
|
||||||
|
@Environment(\.navigationStyle) private var navigationStyle
|
||||||
|
|
||||||
@EnvironmentObject<AccountsModel> private var accounts
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
@EnvironmentObject<PlayerModel> private var player
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
|
@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(
|
||||||
@@ -65,7 +70,9 @@ struct VideoDetails: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
if CommentsModel.enabled, CommentsModel.placement == .separate {
|
if !sidebarQueue ||
|
||||||
|
(CommentsModel.enabled && CommentsModel.placement == .separate)
|
||||||
|
{
|
||||||
pagePicker
|
pagePicker
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
@@ -178,21 +185,52 @@ struct VideoDetails: View {
|
|||||||
Group {
|
Group {
|
||||||
if video != nil {
|
if video != nil {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 10) {
|
||||||
if subscribed {
|
Group {
|
||||||
Image(systemName: "star.circle.fill")
|
ZStack(alignment: .bottomTrailing) {
|
||||||
}
|
authorAvatar
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(video!.channel.name)
|
if subscribed {
|
||||||
.font(.system(size: 13))
|
Image(systemName: "star.circle.fill")
|
||||||
.bold()
|
.background(Color.background)
|
||||||
if let subscribers = video!.channel.subscriptionsString {
|
.clipShape(Circle())
|
||||||
Text("\(subscribers) subscribers")
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(video!.channel.name)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
if showChannelSubscribers {
|
||||||
|
Group {
|
||||||
|
if let subscribers = video!.channel.subscriptionsString {
|
||||||
|
Text("\(subscribers) subscribers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.contextMenu {
|
||||||
|
if let video = video {
|
||||||
|
Button(action: {
|
||||||
|
NavigationModel.openChannel(
|
||||||
|
video.channel,
|
||||||
|
player: player,
|
||||||
|
recents: recents,
|
||||||
|
navigation: navigation,
|
||||||
|
navigationStyle: navigationStyle
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Label("\(video.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
if accounts.app.supportsSubscriptions {
|
if accounts.app.supportsSubscriptions {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -209,7 +247,7 @@ struct VideoDetails: View {
|
|||||||
.alert(isPresented: $presentingUnsubscribeAlert) {
|
.alert(isPresented: $presentingUnsubscribeAlert) {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text(
|
title: Text(
|
||||||
"Are you you want to unsubscribe from \(video!.channel.name)?"
|
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
|
||||||
),
|
),
|
||||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||||
subscriptions.unsubscribe(video!.channel.id)
|
subscriptions.unsubscribe(video!.channel.id)
|
||||||
@@ -364,6 +402,22 @@ struct VideoDetails: View {
|
|||||||
ContentItem(video: player.currentVideo!)
|
ContentItem(video: player.currentVideo!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var authorAvatar: some View {
|
||||||
|
Group {
|
||||||
|
if let video = video, let url = video.channel.thumbnailURL {
|
||||||
|
WebImage(url: url)
|
||||||
|
.resizable()
|
||||||
|
.placeholder {
|
||||||
|
Rectangle().fill(Color("PlaceholderColor"))
|
||||||
|
}
|
||||||
|
.retryOnAppear(false)
|
||||||
|
.indicator(.activity)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: 45, height: 45, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var detailsPage: some View {
|
var detailsPage: some View {
|
||||||
Group {
|
Group {
|
||||||
Group {
|
Group {
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ struct VideoPlayerView: View {
|
|||||||
.onChange(of: geometry.size) { size in
|
.onChange(of: geometry.size) { size in
|
||||||
self.playerSize = size
|
self.playerSize = size
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
|
||||||
}
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ struct AddToPlaylistView: View {
|
|||||||
|
|
||||||
@State private var selectedPlaylistID: Playlist.ID = ""
|
@State private var selectedPlaylistID: Playlist.ID = ""
|
||||||
|
|
||||||
|
@State private var error = ""
|
||||||
|
@State private var presentingErrorAlert = false
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@Environment(\.presentationMode) private var presentationMode
|
@Environment(\.presentationMode) private var presentationMode
|
||||||
|
|
||||||
@EnvironmentObject<PlaylistsModel> private var model
|
@EnvironmentObject<PlaylistsModel> private var model
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -120,6 +124,12 @@ struct AddToPlaylistView: View {
|
|||||||
Button("Add to Playlist", action: addToPlaylist)
|
Button("Add to Playlist", action: addToPlaylist)
|
||||||
.disabled(selectedPlaylist.isNil)
|
.disabled(selectedPlaylist.isNil)
|
||||||
.padding(.top, 30)
|
.padding(.top, 30)
|
||||||
|
.alert(isPresented: $presentingErrorAlert) {
|
||||||
|
Alert(
|
||||||
|
title: Text("Error when accessing playlist"),
|
||||||
|
message: Text(error)
|
||||||
|
)
|
||||||
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
#endif
|
#endif
|
||||||
@@ -155,9 +165,17 @@ struct AddToPlaylistView: View {
|
|||||||
|
|
||||||
Defaults[.lastUsedPlaylistID] = id
|
Defaults[.lastUsedPlaylistID] = id
|
||||||
|
|
||||||
model.addVideo(playlistID: id, videoID: video.videoID) {
|
model.addVideo(
|
||||||
presentationMode.wrappedValue.dismiss()
|
playlistID: id,
|
||||||
}
|
videoID: video.videoID,
|
||||||
|
onSuccess: {
|
||||||
|
presentationMode.wrappedValue.dismiss()
|
||||||
|
},
|
||||||
|
onFailure: { requestError in
|
||||||
|
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
||||||
|
presentingErrorAlert = true
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedPlaylist: Playlist? {
|
private var selectedPlaylist: Playlist? {
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ struct PlaylistFormView: View {
|
|||||||
@State private var visibility = Playlist.Visibility.public
|
@State private var visibility = Playlist.Visibility.public
|
||||||
|
|
||||||
@State private var valid = false
|
@State private var valid = false
|
||||||
@State private var showingDeleteConfirmation = false
|
@State private var presentingDeleteConfirmation = false
|
||||||
|
|
||||||
|
@State private var formError = ""
|
||||||
|
@State private var presentingErrorAlert = false
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@Environment(\.presentationMode) private var presentationMode
|
@Environment(\.presentationMode) private var presentationMode
|
||||||
@@ -57,6 +60,12 @@ struct PlaylistFormView: View {
|
|||||||
|
|
||||||
Button("Save", action: submitForm)
|
Button("Save", action: submitForm)
|
||||||
.disabled(!valid)
|
.disabled(!valid)
|
||||||
|
.alert(isPresented: $presentingErrorAlert) {
|
||||||
|
Alert(
|
||||||
|
title: Text("Error when accessing playlist"),
|
||||||
|
message: Text(formError)
|
||||||
|
)
|
||||||
|
}
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 35)
|
.frame(minHeight: 35)
|
||||||
@@ -165,15 +174,21 @@ struct PlaylistFormView: View {
|
|||||||
|
|
||||||
let body = ["title": name, "privacy": visibility.rawValue]
|
let body = ["title": name, "privacy": visibility.rawValue]
|
||||||
|
|
||||||
resource?.request(editing ? .patch : .post, json: body).onSuccess { response in
|
resource?
|
||||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
.request(editing ? .patch : .post, json: body)
|
||||||
playlist = modifiedPlaylist
|
.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
|
||||||
}
|
}
|
||||||
|
|
||||||
playlists.load(force: true)
|
|
||||||
|
|
||||||
presentationMode.wrappedValue.dismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var resource: Resource? {
|
var resource: Resource? {
|
||||||
@@ -207,9 +222,9 @@ struct PlaylistFormView: View {
|
|||||||
|
|
||||||
var deletePlaylistButton: some View {
|
var deletePlaylistButton: some View {
|
||||||
Button("Delete") {
|
Button("Delete") {
|
||||||
showingDeleteConfirmation = true
|
presentingDeleteConfirmation = true
|
||||||
}
|
}
|
||||||
.alert(isPresented: $showingDeleteConfirmation) {
|
.alert(isPresented: $presentingDeleteConfirmation) {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Are you sure you want to delete playlist?"),
|
title: Text("Are you sure you want to delete playlist?"),
|
||||||
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
|
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
|
||||||
@@ -221,11 +236,17 @@ struct PlaylistFormView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deletePlaylistAndDismiss() {
|
func deletePlaylistAndDismiss() {
|
||||||
accounts.api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
|
accounts.api.playlist(playlist.id)?
|
||||||
playlist = nil
|
.request(.delete)
|
||||||
playlists.load(force: true)
|
.onSuccess { _ in
|
||||||
presentationMode.wrappedValue.dismiss()
|
playlist = nil
|
||||||
}
|
playlists.load(force: true)
|
||||||
|
presentationMode.wrappedValue.dismiss()
|
||||||
|
}
|
||||||
|
.onFailure { error in
|
||||||
|
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||||
|
presentingErrorAlert = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct PlaybackSettings: View {
|
|||||||
@Default(.quality) private var quality
|
@Default(.quality) private var quality
|
||||||
@Default(.playerSidebar) private var playerSidebar
|
@Default(.playerSidebar) private var playerSidebar
|
||||||
@Default(.showKeywords) private var showKeywords
|
@Default(.showKeywords) private var showKeywords
|
||||||
|
@Default(.showChannelSubscribers) private var channelSubscribers
|
||||||
@Default(.saveHistory) private var saveHistory
|
@Default(.saveHistory) private var saveHistory
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -27,6 +28,7 @@ struct PlaybackSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
keywordsToggle
|
keywordsToggle
|
||||||
|
channelSubscribersToggle
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
Section(header: SettingsHeader(text: "Source")) {
|
Section(header: SettingsHeader(text: "Source")) {
|
||||||
@@ -44,6 +46,7 @@ struct PlaybackSettings: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
keywordsToggle
|
keywordsToggle
|
||||||
|
channelSubscribersToggle
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +110,10 @@ struct PlaybackSettings: View {
|
|||||||
private var keywordsToggle: some View {
|
private var keywordsToggle: some View {
|
||||||
Toggle("Show video keywords", isOn: $showKeywords)
|
Toggle("Show video keywords", isOn: $showKeywords)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var channelSubscribersToggle: some View {
|
||||||
|
Toggle("Show channel subscribers count", isOn: $channelSubscribers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlaybackSettings_Previews: PreviewProvider {
|
struct PlaybackSettings_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ struct ChannelCell: View {
|
|||||||
@Environment(\.navigationStyle) private var navigationStyle
|
@Environment(\.navigationStyle) private var navigationStyle
|
||||||
|
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
@EnvironmentObject<RecentsModel> private var recents
|
@EnvironmentObject<RecentsModel> private var recents
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
let recent = RecentItem(from: channel)
|
NavigationModel.openChannel(
|
||||||
recents.add(recent)
|
channel,
|
||||||
navigation.presentingChannel = true
|
player: player,
|
||||||
|
recents: recents,
|
||||||
if navigationStyle == .sidebar {
|
navigation: navigation,
|
||||||
navigation.sidebarSectionChanged.toggle()
|
navigationStyle: navigationStyle
|
||||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
)
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
content
|
content
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct ChannelPlaylistView: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
@EnvironmentObject<AccountsModel> private var accounts
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
|
|
||||||
var items: [ContentItem] {
|
var items: [ContentItem] {
|
||||||
ContentItem.array(of: store.item?.videos ?? [])
|
ContentItem.array(of: store.item?.videos ?? [])
|
||||||
@@ -83,9 +84,11 @@ struct ChannelPlaylistView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(playlist.title)
|
.navigationTitle(playlist.title)
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarHidden(player.playerNavigationLinkActive)
|
||||||
|
#endif
|
||||||
#else
|
#else
|
||||||
.background(Color.background(scheme: colorScheme))
|
.background(Color.background(scheme: colorScheme))
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct ChannelVideosView: View {
|
|||||||
|
|
||||||
@EnvironmentObject<AccountsModel> private var accounts
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||||
|
|
||||||
@Namespace private var focusNamespace
|
@Namespace private var focusNamespace
|
||||||
@@ -120,6 +121,9 @@ struct ChannelVideosView: View {
|
|||||||
resource.load()
|
resource.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarHidden(player.playerNavigationLinkActive)
|
||||||
|
#endif
|
||||||
.navigationTitle(navigationTitle)
|
.navigationTitle(navigationTitle)
|
||||||
|
|
||||||
return Group {
|
return Group {
|
||||||
@@ -160,6 +164,17 @@ struct ChannelVideosView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||||
|
Alert(
|
||||||
|
title: Text(
|
||||||
|
"Are you sure you want to unsubscribe from \(channel.name)?"
|
||||||
|
),
|
||||||
|
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||||
|
subscriptions.unsubscribe(channel.id)
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var contentItem: ContentItem {
|
private var contentItem: ContentItem {
|
||||||
|
|||||||
@@ -97,14 +97,13 @@ struct VideoContextMenuView: View {
|
|||||||
|
|
||||||
private var openChannelButton: some View {
|
private var openChannelButton: some View {
|
||||||
Button {
|
Button {
|
||||||
let recent = RecentItem(from: video.channel)
|
NavigationModel.openChannel(
|
||||||
recents.add(recent)
|
video.channel,
|
||||||
navigation.presentingChannel = true
|
player: player,
|
||||||
|
recents: recents,
|
||||||
if navigationStyle == .sidebar {
|
navigation: navigation,
|
||||||
navigation.sidebarSectionChanged.toggle()
|
navigationStyle: navigationStyle
|
||||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
)
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2562,7 +2562,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 = 9;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -2577,7 +2577,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2593,7 +2593,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 = 9;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -2608,7 +2608,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2628,7 +2628,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 = 9;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -2643,7 +2643,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -2661,7 +2661,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 = 9;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -2676,7 +2676,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -2792,7 +2792,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 = 9;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -2807,7 +2807,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
@@ -2824,7 +2824,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 = 9;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -2839,7 +2839,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||||
PRODUCT_NAME = Yattee;
|
PRODUCT_NAME = Yattee;
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
|
|||||||
@@ -117,9 +117,18 @@ struct NowPlayingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if sections.contains(.comments) {
|
if sections.contains(.comments) {
|
||||||
Section {
|
if !comments.loaded {
|
||||||
ForEach(comments.all) { comment in
|
VStack(alignment: .center) {
|
||||||
CommentView(comment: comment, repliesID: $repliesID)
|
progressView
|
||||||
|
.onAppear {
|
||||||
|
comments.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
ForEach(comments.all) { comment in
|
||||||
|
CommentView(comment: comment, repliesID: $repliesID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +146,19 @@ struct NowPlayingView: View {
|
|||||||
.font((inInfoViewController ? Font.system(size: 40) : .title3).bold())
|
.font((inInfoViewController ? Font.system(size: 40) : .title3).bold())
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var progressView: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NowPlayingView_Previews: PreviewProvider {
|
struct NowPlayingView_Previews: PreviewProvider {
|
||||||
|
|||||||
Reference in New Issue
Block a user