Compare commits

...

37 Commits

Author SHA1 Message Date
Arkadiusz Fal
f132ba9683 Bump build number 2021-12-19 18:22:13 +01:00
Arkadiusz Fal
efce339234 Add context menu to close current video from player bar 2021-12-19 18:21:10 +01:00
Arkadiusz Fal
f89c5ff055 Improve player queue rows buttons labels 2021-12-19 18:18:33 +01:00
Arkadiusz Fal
9b2209c9b5 Update default list of favorites 2021-12-19 18:18:01 +01:00
Arkadiusz Fal
61a4951831 Layout and PiP improvements, new settings
- player is now a separate window on macOS
- add setting to disable pause when player is closed (fixes #40)
- add PiP settings:
  * Close PiP when starting playing other video
  * Close PiP when player is opened
  * Close PiP and open player when application
    enters foreground (iOS/tvOS) (fixes #37)
- new player placeholder when in PiP, context menu with exit option
2021-12-19 18:17:04 +01:00
Arkadiusz Fal
cef0b2594a Better loading and handling streams 2021-12-19 17:56:47 +01:00
Arkadiusz Fal
1fbb0cfa80 Remove favorites drag opacity effect on iOS (fixes #43)
No workaround for how to handle drag and drop effect on opening
context menu
2021-12-19 17:32:28 +01:00
Arkadiusz Fal
984e9e7b16 Fix visibility of likes/dislikes 2021-12-19 17:15:27 +01:00
Arkadiusz Fal
4793fc9a38 Fix visibility of Subscriptions tab navigation item on tvOS 2021-12-19 17:08:48 +01:00
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
Arkadiusz Fal
fe33cc5e3a Bump build number 2021-12-08 00:29:00 +01:00
Arkadiusz Fal
7e7b4e89b5 Add Sparkle update framework for macOS 2021-12-08 00:27:38 +01:00
Arkadiusz Fal
d88292662f Minor README updates 2021-12-08 00:08:13 +01:00
Arkadiusz Fal
21b04e21c4 Remove unused file 2021-12-08 00:07:23 +01:00
Arkadiusz Fal
a44a61b017 Remove redundant query for replies when collapsed and expanded 2021-12-08 00:06:59 +01:00
Arkadiusz Fal
1b090fcd51 Bump build number 2021-12-06 19:15:05 +01:00
Arkadiusz Fal
12eb4401b5 Update README 2021-12-06 19:13:57 +01:00
Arkadiusz Fal
170f2ee94e Fix reloading favorites view 2021-12-06 19:13:49 +01:00
Arkadiusz Fal
fe56739211 Fix crash on dismissing channel playlist on iOS 2021-12-06 19:13:37 +01:00
Arkadiusz Fal
759a942426 Fix search field on macOS 2021-12-06 19:13:20 +01:00
Arkadiusz Fal
8d9bbf647a Fix disabling comments on tvOS 2021-12-06 19:12:59 +01:00
Arkadiusz Fal
eeb7b1f151 Improve search suggestions 2021-12-06 19:12:33 +01:00
Arkadiusz Fal
62bff9283c Faster replacing player item 2021-12-06 19:12:02 +01:00
Arkadiusz Fal
3624c9619a Add setting for displaying comments in separate tab or below description 2021-12-06 19:11:19 +01:00
62 changed files with 1414 additions and 583 deletions

View File

@@ -6,6 +6,14 @@ final class InstancesModel: ObservableObject {
Defaults[.instances]
}
static var forPlayer: Instance? {
guard let id = Defaults[.playerInstanceID] else {
return nil
}
return InstancesModel.find(id)
}
var lastUsed: Instance? {
guard let id = Defaults[.lastInstanceID] else {
return nil

View File

@@ -25,11 +25,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func setAccount(_ account: Account) {
self.account = account
signedIn = false
validInstance = false
signedIn = !account.anonymous
validInstance = account.anonymous
configure()
if !account.anonymous {
validate()
}
}
func validate() {
@@ -80,11 +84,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
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
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
@@ -92,11 +96,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
let type = $0.dictionaryValue["type"]?.stringValue
if type == "channel" {
return ContentItem(channel: InvidiousAPI.extractChannel(from: $0))
return ContentItem(channel: self.extractChannel(from: $0))
} 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 +113,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
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
Playlist(content.json)
self.extractPlaylist(from: content.json)
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
@@ -123,30 +127,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(InvidiousAPI.extractVideo)
return feedVideos.arrayValue.map(self.extractVideo)
}
return []
}
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
InvidiousAPI.extractChannel(from: content.json)
self.extractChannel(from: content.json)
}
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
InvidiousAPI.extractChannelPlaylist(from: content.json)
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
InvidiousAPI.extractVideo(from: content.json)
self.extractVideo(from: content.json)
}
}
@@ -291,7 +295,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return AVURLAsset(url: url)
}
static func extractVideo(from json: JSON) -> Video {
func extractVideo(from json: JSON) -> Video {
let indexID: String?
var id: Video.ID
var publishedAt: Date?
@@ -334,8 +338,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
)
}
static func extractChannel(from json: JSON) -> Channel {
let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")"
func extractChannel(from json: JSON) -> Channel {
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(
id: json["authorId"].stringValue,
@@ -343,33 +354,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
thumbnailURL: URL(string: thumbnailURL),
subscriptionsCount: json["subCount"].int,
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
return ChannelPlaylist(
id: details["playlistId"]!.stringValue,
title: details["title"]!.stringValue,
thumbnailURL: details["playlistThumbnail"]?.url,
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
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) +
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
}
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
SingleAssetStream(
avAsset: AVURLAsset(url: $0["url"].url!),
@@ -380,7 +391,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") }
guard audioAssetURL != nil else {
return []
@@ -399,10 +410,20 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
}
private static func extractRelated(from content: JSON) -> [Video] {
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["recommendedVideos"]?
.arrayValue
.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
PipedAPI.extractChannel(from: content.json)
self.extractChannel(from: content.json)
}
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
PipedAPI.extractVideo(from: content.json)
self.extractVideo(from: content.json)
}
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
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
self.extractContentItems(from: content.json.dictionaryValue["items"]!)
}
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
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
content.json.arrayValue.map { self.extractChannel(from: $0)! }
}
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
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 disabled = details["disabled"]?.boolValue ?? false
@@ -86,7 +86,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
func needsAuthorization(_ url: URL) -> Bool {
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
}
func updateToken() {
@@ -190,7 +190,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
"**\(path)"
}
private static func extractContentItem(from content: JSON) -> ContentItem? {
private func extractContentItem(from content: JSON) -> ContentItem? {
let details = content.dictionaryValue
let url: String! = details["url"]?.string
@@ -210,17 +210,17 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
switch contentType {
case .video:
if let video = PipedAPI.extractVideo(from: content) {
if let video = extractVideo(from: content) {
return ContentItem(video: video)
}
case .playlist:
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
if let playlist = extractChannelPlaylist(from: content) {
return ContentItem(playlist: playlist)
}
case .channel:
if let channel = PipedAPI.extractChannel(from: content) {
if let channel = extractChannel(from: content) {
return ContentItem(channel: channel)
}
}
@@ -228,11 +228,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return nil
}
private static func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
private func extractContentItems(from content: JSON) -> [ContentItem] {
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
guard let id = attributes["id"]?.stringValue ??
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
@@ -244,25 +244,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
var videos = [Video]()
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(
id: id,
name: attributes["name"]!.stringValue,
thumbnailURL: attributes["thumbnail"]?.url,
name: name,
thumbnailURL: thumbnailURL,
subscriptionsCount: subscriptionsCount,
videos: videos
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
let details = json.dictionaryValue
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
var videos = [Video]()
if let relatedStreams = details["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
videos = extractVideos(from: relatedStreams)
}
return ChannelPlaylist(
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 url = details["url"]?.string
@@ -287,7 +290,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
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)
}
@@ -295,18 +298,20 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
return Video(
videoID: PipedAPI.extractID(from: content),
videoID: extractID(from: content),
title: details["title"]!.stringValue,
author: author,
length: details["duration"]!.doubleValue,
published: published,
views: details["views"]!.intValue,
description: PipedAPI.extractDescription(from: content),
channel: Channel(id: channelId, name: author),
description: extractDescription(from: content),
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
thumbnails: thumbnails,
likes: details["likes"]?.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 ??
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!
}
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)
guard !thumbnailURL.isNil else {
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 {
return nil
}
@@ -359,22 +364,22 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return description
}
private static func extractVideos(from content: JSON) -> [Video] {
private func extractVideos(from content: JSON) -> [Video] {
content.arrayValue.compactMap(extractVideo(from:))
}
private static func extractStreams(from content: JSON) -> [Stream] {
private func extractStreams(from content: JSON) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(hlsURL: hlsURL))
}
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
guard let audioStream = compatibleAudioStreams(from: content).first else {
return streams
}
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
let videoStreams = compatibleVideoStream(from: content)
videoStreams.forEach { videoStream in
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
@@ -397,14 +402,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return streams
}
private static func extractRelated(from content: JSON) -> [Video] {
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["relatedStreams"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
private func compatibleAudioStreams(from content: JSON) -> [JSON] {
content
.dictionaryValue["audioStreams"]?
.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
.dictionaryValue["videoStreams"]?
.arrayValue
.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 author = details["author"]?.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? {
guard let frontendHost = frontendHost ?? account.instance.frontendHost else {
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
var urlComponents = account?.instance?.urlComponents
else {
return nil
}
var urlComponents = account.instance.urlComponents
urlComponents.host = frontendHost
var queryItems = [URLQueryItem]()

View File

@@ -28,6 +28,10 @@ struct Channel: Identifiable, Hashable {
self.videos = videos
}
var detailsLoaded: Bool {
!subscriptionsString.isNil
}
var subscriptionsString: String? {
if subscriptionsCount != nil, subscriptionsCount! > 0 {
return subscriptionsCount!.formattedAsAbbreviation()

View File

@@ -8,39 +8,49 @@ final class CommentsModel: ObservableObject {
@Published var nextPage: String?
@Published var firstPage = true
@Published var loaded = true
@Published var loading = false
@Published var loaded = false
@Published var disabled = false
@Published var replies = [Comment]()
@Published var repliesPageID: String?
@Published var repliesLoaded = false
var accounts: AccountsModel!
var player: PlayerModel!
var instance: Instance? {
static var instance: Instance? {
InstancesModel.find(Defaults[.commentsInstanceID])
}
var api: VideosAPI? {
instance.isNil ? nil : PipedAPI(account: instance!.anonymousAccount)
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
}
static var enabled: Bool {
!Defaults[.commentsInstanceID].isNil && !Defaults[.commentsInstanceID]!.isEmpty
!instance.isNil
}
#if !os(tvOS)
static var placement: CommentsPlacement {
Defaults[.commentsPlacement]
}
#endif
var nextPageAvailable: Bool {
!(nextPage?.isEmpty ?? true)
}
func load(page: String? = nil) {
guard Self.enabled else {
guard Self.enabled, !loading else {
return
}
reset()
guard !instance.isNil,
loading = true
guard !Self.instance.isNil,
!(player?.currentVideo.isNil ?? true)
else {
return
@@ -58,6 +68,7 @@ final class CommentsModel: ObservableObject {
}
}
.onCompletion { [weak self] _ in
self?.loading = false
self?.loaded = true
}
}
@@ -71,7 +82,12 @@ final class CommentsModel: ObservableObject {
return
}
if page == repliesPageID {
return
}
replies = []
repliesPageID = page
repliesLoaded = false
api?.comments(player.currentVideo!.videoID, page: page)?
@@ -79,9 +95,10 @@ final class CommentsModel: ObservableObject {
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {
self?.replies = page.comments
self?.repliesLoaded = true
}
}
.onCompletion { [weak self] _ in
.onFailure { [weak self] _ in
self?.repliesLoaded = true
}
}
@@ -92,6 +109,7 @@ final class CommentsModel: ObservableObject {
firstPage = true
nextPage = nil
loaded = false
loading = false
replies = []
repliesLoaded = false
}

View File

@@ -41,6 +41,36 @@ final class NavigationModel: ObservableObject {
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false
static func openChannel(
_ channel: Channel,
player: PlayerModel,
recents: RecentsModel,
navigation: NavigationModel,
navigationStyle: NavigationStyle
) {
let recent = RecentItem(from: channel)
#if os(macOS)
OpenWindow.main.open()
#else
player.hide()
#endif
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> {
Binding<TabSelection>(
get: {

View File

@@ -15,33 +15,32 @@ final class PlayerModel: ObservableObject {
let logger = Logger(label: "stream.yattee.app")
private(set) var player = AVPlayer()
private(set) var playerView = Player()
var controller: PlayerViewController? { didSet { playerView.controller = controller } }
#if os(tvOS)
var avPlayerViewController: AVPlayerViewController?
#endif
var playerView = Player()
var controller: PlayerViewController?
@Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } }
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
@Published var stream: Stream?
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } }
@Published var availableStreams = [Stream]() { didSet { rebuildTVMenu() } }
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
@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 { handleNavigationViewPlayerPresentationChange() } }
@Published var sponsorBlock = SponsorBlockAPI()
@Published var segmentRestorationTime: CMTime?
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
@Published var restoredSegments = [Segment]()
@Published var channelWithDetails: Channel?
var accounts: AccountsModel
var comments: CommentsModel
@@ -68,6 +67,14 @@ final class PlayerModel: ObservableObject {
#endif
}}
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
@Default(.closePiPOnNavigation) var closePiPOnNavigation
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
#if !os(macOS)
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
#endif
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.comments = comments ?? CommentsModel()
@@ -78,12 +85,41 @@ final class PlayerModel: ObservableObject {
addPlayerTimeControlStatusObserver()
}
func presentPlayer() {
func show() {
guard !presentingPlayer else {
#if os(macOS)
OpenWindow.player.focus()
#endif
return
}
#if os(macOS)
OpenWindow.player.open()
OpenWindow.player.focus()
#endif
presentingPlayer = true
}
func hide() {
guard presentingPlayer else {
return
}
presentingPlayer = false
}
func togglePlayer() {
presentingPlayer.toggle()
#if os(macOS)
if !presentingPlayer {
OpenWindow.player.open()
}
OpenWindow.player.focus()
#else
if presentingPlayer {
hide()
} else {
show()
}
#endif
}
var isPlaying: Bool {
@@ -128,19 +164,29 @@ final class PlayerModel: ObservableObject {
func upgradeToStream(_ 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(
_ stream: Stream,
of video: Video,
preservingTime: Bool = false
preservingTime: Bool = false,
upgrading: Bool = false
) {
playerError = nil
resetSegments()
sponsorBlock.loadSegments(videoID: video.videoID, categories: Defaults[.sponsorBlockCategories])
comments.load()
if !upgrading {
resetSegments()
sponsorBlock.loadSegments(
videoID: video.videoID,
categories: Defaults[.sponsorBlockCategories]
) { [weak self] in
if Defaults[.showChannelSubscribers] {
self?.loadCurrentItemChannelDetails()
}
}
}
if let url = stream.singleAssetURL {
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
@@ -154,11 +200,52 @@ final class PlayerModel: ObservableObject {
loadComposition(stream, of: video, preservingTime: preservingTime)
}
updateCurrentArtwork()
if !upgrading {
updateCurrentArtwork()
}
}
private func pauseOnPlayerDismiss() {
if !playingInPictureInPicture, !presentingPlayer {
private func handleAvailableStreamsChange() {
rebuildTVMenu()
guard stream.isNil else {
return
}
guard let stream = preferredStream(availableStreams) else {
return
}
streamSelection = stream
playStream(
stream,
of: currentVideo!,
preservingTime: !currentItem.playbackTime.isNil
)
}
private func handlePresentationChange() {
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.closePiP()
}
}
if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.pause()
}
}
if !presentingPlayer, !pauseOnHidingPlayer, isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
}
}
private func handleNavigationViewPlayerPresentationChange() {
if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.pause()
}
@@ -193,25 +280,47 @@ final class PlayerModel: ObservableObject {
if self.isAutoplaying(playerItem!) {
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 = {
self.player.replaceCurrentItem(with: playerItem)
self.seekToSavedTime { finished in
self.seekToPreservedTime { finished in
guard finished else {
return
}
self.savedTime = nil
self.preservedTime = nil
startPlaying()
}
}
if preservingTime {
if savedTime.isNil {
if preservedTime.isNil {
saveTime {
replaceItemAndSeek()
startPlaying()
@@ -313,6 +422,10 @@ final class PlayerModel: ObservableObject {
item.preferredForwardBufferDuration = 5
observePlayerItemStatus(item)
}
private func observePlayerItemStatus(_ item: AVPlayerItem) {
statusObservation?.invalidate()
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
guard let self = self else {
@@ -364,11 +477,9 @@ final class PlayerModel: ObservableObject {
addCurrentItemToHistory()
resetQueue()
#if os(tvOS)
avPlayerViewController!.dismiss(animated: true) { [weak self] in
self?.controller!.dismiss(animated: true)
}
controller?.dismiss(animated: true)
#endif
presentingPlayer = false
hide()
} else {
advanceToNextItem()
}
@@ -382,13 +493,13 @@ final class PlayerModel: ObservableObject {
}
DispatchQueue.main.async { [weak self] in
self?.savedTime = currentTime
self?.preservedTime = currentTime
completionHandler()
}
}
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = savedTime else {
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = preservedTime else {
return
}
@@ -480,7 +591,11 @@ final class PlayerModel: ObservableObject {
}
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] = [
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
@@ -516,6 +631,36 @@ final class PlayerModel: ObservableObject {
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
}
func loadCurrentItemChannelDetails() {
guard let video = currentVideo,
!video.channel.detailsLoaded
else {
return
}
if restoreLoadedChannel() {
return
}
accounts.api.channel(video.channel.id).load().onSuccess { [weak self] response in
if let channel: Channel = response.typedContent() {
self?.channelWithDetails = channel
withAnimation {
self?.currentItem.video.channel = channel
}
}
}
}
@discardableResult func restoreLoadedChannel() -> Bool {
if !currentVideo.isNil, channelWithDetails?.id == currentVideo!.channel.id {
currentItem.video.channel = channelWithDetails!
return true
}
return false
}
func rateLabel(_ rate: Float) -> String {
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
@@ -529,4 +674,70 @@ final class PlayerModel: ObservableObject {
currentItem = nil
player.replaceCurrentItem(with: nil)
}
func closePiP() {
guard playingInPictureInPicture else {
return
}
let wasPlaying = isPlaying
pause()
#if os(tvOS)
show()
closePipByReplacingItem(wasPlaying: wasPlaying)
#else
closePiPByNilingPlayer(wasPlaying: wasPlaying)
#endif
}
private func closePipByReplacingItem(wasPlaying: Bool) {
let item = player.currentItem
let time = player.currentTime()
self.player.replaceCurrentItem(with: nil)
guard !item.isNil else {
return
}
self.player.seek(to: time)
self.player.replaceCurrentItem(with: item)
guard wasPlaying else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
}
private func closePiPByNilingPlayer(wasPlaying: Bool) {
controller?.playerView.player = nil
controller?.playerView.player = player
guard wasPlaying else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.play()
}
}
#if os(macOS)
var windowTitle: String {
currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)"
}
#else
func handleEnterForeground() {
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
return
}
show()
closePiP()
}
#endif
}

View File

@@ -1,4 +1,4 @@
import AVFoundation
import AVKit
import Defaults
import Foundation
import Siesta
@@ -29,6 +29,10 @@ extension PlayerModel {
}
func playNow(_ video: Video, at time: TimeInterval? = nil) {
if !playingInPictureInPicture || closePiPOnNavigation {
closePiP()
}
addCurrentItemToHistory()
enqueueVideo(video, prepending: true) { _, item in
@@ -37,7 +41,12 @@ extension PlayerModel {
}
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
if !playingInPictureInPicture {
player.replaceCurrentItem(with: nil)
}
comments.reset()
stream = nil
currentItem = item
if !time.isNil {
@@ -50,23 +59,13 @@ extension PlayerModel {
currentItem.video = video!
}
savedTime = currentItem.playbackTime
preservedTime = currentItem.playbackTime
restoreLoadedChannel()
loadAvailableStreams(currentVideo!) { streams in
guard let stream = self.preferredStream(streams) else {
return
}
self.streamSelection = stream
self.playStream(
stream,
of: self.currentVideo!,
preservingTime: !self.currentItem.playbackTime.isNil
)
}
loadAvailableStreams(currentVideo!)
}
private func preferredStream(_ streams: [Stream]) -> Stream? {
func preferredStream(_ streams: [Stream]) -> Stream? {
let quality = Defaults[.quality]
var streams = streams
@@ -124,7 +123,7 @@ extension PlayerModel {
}
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
player.currentItem == item && presentingPlayer
player.currentItem == item
}
@discardableResult func enqueueVideo(

View File

@@ -15,21 +15,20 @@ extension PlayerModel {
availableStreams.sorted(by: streamsSorter)
}
func loadAvailableStreams(
_ video: Video,
completionHandler: @escaping ([Stream]) -> Void = { _ in }
) {
func loadAvailableStreams(_ video: Video) {
availableStreams = []
var instancesWithLoadedStreams = [Instance]()
let playerInstance = InstancesModel.forPlayer ?? InstancesModel.all.first
InstancesModel.all.forEach { instance in
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler
)
guard !playerInstance.isNil else {
return
}
logger.info("loading streams from \(playerInstance!.description)")
fetchStreams(playerInstance!.anonymous.video(video.videoID), instance: playerInstance!, video: video) { _ in
InstancesModel.all.filter { $0 != playerInstance }.forEach { instance in
self.logger.info("loading streams from \(instance.description)")
self.fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video)
}
}
}
@@ -45,25 +44,13 @@ extension PlayerModel {
.onSuccess { response in
if let video: Video = response.typedContent() {
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
} else {
self.logger.critical("no streams available from \(instance.description)")
}
}
.onCompletion(onCompletion)
}
private func completeIfAllInstancesLoaded(
instance: Instance,
streams: [Stream],
instancesWithLoadedStreams: inout [Instance],
completionHandler: @escaping ([Stream]) -> Void
) {
instancesWithLoadedStreams.append(instance)
rebuildTVMenu()
if InstancesModel.all.count == instancesWithLoadedStreams.count {
completionHandler(streams.sorted { $0.kind < $1.kind })
}
}
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in
stream.instance = instance

View File

@@ -66,7 +66,7 @@ extension PlayerModel {
func rebuildTVMenu() {
#if os(tvOS)
avPlayerViewController?.transportBarCustomMenuItems = [
controller?.playerView.transportBarCustomMenuItems = [
restoreLastSkippedSegmentAction,
rateMenu,
streamsMenu

View File

@@ -22,11 +22,12 @@ struct Playlist: Identifiable, Equatable, Hashable {
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.title = title
self.visibility = visibility
self.updated = updated
self.videos = videos
}
init(_ json: JSON) {
@@ -34,7 +35,6 @@ struct Playlist: Identifiable, Equatable, Hashable {
title = json["title"].stringValue
visibility = json["isListed"].boolValue ? .public : .private
updated = json["updated"].doubleValue
videos = json["videos"].arrayValue.map { InvidiousAPI.extractVideo(from: $0) }
}
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 body = ["videoId": videoID]
resource?.request(.post, json: body).onSuccess { _ in
self.load(force: true)
onSuccess()
}
resource?
.request(.post, json: body)
.onSuccess { _ in
self.load(force: true)
onSuccess()
}
.onFailure(onFailure)
}
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {

View File

@@ -1,22 +0,0 @@
import Alamofire
import Foundation
import SwiftyJSON
final class PlaylistsProvider: DataProvider {
@Published var playlists = [Playlist]()
let profile = Profile()
func load(successHandler: @escaping ([Playlist]) -> Void = { _ in }) {
let headers = HTTPHeaders([HTTPHeader(name: "Cookie", value: "SID=\(profile.sid)")])
DataProvider.request("auth/playlists", headers: headers).responseJSON { response in
switch response.result {
case let .success(value):
self.playlists = JSON(value).arrayValue.map { Playlist($0) }
successHandler(self.playlists)
case let .failure(error):
print(error)
}
}
}
}

View File

@@ -9,6 +9,7 @@ final class SearchModel: ObservableObject {
@Published var query = SearchQuery()
@Published var queryText = ""
@Published var querySuggestions = Store<[String]>()
@Published var suggestionsText = ""
@Published var fieldIsFocused = false
@@ -88,7 +89,7 @@ final class SearchModel: ObservableObject {
suggestionsDebounceTimer?.invalidate()
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
let resource = self.accounts.api.searchSuggestions(query: query)
resource.addObserver(self.querySuggestions)
@@ -99,9 +100,11 @@ final class SearchModel: ObservableObject {
if let suggestions: [String] = response.typedContent() {
self.querySuggestions = Store<[String]>(suggestions)
}
self.suggestionsText = query
}
} else {
self.querySuggestions = Store<[String]>(self.querySuggestions.collection)
self.suggestionsText = query
}
}
}

View File

@@ -7,7 +7,7 @@ import SwiftyJSON
final class SponsorBlockAPI: ObservableObject {
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 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 {
completionHandler()
return
}
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 {
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 {
case let .success(value):
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)")
}
completionHandler()
}
}

View File

@@ -85,7 +85,7 @@ struct Video: Identifiable, Equatable, Hashable {
}
var likesCount: String? {
guard likes != -1 else {
guard (likes ?? 0) > 0 else {
return nil
}
@@ -93,7 +93,7 @@ struct Video: Identifiable, Equatable, Hashable {
}
var dislikesCount: String? {
guard dislikes != -1 else {
guard (dislikes ?? 0) > 0 else {
return nil
}

119
README.md
View File

@@ -1,25 +1,23 @@
![Yattee Banner](https://r.yattee.stream/icons/yattee-banner.png)
Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) instances built for iOS, tvOS and macOS.
<div align="center">
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
<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)
[![GitHub issues](https://img.shields.io/github/issues/yattee/yattee)](https://github.com/yattee/yattee/issues)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/yattee/yattee)](https://github.com/yattee/yattee/pulls)
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#yattee:matrix.org)
![Screenshot](https://r.yattee.stream/screenshots/all-platforms.png)
</div>
## 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
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
* Player queue and history
* Fullscreen playback, Picture in Picture and AirPlay support
* Stream quality selection
* Favorites: customizable section of channels, playlists, trending, searches and other views
* `yattee://` URL Scheme for integrations
### Availability
| Feature | Invidious | Piped |
@@ -37,104 +35,15 @@ Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](ht
| Subtitles | 🔴 | ✅ |
| Comments | 🔴 | ✅ |
## Installation
### Requirements
System requirements:
* iOS 14 (or newer)
* tvOS 15 (or newer)
* macOS Big Sur (or newer)
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.
### How to install?
#### macOS
Download and run latest version from the [Releases](https://github.com/yattee/yattee/releases) page.
#### iOS/tvOS: [AltStore](https://altstore.io/) (free)
You can sideload IPA files downloaded from the [Releases](https://github.com/yattee/yattee/releases) page to your iOS or tvOS device - check [AltStore FAQ](https://altstore.io/faq/) for more information.
If you have to access to the beta AltStore version (v1.5, for Patreons only), you can add the following repository in `Browse > Sources` screen:
`https://alt.yattee.stream`
#### iOS/tvOS: Signing IPA files online (paid)
[UDID Registrations](https://www.udidregistrations.com/) provides services to sign IPA files for your devices. Refer to: ***Break free from the App Store*** section of the website for more information.
#### iOS/tvOS: Manual installation
Download sources and compile them on a Mac using Xcode, install to your devices. Please note that if you are not registered in Apple Developer Program you will need to reinstall every 7 days.
## Integrations
### macOS
With [Finicky](https://github.com/johnste/finicky) you can configure your system to open all the video links in the app. Example configuration:
```js
{
match: [
finicky.matchDomains(/(.*\.)?youtube.com/),
finicky.matchDomains(/(.*\.)?youtu.be/)
],
browser: "/Applications/Yattee.app"
}
```
## Screenshots
### iOS
| Player | Search | Playlists |
| - | - | - |
| [![Yattee Player iOS](https://r.yattee.stream/screenshots/iOS/player-thumb.png)](https://r.yattee.stream/screenshots/iOS/player.png) | [![Yattee Search iOS](https://r.yattee.stream/screenshots/iOS/search-suggestions-thumb.png)](https://r.yattee.stream/screenshots/iOS/search-suggestions.png) | [![Yattee Subscriptions iOS](https://r.yattee.stream/screenshots/iOS/playlists-thumb.png)](https://r.yattee.stream/screenshots/iOS/playlists.png) |
### iPadOS
| Settings | Player | Subscriptions |
| - | - | - |
| [![Yattee Player iPadOS](https://r.yattee.stream/screenshots/iPadOS/settings-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/settings.png) | [![Yattee Player iPadOS](https://r.yattee.stream/screenshots/iPadOS/player-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/player.png) | [![Yattee Subscriptions iPad S](https://r.yattee.stream/screenshots/iPadOS/subscriptions-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/subscriptions.png) |
### tvOS
| Player | Popular | Search | Now Playing | Settings |
| - | - | - | - | - |
| [![Yattee Player tvOS](https://r.yattee.stream/screenshots/tvOS/player-thumb.png)](https://r.yattee.stream/screenshots/tvOS/player.png) | [![Yattee Popular tvOS](https://r.yattee.stream/screenshots/tvOS/popular-thumb.png)](https://r.yattee.stream/screenshots/tvOS/popular.png) | [![Yattee Search tvOS](https://r.yattee.stream/screenshots/tvOS/search-thumb.png)](https://r.yattee.stream/screenshots/tvOS/search.png) | [![Yattee Now Playing tvOS](https://r.yattee.stream/screenshots/tvOS/now-playing-thumb.png)](https://r.yattee.stream/screenshots/tvOS/now-playing.png) | [![Yattee Settings tvOS](https://r.yattee.stream/screenshots/tvOS/settings-thumb.png)](https://r.yattee.stream/screenshots/tvOS/settings.png) |
### macOS
| Player | Channel | Search | Settings |
| - | - | - | - |
| [![Yattee Player macOS](https://r.yattee.stream/screenshots/macOS/player-thumb.png)](https://r.yattee.stream/screenshots/macOS/player.png) | [![Yattee Channel macOS](https://r.yattee.stream/screenshots/macOS/channel-thumb.png)](https://r.yattee.stream/screenshots/macOS/channel.png) | [![Yattee Search macOS](https://r.yattee.stream/screenshots/macOS/search-thumb.png)](https://r.yattee.stream/screenshots/macOS/search.png) | [![Yattee Settings macOS](https://r.yattee.stream/screenshots/macOS/settings-thumb.png)](https://r.yattee.stream/screenshots/macOS/settings.png) |
## Tips
### Settings
* [tvOS] To open settings, press Play/Pause button while hovering over navigation menu or video
### Navigation
* Use videos context menus to add to queue, open or subscribe channel and add to playlist
* [tvOS] Pressing buttons in the app trigger switch to next available option (for example: next account in Settings). If you want to access list of all options, press and hold to open the context menu.
* [iOS] Swipe the player/title bar: up to open fullscreen details view, bottom to close fullscreen details or hide player
### Favorites
* Add more sections using ❤️ button in views channels, playlists, searches, subscriptions and popular
* [iOS/macOS] Reorganize with dragging and dropping
* [iOS/macOS] Remove section with right click/press and hold on section name
* [tvOS] Reorganize and remove from `Settings > Edit Favorites...`
### Keyboard shortcuts
* `Command+1` - Favorites
* `Command+2` - Subscriptions
* `Command+3` - Popular
* `Command+4` - Trending
* `Command+F` - Search
* `Command+P` - Play/Pause
* `Command+S` - Play Next
* `Command+O` - Toggle Player
## Donations
You can support development of this app with
[Patreon](https://www.patreon.com/arekf) or cryptocurrencies:
**Monero (XMR)**
```
48zfKjLmnXs21PinU2ucMiUPwhiKt5d7WJKiy3ACVS28BKqSn52c1TX8L337oESHJ5TZCyGkozjfWZG11h6C46mN9n4NPrD
```
**Bitcoin (BTC)**
```
bc1qe24zz5a5hm0trc7glwckz93py274eycxzju3mv
```
**Ethereum (ETH)**
```
0xa2f81A58Ec5E550132F03615c8d91954A4E37423
```
Donations will be used to cover development program access and domain renewal costs.
## Documentation
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-Instructions)
* [FAQ](https://github.com/yattee/yattee/wiki)
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
## Contributing
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.

View File

@@ -22,7 +22,12 @@ extension Defaults.Keys {
static let favorites = Key<[FavoriteItem]>("favorites", default: [
.init(section: .trending("US", "default")),
.init(section: .trending("GB", "default")),
.init(section: .trending("ES", "default")),
.init(section: .channel("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "PewDiePie")),
.init(section: .channel("UCXuqSBlHAE6Xw-yeJA0Tunw", "Linus Tech Tips")),
.init(section: .channel("UCBJycsmduvYEL83R_U4JriQ", "Marques Brownlee")),
.init(section: .channel("UCE_M8A5yxnLfW0KghEeajjw", "Apple")),
.init(section: .searchQuery("Apple Pie Recipes", "", "", ""))
])
@@ -33,7 +38,18 @@ extension Defaults.Keys {
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
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)
#if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: true)
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
#if !os(macOS)
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
#endif
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
@@ -48,6 +64,10 @@ extension Defaults.Keys {
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.favorites, .subscriptions, .trending, .playlists])
#if os(macOS)
static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false)
#endif
}
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
@@ -129,3 +149,9 @@ enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
lhs.sortOrder < rhs.sortOrder
}
}
#if !os(tvOS)
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
case info, separate
}
#endif

View File

@@ -49,23 +49,29 @@ struct FavoriteItemView: View {
}
.contentShape(Rectangle())
.opacity(dragging?.id == item.id ? 0.5 : 1)
.onAppear {
resource?.addObserver(store)
resource?.load()
}
#if os(macOS)
.opacity(dragging?.id == item.id ? 0.5 : 1)
#endif
.onAppear {
resource?.addObserver(store)
resource?.load()
}
#if !os(tvOS)
.onDrag {
dragging = item
return NSItemProvider(object: item.id as NSString)
}
.onDrop(
of: [UTType.text],
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
)
.onDrag {
dragging = item
return NSItemProvider(object: item.id as NSString)
}
.onDrop(
of: [UTType.text],
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
)
#endif
}
}
.onChange(of: accounts.current) { _ in
resource?.addObserver(store)
resource?.load()
}
}
private var isVisible: Bool {

View File

@@ -62,10 +62,18 @@ struct MenuCommands: Commands {
.disabled(model.player?.queue.isEmpty ?? true)
.keyboardShortcut("s")
Button((model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player") {
Button(togglePlayerLabel) {
model.player?.togglePlayer()
}
.keyboardShortcut("o")
}
}
private var togglePlayerLabel: String {
#if os(macOS)
"Show Player"
#else
(model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player"
#endif
}
}

View File

@@ -62,23 +62,15 @@ struct AppSidebarNavigation: View {
}
}
}
#if os(iOS)
.background(
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
videoPlayer
.environment(\.navigationStyle, .sidebar)
}
)
#elseif os(macOS)
.background(
EmptyView().sheet(isPresented: $player.presentingPlayer) {
videoPlayer
.frame(minWidth: 1000, minHeight: 750)
.environment(\.navigationStyle, .sidebar)
}
)
#endif
.environment(\.navigationStyle, .sidebar)
#if os(iOS)
.background(
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
videoPlayer
.environment(\.navigationStyle, .sidebar)
}
)
#endif
}
private var videoPlayer: some View {

View File

@@ -52,6 +52,12 @@ struct AppTabNavigation: View {
ChannelVideosView(channel: channel)
.environment(\.inChannelView, true)
.environment(\.inNavigationView, true)
.environmentObject(accounts)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.background(playerNavigationLink)
}
}
@@ -67,6 +73,12 @@ struct AppTabNavigation: View {
NavigationView {
ChannelPlaylistView(playlist: playlist)
.environment(\.inNavigationView, true)
.environmentObject(accounts)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.background(playerNavigationLink)
}
}
@@ -75,7 +87,7 @@ struct AppTabNavigation: View {
.background(
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
videoPlayer
.environment(\.navigationStyle, .sidebar)
.environment(\.navigationStyle, .tab)
}
)
}
@@ -159,7 +171,7 @@ struct AppTabNavigation: View {
private var playerNavigationLink: some View {
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
VideoPlayerView()
videoPlayer
.environment(\.inNavigationView, true)
}) {
EmptyView()

View File

@@ -7,16 +7,16 @@ import Siesta
import SwiftUI
struct ContentView: View {
@StateObject private var accounts = AccountsModel()
@StateObject private var comments = CommentsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var navigation = NavigationModel()
@StateObject private var player = PlayerModel()
@StateObject private var playlists = PlaylistsModel()
@StateObject private var recents = RecentsModel()
@StateObject private var search = SearchModel()
@StateObject private var subscriptions = SubscriptionsModel()
@StateObject private var thumbnailsModel = ThumbnailsModel()
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
@EnvironmentObject<MenuModel> private var menu
@@ -61,7 +61,6 @@ struct ContentView: View {
}
)
#if !os(tvOS)
.handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"]))
.onOpenURL(perform: handleOpenedURL)
.background(
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
@@ -162,7 +161,7 @@ struct ContentView: View {
if let video: Video = response.typedContent() {
player.addCurrentItemToHistory()
self.player.playNow(video, at: parser.time)
self.player.presentPlayer()
self.player.show()
}
}
}

View File

@@ -1,65 +1,81 @@
import SDWebImageSwiftUI
import SwiftUI
struct CommentView: View {
let comment: Comment
@Binding var repliesID: Comment.ID?
@State private var subscribed = false
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
@Environment(\.colorScheme) private var colorScheme
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .center, spacing: 10) {
authorAvatar
HStack(spacing: 10) {
ZStack(alignment: .bottomTrailing) {
authorAvatar
#if os(iOS)
Group {
if horizontalSizeClass == .regular {
HStack(spacing: 20) {
authorAndTime
Spacer()
Group {
statusIcons
likes
}
}
} else {
HStack(alignment: .center, spacing: 20) {
authorAndTime
Spacer()
VStack(alignment: .trailing, spacing: 8) {
likes
statusIcons
}
}
if subscribed {
Image(systemName: "star.circle.fill")
#if os(tvOS)
.background(Color.background(scheme: colorScheme))
#else
.background(Color.background)
#endif
.clipShape(Circle())
.foregroundColor(.secondary)
}
}
.font(.system(size: 15))
.onAppear {
subscribed = subscriptions.isSubscribing(comment.channel.id)
}
#else
HStack(spacing: 20) {
authorAndTime
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
likes
}
#endif
#endif
}
}
#if os(tvOS)
.font(.system(size: 25).bold())
#else
.font(.system(size: 15))
#endif
Group {
commentText
@@ -94,23 +110,25 @@ struct CommentView: View {
.retryOnAppear(false)
.indicator(.activity)
.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)
.frame(width: 80, height: 80, alignment: .leading)
.focusable()
#else
.frame(width: 45, height: 45, alignment: .leading)
#endif
}
private var authorAndTime: some View {
VStack(alignment: .leading) {
Text(comment.author)
.fontWeight(.bold)
#if os(tvOS)
.font(.system(size: 30).bold())
#else
.font(.system(size: 14).bold())
#endif
Text(comment.time)
.font(.caption2)
.foregroundColor(.secondary)
}
.lineLimit(1)
@@ -125,6 +143,9 @@ struct CommentView: View {
Image(systemName: "heart.fill")
}
}
#if !os(tvOS)
.font(.system(size: 12))
#endif
.foregroundColor(.secondary)
}
@@ -135,6 +156,9 @@ struct CommentView: View {
Image(systemName: "hand.thumbsup")
Text("\(comment.likeCount.formattedAsAbbreviation())")
}
#if !os(tvOS)
.font(.system(size: 12))
#endif
}
}
.foregroundColor(.secondary)
@@ -144,10 +168,6 @@ struct CommentView: View {
Button {
repliesID = repliesID == comment.id ? nil : comment.id
if repliesID.isNil {
comments.replies = []
}
guard !repliesID.isNil, !comment.repliesPage.isNil else {
return
}
@@ -167,6 +187,7 @@ struct CommentView: View {
#if os(tvOS)
.padding(.leading, 5)
#else
.font(.system(size: 13))
.foregroundColor(.secondary)
#endif
}
@@ -185,7 +206,7 @@ struct CommentView: View {
#if os(macOS)
0.4
#else
0.8
0.6
#endif
}
@@ -230,17 +251,13 @@ struct CommentView: View {
}
private func openChannelAction() {
player.presentingPlayer = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let recent = RecentItem(from: comment.channel)
recents.add(recent)
navigation.presentingChannel = true
if navigationStyle == .sidebar {
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
}
NavigationModel.openChannel(
comment.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
}
}
@@ -251,5 +268,7 @@ struct CommentView_Previews: PreviewProvider {
static var previews: some View {
CommentView(comment: fixture, repliesID: .constant(fixture.id))
.environmentObject(SubscriptionsModel())
.padding(5)
}
}

View File

@@ -16,6 +16,9 @@ struct CommentsView: View {
.foregroundColor(.secondary)
} else if !comments.loaded {
progressView
.onAppear {
comments.load()
}
} else {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading) {
@@ -48,6 +51,7 @@ struct CommentsView: View {
}
}
}
.font(.system(size: 13))
.buttonStyle(.plain)
.padding(.vertical, 8)
.foregroundColor(.secondary)
@@ -56,11 +60,6 @@ struct CommentsView: View {
}
}
.padding(.horizontal)
.onAppear {
if !comments.loaded {
comments.load()
}
}
}
private var progressView: some View {

View File

@@ -11,7 +11,9 @@ struct PlaybackBar: View {
var body: some View {
HStack {
closeButton
#if !os(macOS)
closeButton
#endif
if player.currentItem != nil {
HStack {
@@ -20,6 +22,9 @@ struct PlaybackBar: View {
rateMenu
}
.font(.caption2)
#if os(macOS)
.padding(.leading, 4)
#endif
Spacer()
@@ -68,7 +73,7 @@ struct PlaybackBar: View {
message: Text(player.playerError?.localizedDescription ?? "")
)
}
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 20)
.padding(4)
.background(colorScheme == .dark ? Color.black : Color.white)
}

View File

@@ -5,6 +5,7 @@ struct Player: UIViewControllerRepresentable {
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<SubscriptionsModel> private var subscriptions
var controller: PlayerViewController?
@@ -22,6 +23,7 @@ struct Player: UIViewControllerRepresentable {
controller.commentsModel = comments
controller.navigationModel = navigation
controller.playerModel = player
controller.subscriptionsModel = subscriptions
player.controller = controller
return controller

View File

@@ -1,3 +1,4 @@
import Defaults
import Foundation
import SwiftUI
@@ -8,6 +9,8 @@ struct PlayerQueueRow: View {
@EnvironmentObject<PlayerModel> private var player
@Default(.closePiPOnNavigation) var closePiPOnNavigation
var body: some View {
Group {
Button {
@@ -24,6 +27,10 @@ struct PlayerQueueRow: View {
fullScreen = false
}
}
if closePiPOnNavigation, player.playingInPictureInPicture {
player.closePiP()
}
} label: {
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
}

View File

@@ -76,11 +76,16 @@ struct PlayerQueueView: View {
ForEach(player.currentVideo!.related) { video in
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
.contextMenu {
Button("Play Next") {
Button {
player.playNext(video)
} label: {
Label("Play Next", systemImage: "text.insert")
}
Button("Play Last") {
Button {
player.enqueueVideo(video)
} label: {
Label("Play Last", systemImage: "text.append")
}
}
}

View File

@@ -1,5 +1,5 @@
import AVKit
import Logging
import Defaults
import SwiftUI
final class PlayerViewController: UIViewController {
@@ -7,11 +7,12 @@ final class PlayerViewController: UIViewController {
var commentsModel: CommentsModel!
var navigationModel: NavigationModel!
var playerModel: PlayerModel!
var playerViewController = AVPlayerViewController()
var subscriptionsModel: SubscriptionsModel!
var playerView = AVPlayerViewController()
#if !os(tvOS)
var aspectRatio: Double? {
let ratio = Double(playerViewController.videoBounds.width) / Double(playerViewController.videoBounds.height)
let ratio = Double(playerView.videoBounds.width) / Double(playerView.videoBounds.height)
guard ratio.isFinite else {
return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return
@@ -27,29 +28,45 @@ final class PlayerViewController: UIViewController {
loadPlayer()
#if os(tvOS)
if !playerViewController.isBeingPresented, !playerViewController.isBeingDismissed {
present(playerViewController, animated: false)
if !playerView.isBeingPresented, !playerView.isBeingDismissed {
present(playerView, animated: false)
}
#endif
}
#if os(tvOS)
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if !playerModel.presentingPlayer, !Defaults[.pauseOnHidingPlayer], !playerModel.isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.playerModel.play()
}
}
}
#endif
func loadPlayer() {
guard !playerLoaded else {
return
}
playerModel.controller = self
playerViewController.player = playerModel.player
playerViewController.allowsPictureInPicturePlayback = true
playerViewController.delegate = self
playerView.player = playerModel.player
playerView.allowsPictureInPicturePlayback = true
playerView.delegate = self
#if os(tvOS)
playerModel.avPlayerViewController = playerViewController
playerViewController.customInfoViewControllers = [
infoViewController([.comments], title: "Comments"),
var infoViewControllers = [UIHostingController<AnyView>]()
if CommentsModel.enabled {
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
}
infoViewControllers.append(contentsOf: [
infoViewController([.related], title: "Related"),
infoViewController([.playingNext, .playedPreviously], title: "Playing Next")
]
])
playerView.customInfoViewControllers = infoViewControllers
#else
embedViewController()
#endif
@@ -66,6 +83,7 @@ final class PlayerViewController: UIViewController {
.frame(maxHeight: 600)
.environmentObject(commentsModel)
.environmentObject(playerModel)
.environmentObject(subscriptionsModel)
)
)
@@ -75,12 +93,12 @@ final class PlayerViewController: UIViewController {
}
#else
func embedViewController() {
playerViewController.view.frame = view.bounds
playerView.view.frame = view.bounds
addChild(playerViewController)
view.addSubview(playerViewController.view)
addChild(playerView)
view.addSubview(playerView.view)
playerViewController.didMove(toParent: self)
playerView.didMove(toParent: self)
}
#endif
}
@@ -121,19 +139,19 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
}
func playerViewController(
_ playerViewController: AVPlayerViewController,
_: AVPlayerViewController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if self.navigationModel.presentingChannel {
self.playerModel.playerNavigationLinkActive = true
} else {
self.playerModel.presentPlayer()
self.playerModel.show()
}
#if os(tvOS)
if self.playerModel.playingInPictureInPicture {
self.present(playerViewController, animated: false) {
self.present(self.playerView, animated: false) {
completionHandler(true)
}
}

View File

@@ -1,5 +1,6 @@
import Defaults
import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct VideoDetails: View {
@@ -20,11 +21,15 @@ struct VideoDetails: View {
@Environment(\.presentationMode) private var presentationMode
@Environment(\.inNavigationView) private var inNavigationView
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@Default(.showChannelSubscribers) private var showChannelSubscribers
@Default(.showKeywords) private var showKeywords
init(
@@ -65,7 +70,9 @@ struct VideoDetails: View {
}
.padding(.horizontal)
if CommentsModel.enabled {
if !sidebarQueue ||
(CommentsModel.enabled && CommentsModel.placement == .separate)
{
pagePicker
.padding(.horizontal)
}
@@ -178,21 +185,52 @@ struct VideoDetails: View {
Group {
if video != nil {
HStack(alignment: .center) {
HStack(spacing: 4) {
if subscribed {
Image(systemName: "star.circle.fill")
}
VStack(alignment: .leading) {
Text(video!.channel.name)
.font(.system(size: 13))
.bold()
if let subscribers = video!.channel.subscriptionsString {
Text("\(subscribers) subscribers")
HStack(spacing: 10) {
Group {
ZStack(alignment: .bottomTrailing) {
authorAvatar
if subscribed {
Image(systemName: "star.circle.fill")
.background(Color.background)
.clipShape(Circle())
.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)
}
}
}
}
.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 {
Spacer()
@@ -209,7 +247,7 @@ struct VideoDetails: View {
.alert(isPresented: $presentingUnsubscribeAlert) {
Alert(
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")) {
subscriptions.unsubscribe(video!.channel.id)
@@ -245,13 +283,13 @@ struct VideoDetails: View {
Picker("Page", selection: $currentPage) {
if !video.isNil {
Text("Info").tag(Page.info)
if !sidebarQueue {
Text("Related").tag(Page.related)
}
if CommentsModel.enabled {
if CommentsModel.enabled, CommentsModel.placement == .separate {
Text("Comments")
.tag(Page.comments)
}
if !sidebarQueue {
Text("Related").tag(Page.related)
}
}
if !sidebarQueue {
Text("Queue").tag(Page.queue)
@@ -364,67 +402,99 @@ struct VideoDetails: View {
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 {
Group {
if let video = player.currentItem?.video {
Group {
HStack {
publishedDateSection
Spacer()
Group {
if let video = player.currentItem?.video {
Group {
HStack {
publishedDateSection
Spacer()
}
Divider()
countsSection
}
Divider()
countsSection
}
Divider()
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
Group {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
Text(description)
.textSelection(.enabled)
} else {
Text(description)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.system(size: 14))
.lineSpacing(3)
.padding(.bottom, 4)
} else {
Text("No description")
.foregroundColor(.secondary)
}
if showKeywords {
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
HStack {
ForEach(video.keywords, id: \.self) { keyword in
HStack(alignment: .center, spacing: 0) {
Text("#")
.font(.system(size: 11).bold())
Text(keyword)
.frame(maxWidth: 500)
}
.font(.caption)
.foregroundColor(.white)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color("VideoDetailLikesSymbolColor"))
.mask(RoundedRectangle(cornerRadius: 3))
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
Group {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
Text(description)
.textSelection(.enabled)
} else {
Text(description)
}
}
.padding(.bottom, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.system(size: 14))
.lineSpacing(3)
} else {
Text("No description")
.foregroundColor(.secondary)
}
if showKeywords {
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
HStack {
ForEach(video.keywords, id: \.self) { keyword in
HStack(alignment: .center, spacing: 0) {
Text("#")
.font(.system(size: 11).bold())
Text(keyword)
.frame(maxWidth: 500)
}
.font(.caption)
.foregroundColor(.white)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color("VideoDetailLikesSymbolColor"))
.mask(RoundedRectangle(cornerRadius: 3))
}
}
.padding(.bottom, 10)
}
}
}
}
if !video.isNil, CommentsModel.placement == .info {
Divider()
#if os(macOS)
.padding(.bottom, 20)
#else
.padding(.vertical, 10)
#endif
}
}
.padding(.horizontal)
Group {
if !video.isNil, CommentsModel.placement == .info {
CommentsView()
}
}
}
.padding(.horizontal)
}
func videoDetail(label: String, value: String, symbol: String) -> some View {

View File

@@ -31,7 +31,8 @@ struct VideoPlayerView: View {
HSplitView {
content
}
.frame(idealWidth: 1000, maxWidth: 1100, minHeight: 700)
.onOpenURL(perform: handleOpenedURL)
.frame(minWidth: 950, minHeight: 700)
#else
GeometryReader { geometry in
HStack(spacing: 0) {
@@ -43,8 +44,8 @@ struct VideoPlayerView: View {
.onChange(of: geometry.size) { size in
self.playerSize = size
}
.navigationBarHidden(true)
}
.navigationBarHidden(true)
#endif
}
@@ -66,15 +67,16 @@ struct VideoPlayerView: View {
if player.currentItem.isNil {
playerPlaceholder(geometry: geometry)
} else if player.playingInPictureInPicture {
pictureInPicturePlaceholder(geometry: geometry)
} else {
#if os(macOS)
Player()
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio))
#else
player.playerView
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio))
#endif
player.playerView
.modifier(
VideoPlayerSizeModifier(
geometry: geometry,
aspectRatio: player.controller?.aspectRatio
)
)
}
}
#if os(iOS)
@@ -143,6 +145,35 @@ struct VideoPlayerView: View {
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
}
func pictureInPicturePlaceholder(geometry: GeometryProxy) -> some View {
HStack {
Spacer()
VStack {
Spacer()
VStack(spacing: 10) {
#if !os(tvOS)
Image(systemName: "pip")
.font(.system(size: 120))
#endif
Text("Playing in Picture in Picture")
}
Spacer()
}
.foregroundColor(.gray)
Spacer()
}
.contextMenu {
Button {
player.closePiP()
} label: {
Label("Exit Picture in Picture", systemImage: "pip.exit")
}
}
.contentShape(Rectangle())
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
}
var sidebarQueue: Bool {
switch Defaults[.playerSidebar] {
case .never:
@@ -160,6 +191,27 @@ struct VideoPlayerView: View {
set: { _ in }
)
}
#if !os(tvOS)
func handleOpenedURL(_ url: URL) {
guard !player.accounts.current.isNil else {
return
}
let parser = VideoURLParser(url: url)
guard let id = parser.id else {
return
}
player.accounts.api.video(id).load().onSuccess { response in
if let video: Video = response.typedContent() {
self.player.playNow(video, at: parser.time)
self.player.show()
}
}
}
#endif
}
struct VideoPlayerView_Previews: PreviewProvider {

View File

@@ -7,8 +7,12 @@ struct AddToPlaylistView: View {
@State private var selectedPlaylistID: Playlist.ID = ""
@State private var error = ""
@State private var presentingErrorAlert = false
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<PlaylistsModel> private var model
var body: some View {
@@ -120,6 +124,12 @@ struct AddToPlaylistView: View {
Button("Add to Playlist", action: addToPlaylist)
.disabled(selectedPlaylist.isNil)
.padding(.top, 30)
.alert(isPresented: $presentingErrorAlert) {
Alert(
title: Text("Error when accessing playlist"),
message: Text(error)
)
}
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
@@ -155,9 +165,17 @@ struct AddToPlaylistView: View {
Defaults[.lastUsedPlaylistID] = id
model.addVideo(playlistID: id, videoID: video.videoID) {
presentationMode.wrappedValue.dismiss()
}
model.addVideo(
playlistID: id,
videoID: video.videoID,
onSuccess: {
presentationMode.wrappedValue.dismiss()
},
onFailure: { requestError in
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
presentingErrorAlert = true
}
)
}
private var selectedPlaylist: Playlist? {

View File

@@ -8,7 +8,10 @@ struct PlaylistFormView: View {
@State private var visibility = Playlist.Visibility.public
@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(\.presentationMode) private var presentationMode
@@ -57,6 +60,12 @@ struct PlaylistFormView: View {
Button("Save", action: submitForm)
.disabled(!valid)
.alert(isPresented: $presentingErrorAlert) {
Alert(
title: Text("Error when accessing playlist"),
message: Text(formError)
)
}
.keyboardShortcut(.defaultAction)
}
.frame(minHeight: 35)
@@ -165,15 +174,21 @@ struct PlaylistFormView: View {
let body = ["title": name, "privacy": visibility.rawValue]
resource?.request(editing ? .patch : .post, json: body).onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
playlist = modifiedPlaylist
resource?
.request(editing ? .patch : .post, json: body)
.onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
playlist = modifiedPlaylist
}
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
.onFailure { error in
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
presentingErrorAlert = true
}
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
}
var resource: Resource? {
@@ -207,9 +222,9 @@ struct PlaylistFormView: View {
var deletePlaylistButton: some View {
Button("Delete") {
showingDeleteConfirmation = true
presentingDeleteConfirmation = true
}
.alert(isPresented: $showingDeleteConfirmation) {
.alert(isPresented: $presentingDeleteConfirmation) {
Alert(
title: Text("Are you sure you want to delete playlist?"),
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
@@ -221,11 +236,17 @@ struct PlaylistFormView: View {
}
func deletePlaylistAndDismiss() {
accounts.api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
playlist = nil
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
accounts.api.playlist(playlist.id)?
.request(.delete)
.onSuccess { _ in
playlist = nil
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
.onFailure { error in
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
presentingErrorAlert = true
}
}
}

View File

@@ -144,7 +144,7 @@ struct PlaylistsView: View {
Button {
player.playAll(items.compactMap(\.video))
player.presentPlayer()
player.show()
} label: {
HStack(spacing: 15) {
Image(systemName: "play.fill")

View File

@@ -37,6 +37,7 @@ struct SearchTextField: View {
}
}
#if os(macOS)
.frame(maxWidth: 190)
.textFieldStyle(.plain)
#else
.textFieldStyle(.roundedBorder)
@@ -52,6 +53,11 @@ struct SearchTextField: View {
.padding(.trailing)
#endif
clearButton
} else {
#if os(macOS)
clearButton
.opacity(0)
#endif
}
}
}

View File

@@ -14,8 +14,9 @@ struct SearchSuggestions: View {
recents.addQuery(state.queryText)
} label: {
HStack(spacing: 5) {
Label(state.queryText, systemImage: "magnifyingglass")
HStack {
Image(systemName: "magnifyingglass")
Text(state.queryText)
.lineLimit(1)
}
}
@@ -27,15 +28,19 @@ struct SearchSuggestions: View {
Button {
state.queryText = suggestion
} label: {
HStack(spacing: 0) {
Label(state.queryText, systemImage: "arrow.up.left.circle")
.lineLimit(1)
.layoutPriority(2)
HStack {
Image(systemName: "arrow.up.left.circle")
.foregroundColor(.secondary)
HStack(spacing: 0) {
Text(state.suggestionsText)
.lineLimit(1)
.layoutPriority(2)
.foregroundColor(.secondary)
Text(querySuffix(suggestion))
.lineLimit(1)
.layoutPriority(1)
Text(querySuffix(suggestion))
.lineLimit(1)
.layoutPriority(1)
}
}
}
#if os(macOS)
@@ -55,7 +60,7 @@ struct SearchSuggestions: View {
}
private func querySuffix(_ suggestion: String) -> String {
suggestion.replacingFirstOccurrence(of: state.queryText.lowercased(), with: "")
suggestion.replacingFirstOccurrence(of: state.suggestionsText.lowercased(), with: "")
}
#if os(macOS)

View File

@@ -7,7 +7,14 @@ struct PlaybackSettings: View {
@Default(.quality) private var quality
@Default(.playerSidebar) private var playerSidebar
@Default(.showKeywords) private var showKeywords
@Default(.showChannelSubscribers) private var channelSubscribers
@Default(.saveHistory) private var saveHistory
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
@Default(.closePiPOnNavigation) private var closePiPOnNavigation
@Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer
#if !os(macOS)
@Default(.closePiPAndOpenPlayerOnEnteringForeground) private var closePiPAndOpenPlayerOnEnteringForeground
#endif
#if os(iOS)
private var idiom: UIUserInterfaceIdiom {
@@ -27,6 +34,14 @@ struct PlaybackSettings: View {
}
keywordsToggle
channelSubscribersToggle
pauseOnHidingPlayerToggle
}
Section(header: SettingsHeader(text: "Picture in Picture")) {
closePiPOnNavigationToggle
closePiPOnOpeningPlayerToggle
closePiPAndOpenPlayerOnEnteringForegroundToggle
}
#else
Section(header: SettingsHeader(text: "Source")) {
@@ -44,6 +59,16 @@ struct PlaybackSettings: View {
#endif
keywordsToggle
channelSubscribersToggle
pauseOnHidingPlayerToggle
Section(header: SettingsHeader(text: "Picture in Picture")) {
closePiPOnNavigationToggle
closePiPOnOpeningPlayerToggle
#if !os(macOS)
closePiPAndOpenPlayerOnEnteringForegroundToggle
#endif
}
#endif
}
@@ -107,6 +132,28 @@ struct PlaybackSettings: View {
private var keywordsToggle: some View {
Toggle("Show video keywords", isOn: $showKeywords)
}
private var channelSubscribersToggle: some View {
Toggle("Show channel subscribers count", isOn: $channelSubscribers)
}
private var pauseOnHidingPlayerToggle: some View {
Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer)
}
private var closePiPOnNavigationToggle: some View {
Toggle("Close PiP when starting playing other video", isOn: $closePiPOnNavigation)
}
private var closePiPOnOpeningPlayerToggle: some View {
Toggle("Close PiP when player is opened", isOn: $closePiPOnOpeningPlayer)
}
#if !os(macOS)
private var closePiPAndOpenPlayerOnEnteringForegroundToggle: some View {
Toggle("Close PiP and open player when application enters foreground", isOn: $closePiPAndOpenPlayerOnEnteringForeground)
}
#endif
}
struct PlaybackSettings_Previews: PreviewProvider {

View File

@@ -6,9 +6,17 @@ struct ServicesSettings: View {
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
@Default(.commentsInstanceID) private var commentsInstanceID
#if !os(tvOS)
@Default(.commentsPlacement) private var commentsPlacement
#endif
var body: some View {
Section(header: SettingsHeader(text: "Comments")) {
commentsInstancePicker
#if !os(tvOS)
commentsPlacementPicker
.disabled(!CommentsModel.enabled)
#endif
}
Section(header: SettingsHeader(text: "SponsorBlock API")) {
@@ -58,7 +66,7 @@ struct ServicesSettings: View {
}
private var commentsInstancePicker: some View {
Picker("Comments", selection: $commentsInstanceID) {
Picker("Source", selection: $commentsInstanceID) {
Text("Disabled").tag(Optional(""))
ForEach(InstancesModel.all.filter { $0.app.supportsComments }) { instance in
@@ -73,6 +81,19 @@ struct ServicesSettings: View {
#endif
}
#if !os(tvOS)
private var commentsPlacementPicker: some View {
Picker("Placement", selection: $commentsPlacement) {
Text("Below video description").tag(CommentsPlacement.info)
Text("Separate tab").tag(CommentsPlacement.separate)
}
.labelsHidden()
#if os(iOS)
.pickerStyle(.automatic)
#endif
}
#endif
func toggleCategory(_ category: String, value: Bool) {
if let index = sponsorBlockCategories.firstIndex(where: { $0 == category }), !value {
sponsorBlockCategories.remove(at: index)

View File

@@ -5,7 +5,7 @@ import SwiftUI
struct SettingsView: View {
#if os(macOS)
private enum Tabs: Hashable {
case instances, browsing, playback, services
case instances, browsing, playback, services, updates
}
#endif
@@ -57,6 +57,14 @@ struct SettingsView: View {
Label("Services", systemImage: "puzzlepiece")
}
.tag(Tabs.services)
Form {
UpdatesSettings()
}
.tabItem {
Label("Updates", systemImage: "gearshape.2")
}
.tag(Tabs.updates)
}
.padding(20)
.frame(width: 400, height: 380)

View File

@@ -31,7 +31,7 @@ struct VideoCell: View {
if inNavigationView {
player.playerNavigationLinkActive = true
} else {
player.presentPlayer()
player.show()
}
}) {
content

View File

@@ -8,18 +8,18 @@ struct ChannelCell: View {
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
var body: some View {
Button {
let recent = RecentItem(from: channel)
recents.add(recent)
navigation.presentingChannel = true
if navigationStyle == .sidebar {
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
NavigationModel.openChannel(
channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
} label: {
content
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)

View File

@@ -16,6 +16,7 @@ struct ChannelPlaylistView: View {
#endif
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerModel> private var player
var items: [ContentItem] {
ContentItem.array(of: store.item?.videos ?? [])
@@ -83,9 +84,11 @@ struct ChannelPlaylistView: View {
}
}
.navigationTitle(playlist.title)
#if os(iOS)
.navigationBarHidden(player.playerNavigationLinkActive)
#endif
#else
.background(Color.background(scheme: colorScheme))
.background(Color.background(scheme: colorScheme))
#endif
}

View File

@@ -19,6 +19,7 @@ struct ChannelVideosView: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@Namespace private var focusNamespace
@@ -120,6 +121,9 @@ struct ChannelVideosView: View {
resource.load()
}
}
#if os(iOS)
.navigationBarHidden(player.playerNavigationLinkActive)
#endif
.navigationTitle(navigationTitle)
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 {

View File

@@ -27,7 +27,7 @@ struct PlayerControlsView<Content: View>: View {
private var controls: some View {
let controls = HStack {
Button(action: {
model.presentingPlayer.toggle()
model.togglePlayer()
}) {
HStack {
VStack(alignment: .leading, spacing: 3) {
@@ -42,6 +42,15 @@ struct PlayerControlsView<Content: View>: View {
.foregroundColor(.secondary)
.lineLimit(1)
}
.contextMenu {
Button {
model.closeCurrentItem()
} label: {
Label("Close Video", systemImage: "xmark.circle")
.labelStyle(.automatic)
}
.disabled(model.currentItem.isNil)
}
Spacer()
}
@@ -96,7 +105,7 @@ struct PlayerControlsView<Content: View>: View {
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
#if !os(tvOS)
.onSwipeGesture(up: {
model.presentingPlayer = true
model.show()
})
#endif

View File

@@ -64,7 +64,7 @@ struct VideoContextMenuView: View {
if inNavigationView {
playerNavigationLinkActive = true
} else {
player.presentPlayer()
player.show()
}
} label: {
Label("Play Now", systemImage: "play")
@@ -97,14 +97,13 @@ struct VideoContextMenuView: View {
private var openChannelButton: some View {
Button {
let recent = RecentItem(from: video.channel)
recents.add(recent)
navigation.presentingChannel = true
if navigationStyle == .sidebar {
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
NavigationModel.openChannel(
video.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
} label: {
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}

View File

@@ -4,6 +4,11 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
</dict>

View File

@@ -5,29 +5,88 @@ import SwiftUI
struct YatteeApp: App {
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var updater = UpdaterModel()
#endif
@StateObject private var accounts = AccountsModel()
@StateObject private var comments = CommentsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var menu = MenuModel()
@StateObject private var navigation = NavigationModel()
@StateObject private var player = PlayerModel()
@StateObject private var playlists = PlaylistsModel()
@StateObject private var recents = RecentsModel()
@StateObject private var search = SearchModel()
@StateObject private var subscriptions = SubscriptionsModel()
@StateObject private var thumbnails = ThumbnailsModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnails)
.environmentObject(menu)
.environmentObject(search)
#if !os(macOS)
.onReceive(
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
) { _ in
player.handleEnterForeground()
}
#endif
#if !os(tvOS)
.handlesExternalEvents(preferring: Set(["watch"]), allowing: Set(["watch"]))
#endif
}
#if !os(tvOS)
.handlesExternalEvents(matching: Set(["*"]))
.handlesExternalEvents(matching: Set(arrayLiteral: "watch"))
.commands {
SidebarCommands()
CommandGroup(replacing: .newItem, addition: {})
#if os(macOS)
CommandGroup(after: .appInfo) {
CheckForUpdatesView()
.environmentObject(updater)
}
#endif
MenuCommands(model: Binding<MenuModel>(get: { menu }, set: { _ in }))
}
#endif
#if os(macOS)
WindowGroup(player.windowTitle) {
VideoPlayerView()
.onAppear { player.presentingPlayer = true }
.onDisappear { player.presentingPlayer = false }
.environment(\.navigationStyle, .sidebar)
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnails)
.handlesExternalEvents(preferring: Set(["player"]), allowing: Set(["player"]))
}
.handlesExternalEvents(matching: Set(["player"]))
Settings {
SettingsView()
.environmentObject(AccountsModel())
.environmentObject(InstancesModel())
.environmentObject(updater)
}
#endif
}

View File

@@ -223,6 +223,7 @@
37732FF22703A26300F04329 /* AccountValidationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FEF2703A26300F04329 /* AccountValidationStatus.swift */; };
37732FF42703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; };
37732FF52703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; };
37737786276F9858000521C1 /* OpenWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37737785276F9858000521C1 /* OpenWindow.swift */; };
3774122A27387B6C00423605 /* InstancesModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3774122927387B6C00423605 /* InstancesModelTests.swift */; };
3774122F27387C7600423605 /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; };
3774123327387CB000423605 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
@@ -407,6 +408,7 @@
37BE0BD726A1D4A90092E2DB /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE0BD526A1D4A90092E2DB /* PlayerViewController.swift */; };
37BE0BDA26A214630092E2DB /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE0BD926A214630092E2DB /* PlayerViewController.swift */; };
37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE0BDB26A2367F0092E2DB /* Player.swift */; };
37BE7AF12760013C00DBECED /* UpdatesSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE7AF02760013C00DBECED /* UpdatesSettings.swift */; };
37BF661C27308859008CCFB0 /* DropFavorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BF661B27308859008CCFB0 /* DropFavorite.swift */; };
37BF661D27308859008CCFB0 /* DropFavorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BF661B27308859008CCFB0 /* DropFavorite.swift */; };
37BF661F27308884008CCFB0 /* DropFavoriteOutside.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BF661E27308884008CCFB0 /* DropFavoriteOutside.swift */; };
@@ -442,6 +444,9 @@
37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; };
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; };
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountry.swift */; };
37C90AEF275F9CC00015EAF7 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 37C90AEE275F9CC00015EAF7 /* Sparkle */; };
37C90AF1275F9D450015EAF7 /* UpdaterModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C90AF0275F9D450015EAF7 /* UpdaterModel.swift */; };
37C90AF3275F9D5D0015EAF7 /* CheckForUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C90AF2275F9D5D0015EAF7 /* CheckForUpdatesView.swift */; };
37CB12792724C76D00213B45 /* VideoURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB12782724C76D00213B45 /* VideoURLParser.swift */; };
37CB127A2724C76D00213B45 /* VideoURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB12782724C76D00213B45 /* VideoURLParser.swift */; };
37CB128B2724CC1F00213B45 /* VideoURLParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB127B2724C79D00213B45 /* VideoURLParserTests.swift */; };
@@ -638,6 +643,7 @@
376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance+Fixtures.swift"; sourceTree = "<group>"; };
37732FEF2703A26300F04329 /* AccountValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountValidationStatus.swift; sourceTree = "<group>"; };
37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
37737785276F9858000521C1 /* OpenWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenWindow.swift; sourceTree = "<group>"; };
3774122927387B6C00423605 /* InstancesModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesModelTests.swift; sourceTree = "<group>"; };
377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = "<group>"; };
3782B94E27553A6700990149 /* SearchSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestions.swift; sourceTree = "<group>"; };
@@ -691,6 +697,7 @@
37BE0BD526A1D4A90092E2DB /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = "<group>"; };
37BE0BD926A214630092E2DB /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = "<group>"; };
37BE0BDB26A2367F0092E2DB /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; };
37BE7AF02760013C00DBECED /* UpdatesSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatesSettings.swift; sourceTree = "<group>"; };
37BF661B27308859008CCFB0 /* DropFavorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropFavorite.swift; sourceTree = "<group>"; };
37BF661E27308884008CCFB0 /* DropFavoriteOutside.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropFavoriteOutside.swift; sourceTree = "<group>"; };
37C069772725962F00F7F6CB /* ScreenSaverManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverManager.swift; sourceTree = "<group>"; };
@@ -704,6 +711,8 @@
37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelPlaylist+Fixtures.swift"; sourceTree = "<group>"; };
37C3A250272366440087A57A /* ChannelPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPlaylistView.swift; sourceTree = "<group>"; };
37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = "<group>"; };
37C90AF0275F9D450015EAF7 /* UpdaterModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterModel.swift; sourceTree = "<group>"; };
37C90AF2275F9D5D0015EAF7 /* CheckForUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckForUpdatesView.swift; sourceTree = "<group>"; };
37CB12782724C76D00213B45 /* VideoURLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoURLParser.swift; sourceTree = "<group>"; };
37CB127B2724C79D00213B45 /* VideoURLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoURLParserTests.swift; sourceTree = "<group>"; };
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueItem.swift; sourceTree = "<group>"; };
@@ -793,6 +802,7 @@
37FB284F272209AB00A57617 /* SDWebImageSwiftUI in Frameworks */,
3765917A27237D07009F956E /* PINCache in Frameworks */,
37BD07BE2698AC96003EBB87 /* Defaults in Frameworks */,
37C90AEF275F9CC00015EAF7 /* Sparkle in Frameworks */,
37BADCA7269A552E009BE4FB /* Alamofire in Frameworks */,
377FC7ED267A0A0800A6BBAF /* SwiftyJSON in Frameworks */,
37BD07C02698AC97003EBB87 /* Siesta in Frameworks */,
@@ -1101,8 +1111,10 @@
37BE0BD826A214500092E2DB /* macOS */ = {
isa = PBXGroup;
children = (
37BE7AF227601DBF00DBECED /* Updates */,
374C0542272496E4009BDDBE /* AppDelegate.swift */,
37FD43DB270470B70073EE42 /* InstancesSettings.swift */,
37737785276F9858000521C1 /* OpenWindow.swift */,
374108D0272B11B2006C5CC8 /* PictureInPictureDelegate.swift */,
37BE0BDB26A2367F0092E2DB /* Player.swift */,
37BE0BD926A214630092E2DB /* PlayerViewController.swift */,
@@ -1112,6 +1124,16 @@
path = macOS;
sourceTree = "<group>";
};
37BE7AF227601DBF00DBECED /* Updates */ = {
isa = PBXGroup;
children = (
37C90AF2275F9D5D0015EAF7 /* CheckForUpdatesView.swift */,
37C90AF0275F9D450015EAF7 /* UpdaterModel.swift */,
37BE7AF02760013C00DBECED /* UpdatesSettings.swift */,
);
path = Updates;
sourceTree = "<group>";
};
37C7A9022679058300E721B4 /* Extensions */ = {
isa = PBXGroup;
children = (
@@ -1376,6 +1398,7 @@
37FB2850272209AB00A57617 /* SDWebImageWebPCoder */,
37FB285727220D9600A57617 /* SDWebImagePINPlugin */,
3765917927237D07009F956E /* PINCache */,
37C90AEE275F9CC00015EAF7 /* Sparkle */,
);
productName = "Yattee (macOS)";
productReference = 37D4B0CF2671614900C925CA /* Yattee.app */;
@@ -1539,6 +1562,7 @@
37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */,
37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */,
3765917827237D07009F956E /* XCRemoteSwiftPackageReference "PINCache" */,
37C90AED275F9CC00015EAF7 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
productRefGroup = 37D4B0CA2671614900C925CA /* Products */;
projectDirPath = "";
@@ -1903,6 +1927,7 @@
374710062755291C00CE0F87 /* SearchField.swift in Sources */,
378AE93F274EDFB5006A4EE1 /* Tint+Backport.swift in Sources */,
37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */,
37737786276F9858000521C1 /* OpenWindow.swift in Sources */,
37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */,
3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */,
37599F39272B4D740087F250 /* FavoriteButton.swift in Sources */,
@@ -1942,6 +1967,7 @@
3788AC2826F6840700F6BAA9 /* FavoriteItemView.swift in Sources */,
378AE93A274EDFAF006A4EE1 /* Badge+Backport.swift in Sources */,
37599F35272B44000087F250 /* FavoritesModel.swift in Sources */,
37C90AF3275F9D5D0015EAF7 /* CheckForUpdatesView.swift in Sources */,
37F64FE526FE70A60081B69E /* RedrawOnModifier.swift in Sources */,
37FFC441272734C3009FFD26 /* Throttle.swift in Sources */,
37169AA72729E2CC0011DE61 /* AccountsBridge.swift in Sources */,
@@ -1954,6 +1980,7 @@
377FC7E2267A084A00A6BBAF /* VideoCell.swift in Sources */,
37CC3F51270D010D00608308 /* VideoBanner.swift in Sources */,
378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */,
37BE7AF12760013C00DBECED /* UpdatesSettings.swift in Sources */,
37169AA32729D98A0011DE61 /* InstancesBridge.swift in Sources */,
37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */,
3765788A2685471400D4EA09 /* Playlist.swift in Sources */,
@@ -1971,6 +1998,7 @@
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */,
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
37C3A242272359900087A57A /* Double+Format.swift in Sources */,
37C90AF1275F9D450015EAF7 /* UpdaterModel.swift in Sources */,
37DD87C8271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
376578922685490700D4EA09 /* PlaylistsView.swift in Sources */,
37484C2626FC83E000287258 /* InstanceForm.swift in Sources */,
@@ -2277,7 +2305,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -2290,7 +2318,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -2311,7 +2339,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -2324,7 +2352,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -2343,7 +2371,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -2355,7 +2383,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -2375,7 +2403,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -2387,7 +2415,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -2538,14 +2566,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -2553,7 +2581,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = iphoneos;
@@ -2569,14 +2597,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -2584,7 +2612,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = iphoneos;
@@ -2604,7 +2632,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -2619,7 +2647,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = macosx;
@@ -2637,7 +2665,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -2652,7 +2680,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = macosx;
@@ -2768,7 +2796,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
@@ -2783,7 +2811,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = appletvos;
@@ -2800,7 +2828,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
@@ -2815,7 +2843,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = appletvos;
@@ -3076,6 +3104,14 @@
minimumVersion = 0.1.3;
};
};
37C90AED275F9CC00015EAF7 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
branch = 2.x;
kind = branch;
};
};
37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON.git";
@@ -3226,6 +3262,11 @@
package = 37BD07C52698B27B003EBB87 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = Introspect;
};
37C90AEE275F9CC00015EAF7 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 37C90AED275F9CC00015EAF7 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
37D4B19C2671817900C925CA /* SwiftyJSON */ = {
isa = XCSwiftPackageProductDependency;
package = 37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */;

View File

@@ -91,6 +91,15 @@
"version": "1.5.2"
}
},
{
"package": "Sparkle",
"repositoryURL": "https://github.com/sparkle-project/Sparkle",
"state": {
"branch": "2.x",
"revision": "831e9b4eb7e871a9c072469fb14049614fc92810",
"version": null
}
},
{
"package": "swift-log",
"repositoryURL": "https://github.com/apple/swift-log.git",

View File

@@ -15,6 +15,11 @@
</array>
</dict>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>

View File

@@ -15,5 +15,11 @@
</array>
</dict>
</array>
<key>SUEnableInstallerLauncherService</key>
<true/>
<key>SUFeedURL</key>
<string>https://repos.yattee.stream/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>73U5at3utQRS7F/z/7nztpjp3l1gw1Ih+ztOelRLSx4=</string>
</dict>
</plist>

View File

@@ -94,7 +94,7 @@ struct InstancesSettings: View {
}
.labelsHidden()
Text("Used to create links from videos, channels and playlist")
Text("Used to create links from videos, channels and playlists")
.font(.caption)
.foregroundColor(.secondary)
}

40
macOS/OpenWindow.swift Normal file
View File

@@ -0,0 +1,40 @@
import AppKit
import Foundation
enum OpenWindow: String, CaseIterable {
case player, main
var window: NSWindow? {
// this is not solid but works as long as there is only two windows in the app
// needs to be changed in case we ever have more windows to handle
switch self {
case .player:
return NSApplication.shared.windows.last
case .main:
return NSApplication.shared.windows.first
}
}
func focus() {
window?.makeKeyAndOrderFront(self)
}
var location: String {
switch self {
case .player:
return rawValue
case .main:
return ""
}
}
func open() {
switch self {
case .player:
NSWorkspace.shared.open(URL(string: "yattee://player")!)
case .main:
Self.main.focus()
}
}
}

View File

@@ -11,14 +11,14 @@ final class PictureInPictureDelegate: NSObject, AVPlayerViewPictureInPictureDele
func playerViewWillStartPicture(inPicture _: AVPlayerView) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.playerModel.playingInPictureInPicture = true
self?.playerModel.presentingPlayer = false
self?.playerModel.hide()
}
}
func playerViewWillStopPicture(inPicture _: AVPlayerView) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.playerModel.playingInPictureInPicture = false
self?.playerModel.presentPlayer()
self?.playerModel.show()
}
}
@@ -27,7 +27,7 @@ final class PictureInPictureDelegate: NSObject, AVPlayerViewPictureInPictureDele
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: (Bool) -> Void
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.playerModel.presentingPlayer = true
self?.playerModel.show()
}
completionHandler(true)
}

View File

@@ -4,7 +4,7 @@ import SwiftUI
struct Player: NSViewControllerRepresentable {
@EnvironmentObject<PlayerModel> private var player
var controller: PlayerViewController?
@State private var controller: PlayerViewController?
init(controller: PlayerViewController? = nil) {
self.controller = controller

View File

@@ -0,0 +1,10 @@
import SwiftUI
struct CheckForUpdatesView: View {
@EnvironmentObject<UpdaterModel> private var updater
var body: some View {
Button("Check For Updates…", action: updater.checkForUpdates)
.disabled(!updater.canCheckForUpdates)
}
}

View File

@@ -0,0 +1,41 @@
import Defaults
import Sparkle
import SwiftUI
final class UpdaterModel: ObservableObject {
@Published var canCheckForUpdates = false
private let updaterController: SPUStandardUpdaterController
private let updaterDelegate = UpdaterDelegate()
init() {
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: updaterDelegate,
userDriverDelegate: nil
)
updaterController.updater.publisher(for: \.canCheckForUpdates)
.assign(to: &$canCheckForUpdates)
}
func checkForUpdates() {
updaterController.checkForUpdates(nil)
}
var automaticallyChecksForUpdates: Bool {
updaterController.updater.automaticallyChecksForUpdates
}
func setAutomaticallyChecksForUpdates(_ value: Bool) {
updaterController.updater.automaticallyChecksForUpdates = value
}
}
final class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
@Default(.enableBetaChannel) private var enableBetaChannel
func allowedChannels(for _: SPUUpdater) -> Set<String> {
Set(enableBetaChannel ? ["beta"] : [])
}
}

View File

@@ -0,0 +1,32 @@
import Defaults
import SwiftUI
struct UpdatesSettings: View {
@EnvironmentObject<UpdaterModel> private var updater
@State private var automaticallyChecksForUpdates = false
@Default(.enableBetaChannel) private var enableBetaChannel
var body: some View {
Section(header: SettingsHeader(text: "Updates")) {
Toggle("Check automatically", isOn: $automaticallyChecksForUpdates)
Toggle("Enable beta channel", isOn: $enableBetaChannel)
}
.onAppear {
automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates
}
.onChange(of: automaticallyChecksForUpdates) { _ in
updater.setAutomaticallyChecksForUpdates(automaticallyChecksForUpdates)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
}
}
struct UpdatesSettings_Previews: PreviewProvider {
static var previews: some View {
UpdatesSettings()
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -33,7 +33,7 @@ struct NowPlayingView: View {
if sections.contains(.nowPlaying), let item = player.currentItem {
Section(header: Text("Now Playing")) {
Button {
player.presentPlayer()
player.show()
} label: {
VideoBanner(video: item.video)
}
@@ -59,7 +59,7 @@ struct NowPlayingView: View {
ForEach(player.queue) { item in
Button {
player.advanceToItem(item)
player.presentPlayer()
player.show()
} label: {
VideoBanner(video: item.video)
}
@@ -77,7 +77,7 @@ struct NowPlayingView: View {
ForEach(player.currentVideo!.related) { video in
Button {
player.playNow(video)
player.presentPlayer()
player.show()
} label: {
VideoBanner(video: video)
}
@@ -99,7 +99,7 @@ struct NowPlayingView: View {
ForEach(player.history) { item in
Button {
player.playHistory(item)
player.presentPlayer()
player.show()
} label: {
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
}
@@ -117,9 +117,18 @@ struct NowPlayingView: View {
}
if sections.contains(.comments) {
Section {
ForEach(comments.all) { comment in
CommentView(comment: comment, repliesID: $repliesID)
if !comments.loaded {
VStack(alignment: .center) {
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())
.foregroundColor(.secondary)
}
private var progressView: some View {
VStack {
Spacer()
HStack {
Spacer()
ProgressView()
Spacer()
}
Spacer()
}
}
}
struct NowPlayingView_Previews: PreviewProvider {

View File

@@ -17,7 +17,7 @@ struct TVNavigationView: View {
.tag(TabSelection.favorites)
}
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions {
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions, accounts.api.signedIn {
SubscriptionsView()
.tabItem { Text("Subscriptions") }
.tag(TabSelection.subscriptions)