Compare commits

..

14 Commits

Author SHA1 Message Date
Arkadiusz Fal
b6e1f8148c Bump version number 2021-12-17 21:24:21 +01:00
Arkadiusz Fal
23e2e216db Start playing after video intro instead of seeking from beginning 2021-12-17 21:02:15 +01:00
Arkadiusz Fal
d7058b46d3 Fix updating player item duration for live streams 2021-12-17 21:01:18 +01:00
Arkadiusz Fal
c4ca5eb4c7 Show channel thumbnail in player 2021-12-17 21:01:05 +01:00
Arkadiusz Fal
02e66e4520 Fix tab navigation environment objects 2021-12-17 20:58:24 +01:00
Arkadiusz Fal
de09f9dd52 SponsorBlock segments loading improvement 2021-12-17 20:55:52 +01:00
Arkadiusz Fal
4fab7c2c16 Fix channel view in tab navigation 2021-12-17 20:53:53 +01:00
Arkadiusz Fal
f609ed1ed4 Fix unsubscribing from channel 2021-12-17 20:53:24 +01:00
Arkadiusz Fal
201e91a3cc Show errors when handling playlists 2021-12-17 20:53:05 +01:00
Arkadiusz Fal
923f0c0356 More uniform comments UI 2021-12-17 20:46:49 +01:00
Arkadiusz Fal
008cd1553d Comments UI fixes 2021-12-17 18:22:46 +01:00
Arkadiusz Fal
8d49934fe8 Encapsulate open channel action 2021-12-17 17:34:55 +01:00
Arkadiusz Fal
a4c43d9a3a Fix subscriptions/playlists reload on account change 2021-12-14 23:50:19 +01:00
Arkadiusz Fal
310ed3b12b Update README 2021-12-10 23:34:11 +01:00
29 changed files with 572 additions and 237 deletions

View File

@@ -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) }
)
}
} }

View File

@@ -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

View File

@@ -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]()

View File

@@ -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()

View File

@@ -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
} }

View File

@@ -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: {

View File

@@ -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

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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 = {}) {

View File

@@ -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()
} }
} }

View File

@@ -1,6 +1,7 @@
![Yattee Banner](https://r.yattee.stream/icons/yattee-banner.png) <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>
[![AGPL v3](https://shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html) [![AGPL v3](https://shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html)
[![GitHub issues](https://img.shields.io/github/issues/yattee/yattee)](https://github.com/yattee/yattee/issues) [![GitHub issues](https://img.shields.io/github/issues/yattee/yattee)](https://github.com/yattee/yattee/issues)
@@ -8,16 +9,15 @@ Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](ht
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#yattee:matrix.org) [![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#yattee:matrix.org)
![Screenshot](https://r.yattee.stream/screenshots/all-platforms.png) ![Screenshot](https://r.yattee.stream/screenshots/all-platforms.png)
</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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)
} }
} }

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
) )
) )

View File

@@ -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 {

View File

@@ -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
} }

View File

@@ -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? {

View File

@@ -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
}
} }
} }

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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")
} }

View File

@@ -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;

View File

@@ -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 {