mirror of
https://github.com/yattee/yattee.git
synced 2025-12-13 03:28:14 +00:00
Compare commits
95 Commits
v1.4-alpha
...
v1.4-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d344ab15dc | ||
|
|
888813f095 | ||
|
|
a0a6020ee6 | ||
|
|
e3e68cd158 | ||
|
|
42e56d6e4b | ||
|
|
f979af7f01 | ||
|
|
124a48812a | ||
|
|
44e6c28fd4 | ||
|
|
4f6e0e2a3d | ||
|
|
ccc1cc89ad | ||
|
|
5d0eb2478c | ||
|
|
c28429ec7f | ||
|
|
6c3a7882e1 | ||
|
|
76286e8a45 | ||
|
|
c5128f6227 | ||
|
|
4bf171637b | ||
|
|
249adab427 | ||
|
|
a27ebcce27 | ||
|
|
b5f3a1bd09 | ||
|
|
b306819af9 | ||
|
|
aa29688b4c | ||
|
|
371e6a275b | ||
|
|
0b7e9f8c47 | ||
|
|
680fe915e1 | ||
|
|
1e7ebe4e68 | ||
|
|
1a230d36f7 | ||
|
|
60e1cef84f | ||
|
|
1150a01496 | ||
|
|
e5c9e11b17 | ||
|
|
fb3d64bcc7 | ||
|
|
dec670b7b7 | ||
|
|
ea4dc2358b | ||
|
|
fca7b7a1a7 | ||
|
|
ea0db9533a | ||
|
|
db85be76f7 | ||
|
|
5cb2e1b8f0 | ||
|
|
387f29e395 | ||
|
|
9ae8c85f4e | ||
|
|
c94a35d2c6 | ||
|
|
aacbd7889c | ||
|
|
d8020d06dd | ||
|
|
c556f0e21d | ||
|
|
ff1f62b6ad | ||
|
|
7cb4e0dccf | ||
|
|
3229e3151f | ||
|
|
53103c9ecf | ||
|
|
ac53deb39b | ||
|
|
cab6f486ba | ||
|
|
40e16fcc7e | ||
|
|
9c687f8704 | ||
|
|
340ceff131 | ||
|
|
76a116659e | ||
|
|
7c009080a5 | ||
|
|
61d7c53a58 | ||
|
|
08e65aff0f | ||
|
|
722616e3d0 | ||
|
|
0826f01150 | ||
|
|
fcb9ec1f6a | ||
|
|
5bb1589159 | ||
|
|
60e5cc5d75 | ||
|
|
bc410ad6ba | ||
|
|
35c358de6b | ||
|
|
23955699dc | ||
|
|
742103e4c2 | ||
|
|
6173f4610b | ||
|
|
1217acf264 | ||
|
|
0b8d887cef | ||
|
|
b760f172d0 | ||
|
|
bb8fc78760 | ||
|
|
d2317f725f | ||
|
|
693c3364b0 | ||
|
|
18bfc1abc9 | ||
|
|
717830ee99 | ||
|
|
2b001ec96c | ||
|
|
ebeb5bc520 | ||
|
|
7a7e265ba1 | ||
|
|
c086112a49 | ||
|
|
0802fe0029 | ||
|
|
40813c2859 | ||
|
|
b1238869a6 | ||
|
|
453a5fa71b | ||
|
|
86be252bd5 | ||
|
|
20f96fb9d6 | ||
|
|
e539fb0067 | ||
|
|
03d5eefab0 | ||
|
|
0bc4a677d4 | ||
|
|
b374f82da4 | ||
|
|
b70697e1be | ||
|
|
db5765a84b | ||
|
|
8d36f57271 | ||
|
|
836057578f | ||
|
|
e39f4373bb | ||
|
|
1490437537 | ||
|
|
4f1b52826d | ||
|
|
15e62468bb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -91,3 +91,6 @@ iOSInjectionProject/
|
||||
|
||||
# SwiftLint Remote Config Cache
|
||||
.swiftlint/RemoteConfigCache
|
||||
|
||||
# disable simulator libraries - to be removed when replaced with framework for mpv
|
||||
Vendor/mpv/iOS/lib_sim
|
||||
|
||||
7
Extensions/Comparable+Clamped.swift
Normal file
7
Extensions/Comparable+Clamped.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Comparable {
|
||||
func clamped(to limits: ClosedRange<Self>) -> Self {
|
||||
min(max(self, limits.lowerBound), limits.upperBound)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@ import AppKit
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {} // swiftlint:disable:this unused_setter_value
|
||||
set {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
static var fixtureID: Video.ID {
|
||||
"FIXTURE"
|
||||
}
|
||||
static var fixtureID: Video.ID = "video-fixture"
|
||||
static var fixtureChannelID: Channel.ID = "channel-fixture"
|
||||
|
||||
static var fixture: Video {
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
@@ -18,7 +17,7 @@ extension Video {
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
id: "AbCdEFgHI",
|
||||
id: fixtureChannelID,
|
||||
name: "The Channel",
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
subscriptionsCount: 2300,
|
||||
|
||||
0
ISSUE_TEMPLATE.md
Normal file
0
ISSUE_TEMPLATE.md
Normal file
@@ -53,7 +53,7 @@ final class InstancesModel: ObservableObject {
|
||||
}
|
||||
|
||||
static func remove(_ instance: Instance) {
|
||||
let accounts = InstancesModel.accounts(instance.id)
|
||||
let accounts = Self.accounts(instance.id)
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
Defaults[.instances].remove(at: index)
|
||||
accounts.forEach { AccountsModel.remove($0) }
|
||||
|
||||
@@ -92,15 +92,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||
let results = content.json.arrayValue.compactMap { json -> ContentItem in
|
||||
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
|
||||
let type = json.dictionaryValue["type"]?.stringValue
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: self.extractChannel(from: json))
|
||||
} else if type == "playlist" {
|
||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||
} else if type == "video" {
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return SearchPage(results: results, last: results.isEmpty)
|
||||
@@ -157,11 +160,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(InvidiousAPI.basePath)/\(path)"
|
||||
"**\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private func basePathAppending(_ path: String) -> String {
|
||||
"\(InvidiousAPI.basePath)/\(path)"
|
||||
"\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private var cookieHeader: String {
|
||||
@@ -169,11 +172,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/popular")
|
||||
}
|
||||
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
|
||||
.withParam("type", category?.name)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
@@ -183,7 +186,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
@@ -236,6 +239,66 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
playlist(playlistID)?.child("videos").child(videoID)
|
||||
}
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = playlistVideo(playlistID, index)
|
||||
|
||||
resource?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["title": name, "privacy": visibility]
|
||||
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
|
||||
|
||||
resource?
|
||||
.request(!playlist.isNil ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
self.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
@@ -403,7 +466,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
return []
|
||||
}
|
||||
|
||||
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/mp4") && $0["encoding"].stringValue == "h264" }
|
||||
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
||||
|
||||
return videoAssetsURLs.map {
|
||||
Stream(
|
||||
@@ -411,7 +474,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
videoAsset: AVURLAsset(url: $0["url"].url!),
|
||||
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
|
||||
kind: .adaptive,
|
||||
encoding: $0["encoding"].stringValue
|
||||
encoding: $0["encoding"].stringValue,
|
||||
videoFormat: $0["type"].stringValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
@@ -43,6 +43,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
@@ -81,6 +86,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.map { self.extractUserPlaylist(from: $0)! }
|
||||
}
|
||||
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
}
|
||||
@@ -152,7 +161,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
!account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
guard let account = account else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
@@ -166,7 +179,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
var home: Resource? { nil }
|
||||
var popular: Resource? { nil }
|
||||
var playlists: Resource? { nil }
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "user/playlists")
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
@@ -180,10 +195,79 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_: String) -> Resource? { nil }
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
channelPlaylist(id)
|
||||
}
|
||||
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
|
||||
let body = ["videoId": videoID, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
|
||||
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["name": name]
|
||||
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
} else {
|
||||
onSuccess(nil)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
|
||||
let body = ["playlistId": playlist.id]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
@@ -280,7 +364,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
return ChannelPlaylist(
|
||||
id: id,
|
||||
title: details["name"]!.stringValue,
|
||||
title: details["name"]?.stringValue ?? "",
|
||||
thumbnailURL: thumbnailURL,
|
||||
channel: extractChannel(from: json)!,
|
||||
videos: videos,
|
||||
@@ -310,6 +394,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
|
||||
|
||||
let uploaded = details["uploaded"]?.doubleValue
|
||||
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
@@ -327,7 +412,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
published: published!,
|
||||
views: details["views"]!.intValue,
|
||||
description: extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
likes: details["likes"]?.int,
|
||||
@@ -359,6 +444,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)!
|
||||
}
|
||||
|
||||
private func extractUserPlaylist(from json: JSON) -> Playlist? {
|
||||
let id = json["id"].stringValue
|
||||
let title = json["name"].stringValue
|
||||
let visibility = Playlist.Visibility.private
|
||||
|
||||
return Playlist(id: id, title: title, visibility: visibility)
|
||||
}
|
||||
|
||||
private func extractDescription(from content: JSON) -> String? {
|
||||
guard var description = content.dictionaryValue["description"]?.string else {
|
||||
return nil
|
||||
@@ -392,11 +485,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
streams.append(Stream(hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
guard let audioStream = compatibleAudioStreams(from: content).first else {
|
||||
let audioStreams = content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
||||
} ?? []
|
||||
|
||||
guard let audioStream = audioStreams.first else {
|
||||
return streams
|
||||
}
|
||||
|
||||
let videoStreams = compatibleVideoStream(from: content)
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
videoStreams.forEach { videoStream in
|
||||
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||
@@ -404,10 +505,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
|
||||
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.stringValue
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive)
|
||||
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive, videoFormat: videoFormat)
|
||||
)
|
||||
} else {
|
||||
streams.append(
|
||||
@@ -426,23 +528,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private func compatibleVideoStream(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["videoStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||
}
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.stringValue ?? ""
|
||||
|
||||
@@ -27,6 +27,34 @@ protocol VideosAPI {
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
)
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
|
||||
@@ -32,6 +32,18 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsAreEditable: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
|
||||
@@ -23,13 +23,14 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func updateWatch(finished: Bool = false) {
|
||||
guard let id = currentVideo?.videoID else {
|
||||
guard let id = currentVideo?.videoID,
|
||||
Defaults[.saveHistory]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let time = player.currentTime()
|
||||
let seconds = time.seconds
|
||||
currentItem.playbackTime = time
|
||||
let time = backend.currentTime
|
||||
let seconds = time?.seconds ?? 0
|
||||
|
||||
let watch: Watch!
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
|
||||
@@ -74,6 +74,13 @@ final class NavigationModel: ObservableObject {
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = true
|
||||
) {
|
||||
guard channel.id != Video.fixtureChannelID else {
|
||||
return
|
||||
}
|
||||
|
||||
player.presentingPlayer = false
|
||||
navigation.presentingChannel = false
|
||||
|
||||
let recent = RecentItem(from: channel)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
@@ -109,6 +116,8 @@ final class NavigationModel: ObservableObject {
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = false
|
||||
) {
|
||||
navigation.presentingPlaylist = false
|
||||
|
||||
let recent = RecentItem(from: playlist)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
|
||||
584
Model/Player/Backends/AVPlayerBackend.swift
Normal file
584
Model/Player/Backends/AVPlayerBackend.swift
Normal file
@@ -0,0 +1,584 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class AVPlayerBackend: PlayerBackend {
|
||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||
|
||||
var model: PlayerModel!
|
||||
var controls: PlayerControlsModel!
|
||||
|
||||
var stream: Stream?
|
||||
var video: Video?
|
||||
|
||||
var currentTime: CMTime? {
|
||||
avPlayer.currentTime()
|
||||
}
|
||||
|
||||
var loadedVideo: Bool {
|
||||
!avPlayer.currentItem.isNil
|
||||
}
|
||||
|
||||
var isLoadingVideo: Bool {
|
||||
model.currentItem == nil || model.time == nil || !model.time!.isValid
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
avPlayer.timeControlStatus == .playing
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
avPlayer.currentItem?.asset.duration
|
||||
}
|
||||
|
||||
private(set) var avPlayer = AVPlayer()
|
||||
var controller: AppleAVPlayerViewController?
|
||||
var startPictureInPictureOnPlay = false
|
||||
var switchToMPVOnPipClose = false
|
||||
|
||||
private var asset: AVURLAsset?
|
||||
private var composition = AVMutableComposition()
|
||||
private var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
private var playerTimeControlStatusObserver: Any?
|
||||
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
init(model: PlayerModel, controls: PlayerControlsModel?) {
|
||||
self.model = model
|
||||
self.controls = controls
|
||||
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
addPlayerTimeControlStatusObserver()
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution _: ResolutionSetting) -> Stream? {
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.max { $0.resolution < $1.resolution }
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.kind == .hls || (stream.kind == .stream && stream.resolution.height <= 720)
|
||||
}
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading _: Bool
|
||||
) {
|
||||
if let url = stream.singleAssetURL {
|
||||
model.logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
} else {
|
||||
model.logger.info("playing stream with many assets:")
|
||||
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
model.logger.info("composition video asset: \(stream.videoAsset.url)")
|
||||
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard avPlayer.timeControlStatus != .playing else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard avPlayer.timeControlStatus != .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.pause()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
|
||||
avPlayer.seek(
|
||||
to: time,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler ?? { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
if let currentTime = currentTime {
|
||||
seek(to: currentTime + time, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
avPlayer.rate = rate
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func enterFullScreen() {
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
}
|
||||
|
||||
func exitFullScreen() {
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
func closePiP(wasPlaying: Bool) {
|
||||
let item = avPlayer.currentItem
|
||||
let time = avPlayer.currentTime()
|
||||
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
|
||||
guard !item.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.seek(to: time)
|
||||
avPlayer.replaceCurrentItem(with: item)
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#else
|
||||
func closePiP(wasPlaying: Bool) {
|
||||
model.pipController?.stopPictureInPicture()
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func loadSingleAsset(
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
var error: NSError?
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
case .failed:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.playerError = error
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadComposition(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
loadedCompositionAssets = []
|
||||
loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime, model: model)
|
||||
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime, model: model)
|
||||
}
|
||||
|
||||
private func loadCompositionAsset(
|
||||
_ asset: AVURLAsset,
|
||||
stream: Stream,
|
||||
type: AVMediaType,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false,
|
||||
model: PlayerModel
|
||||
) {
|
||||
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
model.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
let assetTracks = asset.tracks(withMediaType: type)
|
||||
|
||||
guard let compositionTrack = self.composition.addMutableTrack(
|
||||
withMediaType: type,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
model.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
guard let assetTrack = assetTracks.first else {
|
||||
model.logger.critical("asset \(type.rawValue) track FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
|
||||
model.logger.critical("\(type.rawValue) LOADED")
|
||||
|
||||
guard model.streamSelection == stream else {
|
||||
model.logger.critical("IGNORING LOADED")
|
||||
return
|
||||
}
|
||||
|
||||
self.loadedCompositionAssets.append(type)
|
||||
|
||||
if self.loadedCompositionAssets.count == 2 {
|
||||
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
model.playerItem = playerItem(stream)
|
||||
guard model.playerItem != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
attachMetadata(to: model.playerItem!, video: video, for: stream)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.stream = stream
|
||||
self.video = video
|
||||
self.model.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
self.asset = nil
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
if self.isAutoplaying(self.model.playerItem!) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
if !preservingTime,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
segment.start < 3,
|
||||
self.model.lastSkipped.isNil
|
||||
{
|
||||
self.avPlayer.seek(
|
||||
to: segment.endTime,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero
|
||||
) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
|
||||
self.model.lastSkipped = segment
|
||||
self.model.play()
|
||||
}
|
||||
} else {
|
||||
self.model.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.setRate(self.model.currentRate)
|
||||
}
|
||||
|
||||
let replaceItemAndSeek = {
|
||||
guard video == self.model.currentVideo else {
|
||||
return
|
||||
}
|
||||
self.avPlayer.replaceCurrentItem(with: self.model.playerItem)
|
||||
self.seekToPreservedTime { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
self.model.preservedTime = nil
|
||||
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
avPlayer.replaceCurrentItem(with: model.playerItem)
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||
guard let time = model.preservedTime else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.seek(
|
||||
to: time,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
private func playerItem(_: Stream) -> AVPlayerItem? {
|
||||
if let asset = asset {
|
||||
return AVPlayerItem(asset: asset)
|
||||
} else {
|
||||
return AVPlayerItem(asset: composition)
|
||||
}
|
||||
}
|
||||
|
||||
private func attachMetadata(to item: AVPlayerItem, video: Video, for _: Stream? = nil) {
|
||||
#if !os(macOS)
|
||||
var externalMetadata = [
|
||||
makeMetadataItem(.commonIdentifierTitle, value: video.title),
|
||||
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
|
||||
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
|
||||
]
|
||||
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
|
||||
let image = UIImage(data: thumbnailData),
|
||||
let pngData = image.pngData()
|
||||
{
|
||||
let artworkItem = makeMetadataItem(.commonIdentifierArtwork, value: pngData)
|
||||
externalMetadata.append(artworkItem)
|
||||
}
|
||||
|
||||
item.externalMetadata = externalMetadata
|
||||
#endif
|
||||
|
||||
item.preferredForwardBufferDuration = 5
|
||||
|
||||
observePlayerItemStatus(item)
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
||||
let item = AVMutableMetadataItem()
|
||||
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
item.extendedLanguageTag = "und"
|
||||
|
||||
return item.copy() as! AVMetadataItem
|
||||
}
|
||||
#endif
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
avPlayer.currentItem == item
|
||||
}
|
||||
|
||||
private func observePlayerItemStatus(_ item: AVPlayerItem) {
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
if self.isAutoplaying(playerItem) {
|
||||
self.model.play()
|
||||
}
|
||||
case .failed:
|
||||
self.model.playerError = item.error
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: model.playerItem
|
||||
)
|
||||
}
|
||||
|
||||
private func removeItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: model.playerItem
|
||||
)
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
model.resetQueue()
|
||||
#if os(tvOS)
|
||||
controller?.playerView.dismiss(animated: false) { [weak self] in
|
||||
self?.controller?.dismiss(animated: true)
|
||||
}
|
||||
#else
|
||||
model.hide()
|
||||
#endif
|
||||
} else {
|
||||
if model.playingInPictureInPicture {
|
||||
startPictureInPictureOnPlay = true
|
||||
}
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func addFrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(0.5)
|
||||
|
||||
frequentTimeObserver = avPlayer.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.model.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.controls.duration = self.playerItemDuration ?? .zero
|
||||
self.controls.currentTime = self.currentTime ?? .zero
|
||||
|
||||
#if !os(tvOS)
|
||||
self.model.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
if let currentTime = self.currentTime {
|
||||
self.model.handleSegments(at: currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addInfrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(5)
|
||||
|
||||
infrequentTimeObserver = avPlayer.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.model.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addPlayerTimeControlStatusObserver() {
|
||||
playerTimeControlStatusObserver = avPlayer.observe(\.timeControlStatus) { [weak self] player, _ in
|
||||
guard let self = self,
|
||||
self.avPlayer == player
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.controls.isPlaying = player.timeControlStatus == .playing
|
||||
}
|
||||
|
||||
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate {
|
||||
if let controller = self.model.pipController {
|
||||
if controller.isPictureInPicturePossible {
|
||||
if self.startPictureInPictureOnPlay {
|
||||
self.startPictureInPictureOnPlay = false
|
||||
DispatchQueue.main.async {
|
||||
self.model.pipController?.startPictureInPicture()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
if player.timeControlStatus == .playing {
|
||||
if player.rate != self.model.currentRate {
|
||||
player.rate = self.model.currentRate
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if player.timeControlStatus == .playing {
|
||||
ScreenSaverManager.shared.disable(reason: "Yattee is playing video")
|
||||
} else {
|
||||
ScreenSaverManager.shared.enable()
|
||||
}
|
||||
#endif
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateControls() {}
|
||||
func startControlsUpdates() {}
|
||||
func stopControlsUpdates() {}
|
||||
func setNeedsDrawing(_: Bool) {}
|
||||
func setSize(_: Double, _: Double) {}
|
||||
}
|
||||
387
Model/Player/Backends/MPVBackend.swift
Normal file
387
Model/Player/Backends/MPVBackend.swift
Normal file
@@ -0,0 +1,387 @@
|
||||
import AVFAudio
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftUI
|
||||
|
||||
final class MPVBackend: PlayerBackend {
|
||||
private var logger = Logger(label: "mpv-backend")
|
||||
|
||||
var model: PlayerModel!
|
||||
var controls: PlayerControlsModel!
|
||||
|
||||
var stream: Stream?
|
||||
var video: Video?
|
||||
var currentTime: CMTime?
|
||||
|
||||
var loadedVideo = false
|
||||
var isLoadingVideo = true { didSet {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.controls.isLoadingVideo = self.isLoadingVideo
|
||||
|
||||
if !self.isLoadingVideo {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.handleEOF = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
var isPlaying = true { didSet {
|
||||
if isPlaying {
|
||||
startClientUpdates()
|
||||
} else {
|
||||
stopControlsUpdates()
|
||||
}
|
||||
|
||||
updateControlsIsPlaying()
|
||||
|
||||
#if !os(macOS)
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = self.model.presentingPlayer && self.isPlaying
|
||||
}
|
||||
#endif
|
||||
}}
|
||||
var playerItemDuration: CMTime?
|
||||
|
||||
#if !os(macOS)
|
||||
var controller: MPVViewController!
|
||||
#endif
|
||||
var client: MPVClient! { didSet { client.backend = self } }
|
||||
|
||||
private var clientTimer: RepeatingTimer!
|
||||
|
||||
private var handleEOF = false
|
||||
private var onFileLoaded: (() -> Void)?
|
||||
|
||||
private var controlsUpdates = false
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
init(model: PlayerModel, controls: PlayerControlsModel? = nil) {
|
||||
self.model = model
|
||||
self.controls = controls
|
||||
|
||||
clientTimer = .init(timeInterval: 1)
|
||||
clientTimer.eventHandler = getClientUpdates
|
||||
}
|
||||
|
||||
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
streams
|
||||
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
|
||||
.max { lhs, rhs in
|
||||
let predicates: [AreInIncreasingOrder] = [
|
||||
{ $0.resolution < $1.resolution },
|
||||
{ $0.format > $1.format }
|
||||
]
|
||||
|
||||
for predicate in predicates {
|
||||
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||
continue
|
||||
}
|
||||
|
||||
return predicate(lhs, rhs)
|
||||
}
|
||||
|
||||
return false
|
||||
} ??
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.resolution != .unknown && stream.format != .av1
|
||||
}
|
||||
|
||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading _: Bool) {
|
||||
handleEOF = false
|
||||
#if !os(macOS)
|
||||
if model.presentingPlayer {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
#endif
|
||||
|
||||
let updateCurrentStream = {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.stream = stream
|
||||
self?.video = video
|
||||
self?.model.stream = stream
|
||||
}
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
if !preservingTime,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
segment.start > 4,
|
||||
self.model.lastSkipped.isNil
|
||||
{
|
||||
self.seek(to: segment.endTime) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
|
||||
self.model.lastSkipped = segment
|
||||
self.play()
|
||||
}
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItem: (CMTime?) -> Void = { [weak self] time in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.stop()
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
self.onFileLoaded = {
|
||||
updateCurrentStream()
|
||||
startPlaying()
|
||||
}
|
||||
|
||||
self.client.loadFile(url, time: time) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
}
|
||||
} else {
|
||||
self.onFileLoaded = { [weak self] in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
self?.client.addAudio(stream.audioAsset.url) { _ in
|
||||
updateCurrentStream()
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.client.loadFile(stream.videoAsset.url, time: time) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
} else {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
} else {
|
||||
replaceItem(nil)
|
||||
}
|
||||
|
||||
startClientUpdates()
|
||||
}
|
||||
|
||||
func play() {
|
||||
isPlaying = true
|
||||
startClientUpdates()
|
||||
|
||||
if controls.presentingControls {
|
||||
startControlsUpdates()
|
||||
}
|
||||
|
||||
setRate(model.currentRate)
|
||||
|
||||
client?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPlaying = false
|
||||
stopClientUpdates()
|
||||
|
||||
client?.pause()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
client?.stop()
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
|
||||
client.seek(to: time) { [weak self] _ in
|
||||
self?.getClientUpdates()
|
||||
self?.updateControls()
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
client.seek(relative: time) { [weak self] _ in
|
||||
self?.getClientUpdates()
|
||||
self?.updateControls()
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
client?.setDoubleAsync("speed", Double(rate))
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
handleEOF = false
|
||||
client?.pause()
|
||||
client?.stop()
|
||||
}
|
||||
|
||||
func enterFullScreen() {
|
||||
model.toggleFullscreen(controls?.playingFullscreen ?? false)
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockOrientationInFullScreen] {
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func exitFullScreen() {}
|
||||
|
||||
func closePiP(wasPlaying _: Bool) {}
|
||||
|
||||
func updateControls() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.logger.info("updating controls")
|
||||
self?.controls.currentTime = self?.currentTime ?? .zero
|
||||
self?.controls.duration = self?.playerItemDuration ?? .zero
|
||||
}
|
||||
}
|
||||
|
||||
func startControlsUpdates() {
|
||||
self.logger.info("starting controls updates")
|
||||
controlsUpdates = true
|
||||
}
|
||||
|
||||
func stopControlsUpdates() {
|
||||
self.logger.info("stopping controls updates")
|
||||
controlsUpdates = false
|
||||
}
|
||||
|
||||
func startClientUpdates() {
|
||||
clientTimer.resume()
|
||||
}
|
||||
|
||||
private var handleSegmentsThrottle = Throttle(interval: 1)
|
||||
|
||||
private func getClientUpdates() {
|
||||
self.logger.info("getting client updates")
|
||||
|
||||
currentTime = client?.currentTime
|
||||
playerItemDuration = client?.duration
|
||||
|
||||
if controlsUpdates {
|
||||
updateControls()
|
||||
}
|
||||
|
||||
model.updateNowPlayingInfo()
|
||||
|
||||
handleSegmentsThrottle.execute {
|
||||
if let currentTime = currentTime {
|
||||
model.handleSegments(at: currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopClientUpdates() {
|
||||
clientTimer.suspend()
|
||||
}
|
||||
|
||||
private func updateControlsIsPlaying() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.controls?.isPlaying = self?.isPlaying ?? false
|
||||
}
|
||||
}
|
||||
|
||||
func handle(_ event: UnsafePointer<mpv_event>!) {
|
||||
logger.info("\(String(cString: mpv_event_name(event.pointee.event_id)))")
|
||||
|
||||
switch event.pointee.event_id {
|
||||
case MPV_EVENT_SHUTDOWN:
|
||||
mpv_destroy(client.mpv)
|
||||
client.mpv = nil
|
||||
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
let logmsg = UnsafeMutablePointer<mpv_event_log_message>(OpaquePointer(event.pointee.data))
|
||||
logger.info(.init(stringLiteral: "log: \(String(cString: (logmsg!.pointee.prefix)!)), "
|
||||
+ "\(String(cString: (logmsg!.pointee.level)!)), "
|
||||
+ "\(String(cString: (logmsg!.pointee.text)!))"))
|
||||
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
onFileLoaded?()
|
||||
startClientUpdates()
|
||||
onFileLoaded = nil
|
||||
|
||||
case MPV_EVENT_PLAYBACK_RESTART:
|
||||
isLoadingVideo = false
|
||||
|
||||
onFileLoaded?()
|
||||
startClientUpdates()
|
||||
onFileLoaded = nil
|
||||
|
||||
case MPV_EVENT_UNPAUSE:
|
||||
isLoadingVideo = false
|
||||
|
||||
case MPV_EVENT_END_FILE:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.handleEndOfFile(event)
|
||||
}
|
||||
|
||||
default:
|
||||
logger.info(.init(stringLiteral: "event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
||||
}
|
||||
}
|
||||
|
||||
func handleEndOfFile(_: UnsafePointer<mpv_event>!) {
|
||||
guard handleEOF, !isLoadingVideo else {
|
||||
return
|
||||
}
|
||||
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
model.resetQueue()
|
||||
|
||||
model.hide()
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
client?.setNeedsDrawing(needsDrawing)
|
||||
}
|
||||
|
||||
func setSize(_ width: Double, _ height: Double) {
|
||||
self.client?.setSize(width, height)
|
||||
}
|
||||
}
|
||||
299
Model/Player/Backends/MPVClient.swift
Normal file
299
Model/Player/Backends/MPVClient.swift
Normal file
@@ -0,0 +1,299 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import Logging
|
||||
#if !os(macOS)
|
||||
import Siesta
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class MPVClient: ObservableObject {
|
||||
private var logger = Logger(label: "mpv-client")
|
||||
|
||||
var mpv: OpaquePointer!
|
||||
var mpvGL: OpaquePointer!
|
||||
var queue: DispatchQueue!
|
||||
#if os(macOS)
|
||||
var layer: VideoLayer!
|
||||
var link: CVDisplayLink!
|
||||
#else
|
||||
var glView: MPVOGLView!
|
||||
#endif
|
||||
var backend: MPVBackend!
|
||||
|
||||
var seeking = false
|
||||
|
||||
func create(frame: CGRect? = nil) {
|
||||
#if !os(macOS)
|
||||
if let frame = frame {
|
||||
glView = MPVOGLView(frame: frame)
|
||||
}
|
||||
#endif
|
||||
|
||||
mpv = mpv_create()
|
||||
if mpv == nil {
|
||||
print("failed creating context\n")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
checkError(mpv_request_log_messages(mpv, "warn"))
|
||||
|
||||
#if os(macOS)
|
||||
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
||||
#else
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "override-display-fps", "\(UIScreen.main.maximumFramesPerSecond)"))
|
||||
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
|
||||
#endif
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
|
||||
checkError(mpv_initialize(mpv))
|
||||
|
||||
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
||||
var initParams = mpv_opengl_init_params(
|
||||
get_proc_address: getProcAddress,
|
||||
get_proc_address_ctx: nil,
|
||||
extra_exts: nil
|
||||
)
|
||||
|
||||
queue = DispatchQueue(label: "mpv", qos: .background)
|
||||
|
||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: initParams),
|
||||
mpv_render_param()
|
||||
]
|
||||
|
||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||
puts("failed to initialize mpv GL context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
mpv_render_context_set_update_callback(
|
||||
mpvGL,
|
||||
glUpdate,
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(layer).toOpaque())
|
||||
)
|
||||
#else
|
||||
glView.mpvGL = UnsafeMutableRawPointer(mpvGL)
|
||||
|
||||
mpv_render_context_set_update_callback(
|
||||
mpvGL,
|
||||
glUpdate(_:),
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(glView).toOpaque())
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
queue!.async {
|
||||
mpv_set_wakeup_callback(self.mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
||||
}
|
||||
}
|
||||
|
||||
func readEvents() {
|
||||
queue?.async { [self] in
|
||||
while self.mpv != nil {
|
||||
let event = mpv_wait_event(self.mpv, 0)
|
||||
if event!.pointee.event_id == MPV_EVENT_NONE {
|
||||
break
|
||||
}
|
||||
backend.handle(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadFile(_ url: URL, time: CMTime? = nil, completionHandler: ((Int32) -> Void)? = nil) {
|
||||
var args = [url.absoluteString]
|
||||
if let time = time {
|
||||
args.append("replace")
|
||||
args.append("start=\(Int(time.seconds))")
|
||||
}
|
||||
|
||||
command("loadfile", args: args, returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
func addAudio(_ url: URL, completionHandler: ((Int32) -> Void)? = nil) {
|
||||
command("audio-add", args: [url.absoluteString], returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
func play() {
|
||||
setFlagAsync("pause", false)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
setFlagAsync("pause", true)
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
command("cycle", args: ["pause"])
|
||||
}
|
||||
|
||||
func stop() {
|
||||
command("stop")
|
||||
}
|
||||
|
||||
var currentTime: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(mpv.isNil ? -1 : getDouble("time-pos"))
|
||||
}
|
||||
|
||||
var duration: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(mpv.isNil ? -1 : getDouble("duration"))
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
guard !seeking else {
|
||||
logger.warning("ignoring seek, another in progress")
|
||||
return
|
||||
}
|
||||
|
||||
seeking = true
|
||||
command("seek", args: [String(time.seconds)]) { [weak self] _ in
|
||||
self?.seeking = false
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
guard !seeking else {
|
||||
logger.warning("ignoring seek, another in progress")
|
||||
return
|
||||
}
|
||||
|
||||
seeking = true
|
||||
command("seek", args: [String(time.seconds), "absolute"]) { [weak self] _ in
|
||||
self?.seeking = false
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func setSize(_ width: Double, _ height: Double) {
|
||||
let roundedWidth = width.rounded()
|
||||
let roundedHeight = height.rounded()
|
||||
|
||||
guard width > 0, height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("setting player size to \(roundedWidth),\(roundedHeight)")
|
||||
#if !os(macOS)
|
||||
guard roundedWidth <= UIScreen.main.bounds.width, roundedHeight <= UIScreen.main.bounds.height else {
|
||||
logger.info("requested size is greater than screen size, ignoring")
|
||||
logger.info("width: \(roundedWidth) <= \(UIScreen.main.bounds.width)")
|
||||
logger.info("height: \(roundedHeight) <= \(UIScreen.main.bounds.height)")
|
||||
return
|
||||
}
|
||||
|
||||
glView?.frame = CGRect(x: 0, y: 0, width: roundedWidth, height: roundedHeight)
|
||||
#endif
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
logger.info("needs drawing: \(needsDrawing)")
|
||||
#if !os(macOS)
|
||||
glView.needsDrawing = needsDrawing
|
||||
#endif
|
||||
}
|
||||
|
||||
func command(
|
||||
_ command: String,
|
||||
args: [String?] = [],
|
||||
checkForErrors: Bool = true,
|
||||
returnValueCallback: ((Int32) -> Void)? = nil
|
||||
) {
|
||||
guard mpv != nil else {
|
||||
return
|
||||
}
|
||||
var cargs = makeCArgs(command, args).map { $0.flatMap { UnsafePointer<CChar>(strdup($0)) } }
|
||||
defer {
|
||||
for ptr in cargs where ptr != nil {
|
||||
free(UnsafeMutablePointer(mutating: ptr!))
|
||||
}
|
||||
}
|
||||
logger.info("\(command) -- \(args)")
|
||||
let returnValue = mpv_command(mpv, &cargs)
|
||||
if checkForErrors {
|
||||
checkError(returnValue)
|
||||
}
|
||||
if let cb = returnValueCallback {
|
||||
cb(returnValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func setFlagAsync(_ name: String, _ flag: Bool) {
|
||||
var data: Int = flag ? 1 : 0
|
||||
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_FLAG, &data)
|
||||
}
|
||||
|
||||
func setDoubleAsync(_ name: String, _ value: Double) {
|
||||
var data = value
|
||||
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_DOUBLE, &data)
|
||||
}
|
||||
|
||||
private func getDouble(_ name: String) -> Double {
|
||||
var data = Double()
|
||||
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
|
||||
return data
|
||||
}
|
||||
|
||||
private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] {
|
||||
if !args.isEmpty, args.last == nil {
|
||||
fatalError("Command do not need a nil suffix")
|
||||
}
|
||||
|
||||
var strArgs = args
|
||||
strArgs.insert(command, at: 0)
|
||||
strArgs.append(nil)
|
||||
|
||||
return strArgs
|
||||
}
|
||||
|
||||
func checkError(_ status: CInt) {
|
||||
if status < 0 {
|
||||
logger.error(.init(stringLiteral: "MPV API error: \(String(cString: mpv_error_string(status)))\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {
|
||||
let symbolName = CFStringCreateWithCString(kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
|
||||
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengl" as CFString)
|
||||
|
||||
return CFBundleGetFunctionPointerForName(identifier, symbolName)
|
||||
}
|
||||
|
||||
func glUpdate(_ ctx: UnsafeMutableRawPointer?) {
|
||||
let videoLayer = unsafeBitCast(ctx, to: VideoLayer.self)
|
||||
|
||||
videoLayer.client?.queue?.async {
|
||||
if !videoLayer.isAsynchronous {
|
||||
videoLayer.display()
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {
|
||||
let symbolName = CFStringCreateWithCString(kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
|
||||
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengles" as CFString)
|
||||
|
||||
return CFBundleGetFunctionPointerForName(identifier, symbolName)
|
||||
}
|
||||
|
||||
private func glUpdate(_ ctx: UnsafeMutableRawPointer?) {
|
||||
let glView = unsafeBitCast(ctx, to: MPVOGLView.self)
|
||||
|
||||
guard glView.needsDrawing else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
glView.setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
private func wakeUp(_ context: UnsafeMutableRawPointer?) {
|
||||
let client = unsafeBitCast(context, to: MPVClient.self)
|
||||
client.readEvents()
|
||||
}
|
||||
68
Model/Player/Backends/PlayerBackend.swift
Normal file
68
Model/Player/Backends/PlayerBackend.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
protocol PlayerBackend {
|
||||
var model: PlayerModel! { get set }
|
||||
var controls: PlayerControlsModel! { get set }
|
||||
|
||||
var stream: Stream? { get set }
|
||||
var video: Video? { get set }
|
||||
var currentTime: CMTime? { get }
|
||||
|
||||
var loadedVideo: Bool { get }
|
||||
var isLoadingVideo: Bool { get }
|
||||
|
||||
var isPlaying: Bool { get }
|
||||
var playerItemDuration: CMTime? { get }
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
|
||||
func canPlay(_ stream: Stream) -> Bool
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading: Bool
|
||||
)
|
||||
|
||||
func play()
|
||||
func pause()
|
||||
func togglePlay()
|
||||
|
||||
func stop()
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?)
|
||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)?)
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)?)
|
||||
|
||||
func setRate(_ rate: Float)
|
||||
|
||||
func closeItem()
|
||||
|
||||
func enterFullScreen()
|
||||
func exitFullScreen()
|
||||
|
||||
func closePiP(wasPlaying: Bool)
|
||||
|
||||
func updateControls()
|
||||
func startControlsUpdates()
|
||||
func stopControlsUpdates()
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool)
|
||||
func setSize(_ width: Double, _ height: Double)
|
||||
}
|
||||
|
||||
extension PlayerBackend {
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(to: time, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(to: .secondsInDefaultTimescale(seconds), completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(relative: time, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
16
Model/Player/Backends/PlayerBackendType.swift
Normal file
16
Model/Player/Backends/PlayerBackendType.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
enum PlayerBackendType: String, CaseIterable, Defaults.Serializable {
|
||||
case mpv
|
||||
case appleAVPlayer
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .mpv:
|
||||
return "MPV"
|
||||
case .appleAVPlayer:
|
||||
return "AVPlayer"
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Model/Player/PiPDelegate.swift
Normal file
54
Model/Player/PiPDelegate.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import AVKit
|
||||
import Foundation
|
||||
|
||||
final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
var player: PlayerModel!
|
||||
|
||||
func pictureInPictureController(
|
||||
_: AVPictureInPictureController,
|
||||
failedToStartPictureInPictureWithError error: Error
|
||||
) {
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_: AVPictureInPictureController) {}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(_: AVPictureInPictureController) {
|
||||
player?.playingInPictureInPicture = true
|
||||
player?.avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_: AVPictureInPictureController) {
|
||||
guard let player = player else {
|
||||
return
|
||||
}
|
||||
|
||||
if player.avPlayerBackend.switchToMPVOnPipClose,
|
||||
!player.currentItem.isNil
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
player.avPlayerBackend.switchToMPVOnPipClose = false
|
||||
player.saveTime {
|
||||
player.changeActiveBackend(from: .appleAVPlayer, to: .mpv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
player.playingInPictureInPicture = false
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {}
|
||||
|
||||
func pictureInPictureController(
|
||||
_: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
if !player.currentItem.isNil {
|
||||
player?.show()
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
145
Model/Player/PlayerControlsModel.swift
Normal file
145
Model/Player/PlayerControlsModel.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class PlayerControlsModel: ObservableObject {
|
||||
@Published var isLoadingVideo = false
|
||||
@Published var isPlaying = true
|
||||
@Published var currentTime = CMTime.zero
|
||||
@Published var duration = CMTime.zero
|
||||
@Published var presentingControls = false { didSet { handlePresentationChange() } }
|
||||
@Published var timer: Timer?
|
||||
@Published var playingFullscreen = false
|
||||
|
||||
private var throttle = Throttle(interval: 1)
|
||||
|
||||
var player: PlayerModel!
|
||||
|
||||
var playbackTime: String {
|
||||
guard let current = currentTime.seconds.formattedAsPlaybackTime(),
|
||||
let duration = duration.seconds.formattedAsPlaybackTime()
|
||||
else {
|
||||
return "--:-- / --:--"
|
||||
}
|
||||
|
||||
var withoutSegments = ""
|
||||
if let withoutSegmentsDuration = playerItemDurationWithoutSponsorSegments,
|
||||
self.duration.seconds != withoutSegmentsDuration
|
||||
{
|
||||
withoutSegments = " (\(withoutSegmentsDuration.formattedAsPlaybackTime() ?? "--:--"))"
|
||||
}
|
||||
|
||||
return "\(current) / \(duration)\(withoutSegments)"
|
||||
}
|
||||
|
||||
var playerItemDurationWithoutSponsorSegments: Double? {
|
||||
guard let duration = player.playerItemDurationWithoutSponsorSegments else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return duration.seconds
|
||||
}
|
||||
|
||||
func handlePresentationChange() {
|
||||
if presentingControls {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.player?.backend.startControlsUpdates()
|
||||
self?.resetTimer()
|
||||
}
|
||||
} else {
|
||||
player.backend.stopControlsUpdates()
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard !(player?.currentItem.isNil ?? true) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !presentingControls else {
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls = true
|
||||
}
|
||||
}
|
||||
|
||||
func hide() {
|
||||
player?.backend.stopControlsUpdates()
|
||||
|
||||
guard !(player?.currentItem.isNil ?? true) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard presentingControls else {
|
||||
return
|
||||
}
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls = false
|
||||
}
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentTime = .zero
|
||||
duration = .zero
|
||||
}
|
||||
|
||||
func resetTimer() {
|
||||
#if os(tvOS)
|
||||
if !presentingControls {
|
||||
show()
|
||||
}
|
||||
#endif
|
||||
|
||||
removeTimer()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
||||
withAnimation(PlayerControls.animation) { [weak self] in
|
||||
self?.presentingControls = false
|
||||
self?.player.backend.stopControlsUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startPiP(startImmediately: Bool = true) {
|
||||
if player.activeBackend == .mpv {
|
||||
player.avPlayerBackend.switchToMPVOnPipClose = true
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
player.exitFullScreen()
|
||||
#endif
|
||||
|
||||
if player.activeBackend != PlayerBackendType.appleAVPlayer {
|
||||
player.saveTime { [weak player] in
|
||||
player?.changeActiveBackend(from: .mpv, to: .appleAVPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak player] in
|
||||
player?.avPlayerBackend.startPictureInPictureOnPlay = true
|
||||
if startImmediately {
|
||||
player?.pipController?.startPictureInPicture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
func update() {
|
||||
throttle.execute { [weak self] in
|
||||
self?.player?.backend.updateControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,37 @@ import SwiftyJSON
|
||||
|
||||
final class PlayerModel: ObservableObject {
|
||||
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
|
||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||
let logger = Logger(label: "stream.yattee.app")
|
||||
|
||||
private(set) var player = AVPlayer()
|
||||
var playerView = Player()
|
||||
var controller: PlayerViewController?
|
||||
var avPlayerView = AppleAVPlayerView()
|
||||
var playerItem: AVPlayerItem?
|
||||
|
||||
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
|
||||
var mpvPlayerView = MPVPlayerView()
|
||||
|
||||
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
|
||||
@Published var activeBackend = PlayerBackendType.mpv
|
||||
|
||||
var avPlayerBackend: AVPlayerBackend!
|
||||
var mpvBackend: MPVBackend!
|
||||
|
||||
var backends: [PlayerBackend] {
|
||||
[avPlayerBackend, mpvBackend]
|
||||
}
|
||||
|
||||
var backend: PlayerBackend! {
|
||||
switch activeBackend {
|
||||
case .mpv:
|
||||
return mpvBackend
|
||||
case .appleAVPlayer:
|
||||
return avPlayerBackend
|
||||
}
|
||||
}
|
||||
|
||||
@Published var playerSize: CGSize = .zero { didSet {
|
||||
backend.setSize(playerSize.width, playerSize.height)
|
||||
}}
|
||||
@Published var stream: Stream?
|
||||
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } }
|
||||
@Published var currentRate: Float = 1.0 { didSet { backend.setRate(currentRate) } }
|
||||
|
||||
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
@@ -38,14 +57,12 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
@Published var preservedTime: CMTime?
|
||||
|
||||
@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?
|
||||
@Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI()
|
||||
|
||||
#if os(iOS)
|
||||
@Published var motionManager: CMMotionManager!
|
||||
@@ -55,24 +72,17 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
var accounts: AccountsModel
|
||||
var comments: CommentsModel
|
||||
|
||||
var asset: AVURLAsset?
|
||||
var composition = AVMutableComposition()
|
||||
var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
var controls: PlayerControlsModel { didSet {
|
||||
backends.forEach { backend in
|
||||
var backend = backend
|
||||
backend.controls = controls
|
||||
}
|
||||
}}
|
||||
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
private var playerTimeControlStatusObserver: Any?
|
||||
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
var playingInPictureInPicture = false
|
||||
var playingFullscreen = false
|
||||
@Published var playingInPictureInPicture = false
|
||||
var pipController: AVPictureInPictureController?
|
||||
var pipDelegate = PiPDelegate()
|
||||
|
||||
@Published var presentingErrorDetails = false
|
||||
var playerError: Error? { didSet {
|
||||
@@ -91,32 +101,47 @@ final class PlayerModel: ObservableObject {
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
#if !os(macOS)
|
||||
var playerLayerView: PlayerLayerView!
|
||||
#endif
|
||||
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil, controls: PlayerControlsModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
self.comments = comments ?? CommentsModel()
|
||||
self.controls = controls ?? PlayerControlsModel()
|
||||
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
addPlayerTimeControlStatusObserver()
|
||||
self.avPlayerBackend = AVPlayerBackend(model: self, controls: controls)
|
||||
self.mpvBackend = MPVBackend(model: self)
|
||||
|
||||
self.activeBackend = Defaults[.activeBackend]
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard !presentingPlayer else {
|
||||
#if os(macOS)
|
||||
#if os(macOS)
|
||||
if presentingPlayer {
|
||||
Windows.player.focus()
|
||||
#endif
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
presentingPlayer = true
|
||||
|
||||
#if os(macOS)
|
||||
Windows.player.open()
|
||||
Windows.player.focus()
|
||||
#endif
|
||||
presentingPlayer = true
|
||||
}
|
||||
|
||||
func hide() {
|
||||
controls.playingFullscreen = false
|
||||
presentingPlayer = false
|
||||
playerNavigationLinkActive = false
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func togglePlayer() {
|
||||
@@ -139,11 +164,25 @@ final class PlayerModel: ObservableObject {
|
||||
return false
|
||||
}
|
||||
|
||||
return player.currentItem == nil || time == nil || !time!.isValid
|
||||
return backend.isLoadingVideo
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
player.timeControlStatus == .playing
|
||||
backend.isPlaying
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
backend.playerItemDuration
|
||||
}
|
||||
|
||||
var playerItemDurationWithoutSponsorSegments: CMTime? {
|
||||
(backend.playerItemDuration ?? .zero) - .secondsInDefaultTimescale(
|
||||
sponsorBlock.segments.reduce(0) { $0 + $1.duration }
|
||||
)
|
||||
}
|
||||
|
||||
var videoDuration: TimeInterval? {
|
||||
currentItem?.duration ?? currentVideo?.length ?? playerItemDuration?.seconds
|
||||
}
|
||||
|
||||
var time: CMTime? {
|
||||
@@ -154,44 +193,37 @@ final class PlayerModel: ObservableObject {
|
||||
currentVideo?.live ?? false
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
player.currentItem?.asset.duration
|
||||
}
|
||||
|
||||
var videoDuration: TimeInterval? {
|
||||
currentItem?.duration ?? currentVideo?.length ?? player.currentItem?.asset.duration.seconds
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
backend.togglePlay()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard player.timeControlStatus != .playing else {
|
||||
return
|
||||
}
|
||||
|
||||
player.play()
|
||||
backend.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard player.timeControlStatus != .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
player.pause()
|
||||
backend.pause()
|
||||
}
|
||||
|
||||
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
|
||||
playNow(video, at: time)
|
||||
func play(_ video: Video, at time: CMTime? = nil, showingPlayer: Bool = true) {
|
||||
var delay = 0.0
|
||||
#if !os(macOS)
|
||||
delay = 0.3
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.playNow(video, at: time)
|
||||
}
|
||||
|
||||
guard !playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
if showingPlayer {
|
||||
show()
|
||||
}
|
||||
}
|
||||
@@ -210,33 +242,51 @@ final class PlayerModel: ObservableObject {
|
||||
self?.sponsorBlock.loadSegments(
|
||||
videoID: video.videoID,
|
||||
categories: Defaults[.sponsorBlockCategories]
|
||||
) { [weak self] in
|
||||
if Defaults[.showChannelSubscribers] {
|
||||
self?.loadCurrentItemChannelDetails()
|
||||
}
|
||||
)
|
||||
|
||||
guard Defaults[.enableReturnYouTubeDislike] else {
|
||||
return
|
||||
}
|
||||
|
||||
self?.returnYouTubeDislike.loadDislikes(videoID: video.videoID) { [weak self] dislikes in
|
||||
self?.currentItem?.video?.dislikes = dislikes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
} else {
|
||||
logger.info("playing stream with many assets:")
|
||||
logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
logger.info("composition video asset: \(stream.videoAsset.url)")
|
||||
controls.reset()
|
||||
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
backend.playStream(
|
||||
stream,
|
||||
of: video,
|
||||
preservingTime: preservingTime,
|
||||
upgrading: upgrading
|
||||
)
|
||||
|
||||
if !upgrading {
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream) {
|
||||
if !self.stream.isNil, self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
|
||||
func saveTime(completionHandler: @escaping () -> Void = {}) {
|
||||
guard let currentTime = backend.currentTime, currentTime.seconds > 0 else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.preservedTime = currentTime
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream, force: Bool = false) {
|
||||
guard let video = currentVideo else {
|
||||
return
|
||||
}
|
||||
|
||||
if !self.stream.isNil, force || self.stream != stream {
|
||||
playStream(stream, of: video, preservingTime: true, upgrading: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +310,24 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func handlePresentationChange() {
|
||||
var delay = 0.0
|
||||
|
||||
#if os(iOS)
|
||||
if presentingPlayer {
|
||||
delay = 0.2
|
||||
}
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.backend.setNeedsDrawing(self?.presentingPlayer ?? false)
|
||||
}
|
||||
|
||||
controls.hide()
|
||||
|
||||
#if !os(macOS)
|
||||
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
|
||||
#endif
|
||||
|
||||
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.closePiP()
|
||||
@@ -272,468 +340,64 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer, !pauseOnHidingPlayer, isPlaying {
|
||||
if !presentingPlayer, !pauseOnHidingPlayer, backend.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
playerItem = playerItem(stream)
|
||||
guard playerItem != nil else {
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType) {
|
||||
guard activeBackend != to else {
|
||||
return
|
||||
}
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
attachMetadata(to: playerItem!, video: video, for: stream)
|
||||
Defaults[.activeBackend] = to
|
||||
self.activeBackend = to
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
self.asset = nil
|
||||
guard var stream = stream else {
|
||||
return
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
inactiveBackends().forEach { $0.pause() }
|
||||
|
||||
if self.isAutoplaying(self.playerItem!) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
let fromBackend: PlayerBackend = from == .appleAVPlayer ? avPlayerBackend : mpvBackend
|
||||
let toBackend: PlayerBackend = to == .appleAVPlayer ? avPlayerBackend : mpvBackend
|
||||
|
||||
if !preservingTime,
|
||||
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 = {
|
||||
guard video == self.currentVideo else {
|
||||
return
|
||||
}
|
||||
self.player.replaceCurrentItem(with: self.playerItem)
|
||||
self.seekToPreservedTime { finished in
|
||||
if let stream = toBackend.stream, toBackend.video == fromBackend.video {
|
||||
toBackend.seek(to: fromBackend.currentTime?.seconds ?? .zero) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
self.preservedTime = nil
|
||||
|
||||
startPlaying()
|
||||
toBackend.play()
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
streamSelection = stream
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if preservedTime.isNil {
|
||||
saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSingleAsset(
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
var error: NSError?
|
||||
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
case .failed:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.playerError = error
|
||||
}
|
||||
default:
|
||||
if !backend.canPlay(stream) || (to == .mpv && !stream.hlsURL.isNil) {
|
||||
guard let preferredStream = preferredStream(availableStreams) else {
|
||||
return
|
||||
}
|
||||
|
||||
stream = preferredStream
|
||||
streamSelection = preferredStream
|
||||
}
|
||||
}
|
||||
|
||||
private func loadComposition(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
loadedCompositionAssets = []
|
||||
loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime)
|
||||
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
private func loadCompositionAsset(
|
||||
_ asset: AVURLAsset,
|
||||
stream: Stream,
|
||||
type: AVMediaType,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
self.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
let assetTracks = asset.tracks(withMediaType: type)
|
||||
|
||||
guard let compositionTrack = self.composition.addMutableTrack(
|
||||
withMediaType: type,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
self.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
guard let assetTrack = assetTracks.first else {
|
||||
self.logger.critical("asset \(type.rawValue) track FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
|
||||
self.logger.critical("\(type.rawValue) LOADED")
|
||||
|
||||
guard self.streamSelection == stream else {
|
||||
self.logger.critical("IGNORING LOADED")
|
||||
return
|
||||
}
|
||||
|
||||
self.loadedCompositionAssets.append(type)
|
||||
|
||||
if self.loadedCompositionAssets.count == 2 {
|
||||
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
self.upgradeToStream(stream, force: true)
|
||||
self.setNeedsDrawing(self.presentingPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
private func playerItem(_: Stream) -> AVPlayerItem? {
|
||||
if let asset = asset {
|
||||
return AVPlayerItem(asset: asset)
|
||||
} else {
|
||||
return AVPlayerItem(asset: composition)
|
||||
}
|
||||
}
|
||||
|
||||
private func attachMetadata(to item: AVPlayerItem, video: Video, for _: Stream? = nil) {
|
||||
#if !os(macOS)
|
||||
var externalMetadata = [
|
||||
makeMetadataItem(.commonIdentifierTitle, value: video.title),
|
||||
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
|
||||
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
|
||||
]
|
||||
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
|
||||
let image = UIImage(data: thumbnailData),
|
||||
let pngData = image.pngData()
|
||||
{
|
||||
let artworkItem = makeMetadataItem(.commonIdentifierArtwork, value: pngData)
|
||||
externalMetadata.append(artworkItem)
|
||||
}
|
||||
|
||||
item.externalMetadata = externalMetadata
|
||||
#endif
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
if self.isAutoplaying(playerItem) {
|
||||
self.play()
|
||||
}
|
||||
case .failed:
|
||||
self.playerError = item.error
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
||||
let item = AVMutableMetadataItem()
|
||||
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
item.extendedLanguageTag = "und"
|
||||
|
||||
return item.copy() as! AVMetadataItem
|
||||
}
|
||||
#endif
|
||||
|
||||
private func addItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
private func removeItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
resetQueue()
|
||||
#if os(tvOS)
|
||||
controller?.playerView.dismiss(animated: false) { [weak self] in
|
||||
self?.controller?.dismiss(animated: true)
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveTime(completionHandler: @escaping () -> Void = {}) {
|
||||
let currentTime = player.currentTime()
|
||||
|
||||
guard currentTime.seconds > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.preservedTime = currentTime
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||
guard let time = preservedTime else {
|
||||
return
|
||||
}
|
||||
|
||||
player.seek(
|
||||
to: time,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
private func addFrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(0.5)
|
||||
|
||||
frequentTimeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
self.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
self.handleSegments(at: self.player.currentTime())
|
||||
}
|
||||
}
|
||||
|
||||
private func addInfrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(5)
|
||||
|
||||
infrequentTimeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addPlayerTimeControlStatusObserver() {
|
||||
playerTimeControlStatusObserver = player.observe(\.timeControlStatus) { [weak self] player, _ in
|
||||
guard let self = self,
|
||||
self.player == player
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
if player.timeControlStatus == .playing, player.rate != self.currentRate {
|
||||
player.rate = self.currentRate
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if player.timeControlStatus == .playing {
|
||||
ScreenSaverManager.shared.disable(reason: "Yattee is playing video")
|
||||
} else {
|
||||
ScreenSaverManager.shared.enable()
|
||||
}
|
||||
#endif
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func updateNowPlayingInfo() {
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||
]
|
||||
|
||||
if !currentArtwork.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
|
||||
}
|
||||
|
||||
if !currentItem.video.live {
|
||||
let itemDuration = currentItem.videoDuration ?? currentItem.duration
|
||||
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
|
||||
|
||||
if !duration.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
|
||||
}
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
private func updateCurrentArtwork() {
|
||||
guard let thumbnailData = try? Data(contentsOf: currentItem.video.thumbnailURL(quality: .medium)!) else {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
let image = NSImage(data: thumbnailData)
|
||||
#else
|
||||
let image = UIImage(data: thumbnailData)
|
||||
#endif
|
||||
|
||||
if image.isNil {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
private func inactiveBackends() -> [PlayerBackend] {
|
||||
[activeBackend == PlayerBackendType.mpv ? avPlayerBackend : mpvBackend]
|
||||
}
|
||||
|
||||
func rateLabel(_ rate: Float) -> String {
|
||||
@@ -744,10 +408,11 @@ final class PlayerModel: ObservableObject {
|
||||
return "\(formatter.string(from: NSNumber(value: rate))!)×"
|
||||
}
|
||||
|
||||
func closeCurrentItem() {
|
||||
prepareCurrentItemForHistory()
|
||||
func closeCurrentItem(finished: Bool = false) {
|
||||
prepareCurrentItemForHistory(finished: finished)
|
||||
currentItem = nil
|
||||
player.replaceCurrentItem(with: nil)
|
||||
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
func closePiP() {
|
||||
@@ -762,46 +427,9 @@ final class PlayerModel: ObservableObject {
|
||||
show()
|
||||
#endif
|
||||
|
||||
doClosePiP(wasPlaying: wasPlaying)
|
||||
backend.closePiP(wasPlaying: wasPlaying)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private func doClosePiP(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()
|
||||
}
|
||||
}
|
||||
#else
|
||||
private func doClosePiP(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()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func handleCurrentItemChange() {
|
||||
#if os(macOS)
|
||||
Windows.player.window?.title = windowTitle
|
||||
@@ -825,25 +453,104 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func enterFullScreen() {
|
||||
guard !playingFullscreen else {
|
||||
guard !controls.playingFullscreen else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("entering fullscreen")
|
||||
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
backend.enterFullScreen()
|
||||
}
|
||||
|
||||
func exitFullScreen() {
|
||||
guard playingFullscreen else {
|
||||
guard controls.playingFullscreen else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("exiting fullscreen")
|
||||
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
if controls.playingFullscreen {
|
||||
toggleFullscreen(true)
|
||||
}
|
||||
|
||||
backend.exitFullScreen()
|
||||
}
|
||||
#endif
|
||||
|
||||
func updateNowPlayingInfo() {
|
||||
guard let video = currentItem?.video else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: video.author as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueIndex: 1 as AnyObject,
|
||||
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||
]
|
||||
|
||||
if !currentArtwork.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
|
||||
}
|
||||
|
||||
if !video.live {
|
||||
let itemDuration = (backend.playerItemDuration ?? .zero).seconds
|
||||
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
|
||||
|
||||
if !duration.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
|
||||
}
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
func updateCurrentArtwork() {
|
||||
guard let video = currentVideo,
|
||||
let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
let image = NSImage(data: thumbnailData)
|
||||
#else
|
||||
let image = UIImage(data: thumbnailData)
|
||||
#endif
|
||||
|
||||
if image.isNil {
|
||||
return
|
||||
}
|
||||
|
||||
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
|
||||
}
|
||||
|
||||
func toggleFullscreen(_ isFullScreen: Bool) {
|
||||
controls.resetTimer()
|
||||
|
||||
#if os(macOS)
|
||||
Windows.player.toggleFullScreen()
|
||||
#endif
|
||||
|
||||
controls.playingFullscreen = !isFullScreen
|
||||
|
||||
#if os(iOS)
|
||||
if controls.playingFullscreen {
|
||||
guard !(UIApplication.shared.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? true) else {
|
||||
return
|
||||
}
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: .landscapeRight)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
backends.forEach { $0.setNeedsDrawing(needsDrawing) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ extension PlayerModel {
|
||||
currentItem?.video
|
||||
}
|
||||
|
||||
func play(_ videos: [Video], shuffling: Bool = false, inNavigationView: Bool = false) {
|
||||
func play(_ videos: [Video], shuffling: Bool = false) {
|
||||
let videosToPlay = shuffling ? videos.shuffled() : videos
|
||||
|
||||
guard let first = videosToPlay.first else {
|
||||
@@ -27,11 +27,7 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
func playNext(_ video: Video) {
|
||||
@@ -42,7 +38,7 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func playNow(_ video: Video, at time: TimeInterval? = nil) {
|
||||
func playNow(_ video: Video, at time: CMTime? = nil) {
|
||||
if playingInPictureInPicture, closePiPOnNavigation {
|
||||
closePiP()
|
||||
}
|
||||
@@ -54,9 +50,9 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: CMTime? = nil) {
|
||||
if !playingInPictureInPicture {
|
||||
player.replaceCurrentItem(with: nil)
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
comments.reset()
|
||||
@@ -64,7 +60,7 @@ extension PlayerModel {
|
||||
currentItem = item
|
||||
|
||||
if !time.isNil {
|
||||
currentItem.playbackTime = .secondsInDefaultTimescale(time!)
|
||||
currentItem.playbackTime = time
|
||||
} else if currentItem.playbackTime.isNil {
|
||||
currentItem.playbackTime = .zero
|
||||
}
|
||||
@@ -74,7 +70,6 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
preservedTime = currentItem.playbackTime
|
||||
restoreLoadedChannel()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let video = self?.currentVideo else {
|
||||
@@ -93,15 +88,9 @@ extension PlayerModel {
|
||||
streams = streams.filter { $0.instance.id == id }
|
||||
}
|
||||
|
||||
switch quality {
|
||||
case .best:
|
||||
return streams.first { $0.kind == .hls } ??
|
||||
streams.filter { $0.kind == .stream }.max { $0.resolution < $1.resolution } ??
|
||||
streams.first
|
||||
default:
|
||||
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }
|
||||
return sorted.first(where: { $0.resolution.height <= quality.value.height })
|
||||
}
|
||||
streams = streams.filter { backend.canPlay($0) }
|
||||
|
||||
return backend.bestPlayable(streams, maxResolution: quality)
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
@@ -112,13 +101,12 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: CMTime? = nil) {
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
remove(newItem)
|
||||
|
||||
currentItem = newItem
|
||||
player.pause()
|
||||
|
||||
accounts.api.loadDetails(newItem) { newItem in
|
||||
self.playItem(newItem, video: newItem.video, at: time)
|
||||
@@ -144,11 +132,7 @@ extension PlayerModel {
|
||||
self.removeQueueItems()
|
||||
}
|
||||
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
player.currentItem == item
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
@discardableResult func enqueueVideo(
|
||||
@@ -163,7 +147,6 @@ extension PlayerModel {
|
||||
if play {
|
||||
currentItem = item
|
||||
// pause playing current video as it's going to be replaced with next one
|
||||
player.pause()
|
||||
}
|
||||
|
||||
queue.insert(item, at: prepending ? 0 : queue.endIndex)
|
||||
@@ -188,8 +171,8 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func playHistory(_ item: PlayerQueueItem) {
|
||||
var time = item.playbackTime
|
||||
func playHistory(_ item: PlayerQueueItem, at time: CMTime? = nil) {
|
||||
var time = time ?? item.playbackTime
|
||||
|
||||
if item.shouldRestartPlaying {
|
||||
time = .zero
|
||||
@@ -209,8 +192,17 @@ extension PlayerModel {
|
||||
return
|
||||
}
|
||||
|
||||
queue = ([Defaults[.lastPlayed]] + Defaults[.queue]).compactMap { $0 }
|
||||
Defaults[.lastPlayed] = nil
|
||||
var restoredQueue = [PlayerQueueItem?]()
|
||||
|
||||
if let lastPlayed = Defaults[.lastPlayed],
|
||||
!Defaults[.queue].contains(where: { $0.videoID == lastPlayed.videoID })
|
||||
{
|
||||
restoredQueue.append(lastPlayed)
|
||||
Defaults[.lastPlayed] = nil
|
||||
}
|
||||
|
||||
restoredQueue.append(contentsOf: Defaults[.queue])
|
||||
queue = restoredQueue.compactMap { $0 }
|
||||
|
||||
queue.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
|
||||
@@ -28,7 +28,7 @@ struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
videoDuration ?? video.length
|
||||
videoDuration ?? video?.length ?? .zero
|
||||
}
|
||||
|
||||
var shouldRestartPlaying: Bool {
|
||||
|
||||
@@ -38,16 +38,20 @@ extension PlayerModel {
|
||||
return
|
||||
}
|
||||
|
||||
player.seek(to: segment.endTime)
|
||||
lastSkipped = segment
|
||||
segmentRestorationTime = time
|
||||
backend.seek(to: segment.endTime)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.lastSkipped = segment
|
||||
self?.segmentRestorationTime = time
|
||||
}
|
||||
logger.info("SponsorBlock skipping to: \(segment.end)")
|
||||
}
|
||||
|
||||
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
||||
guard isPlaying,
|
||||
!restoredSegments.contains(segment),
|
||||
Defaults[.sponsorBlockCategories].contains(segment.category)
|
||||
Defaults[.sponsorBlockCategories].contains(segment.category),
|
||||
segment.start > 4
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -63,13 +67,15 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
restoredSegments.append(segment)
|
||||
player.seek(to: time)
|
||||
backend.seek(to: time)
|
||||
resetLastSegment()
|
||||
}
|
||||
|
||||
private func resetLastSegment() {
|
||||
lastSkipped = nil
|
||||
segmentRestorationTime = nil
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.lastSkipped = nil
|
||||
self?.segmentRestorationTime = nil
|
||||
}
|
||||
}
|
||||
|
||||
func resetSegments() {
|
||||
|
||||
@@ -66,7 +66,7 @@ extension PlayerModel {
|
||||
|
||||
func rebuildTVMenu() {
|
||||
#if os(tvOS)
|
||||
controller?.playerView.transportBarCustomMenuItems = [
|
||||
avPlayerBackend.controller?.playerView.transportBarCustomMenuItems = [
|
||||
restoreLastSkippedSegmentAction,
|
||||
rateMenu,
|
||||
streamsMenu
|
||||
|
||||
@@ -18,11 +18,11 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
||||
var title: String
|
||||
var visibility: Visibility
|
||||
|
||||
var updated: TimeInterval
|
||||
var updated: TimeInterval?
|
||||
|
||||
var videos = [Video]()
|
||||
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval, videos: [Video] = []) {
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval? = nil, videos: [Video] = []) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.visibility = visibility
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
final class PlaylistsModel: ObservableObject {
|
||||
@Published var playlists = [Playlist]()
|
||||
@Published var reloadPlaylists = false
|
||||
|
||||
var accounts = AccountsModel()
|
||||
|
||||
@@ -58,24 +59,20 @@ final class PlaylistsModel: ObservableObject {
|
||||
onSuccess: @escaping () -> Void = {},
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in }
|
||||
) {
|
||||
let resource = accounts.api.playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in
|
||||
self.load(force: true)
|
||||
accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
|
||||
self.load(force: true) {
|
||||
self.reloadPlaylists.toggle()
|
||||
onSuccess()
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
}
|
||||
|
||||
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
|
||||
|
||||
resource?.request(.delete).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
func removeVideo(index: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.removeVideoFromPlaylist(index, playlistID, onFailure: { _ in }) {
|
||||
self.load(force: true) {
|
||||
self.reloadPlaylists.toggle()
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Foundation
|
||||
final class RecentsModel: ObservableObject {
|
||||
@Default(.recentlyOpened) var items
|
||||
@Default(.saveRecents) var saveRecents
|
||||
|
||||
func clear() {
|
||||
items = []
|
||||
}
|
||||
|
||||
48
Model/ReturnYouTubeDislike/ReturnYouTubeDislikeAPI.swift
Normal file
48
Model/ReturnYouTubeDislike/ReturnYouTubeDislikeAPI.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Alamofire
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
final class ReturnYouTubeDislikeAPI: ObservableObject {
|
||||
let logger = Logger(label: "stream.yattee.app.rytd")
|
||||
|
||||
@Published var videoID: String?
|
||||
@Published var dislikes = -1
|
||||
|
||||
func loadDislikes(videoID: String, completionHandler: @escaping (Int) -> Void = { _ in }) {
|
||||
guard self.videoID != videoID else {
|
||||
completionHandler(dislikes)
|
||||
return
|
||||
}
|
||||
|
||||
self.videoID = videoID
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.requestDislikes(completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestDislikes(completionHandler: @escaping (Int) -> Void = { _ in }) {
|
||||
AF.request(votesURL).responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
let value = JSON(value).dictionaryValue["dislikes"]?.int
|
||||
self.dislikes = value ?? -1
|
||||
|
||||
case let .failure(error):
|
||||
self.logger.error("failed to load dislikes: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
completionHandler(self.dislikes)
|
||||
}
|
||||
}
|
||||
|
||||
private var votesURL: String {
|
||||
"https://returnyoutubedislikeapi.com/Votes?videoId=\(videoID ?? "")"
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ final class SearchModel: ObservableObject {
|
||||
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
|
||||
resource = accounts.api.search(query, page: page?.nextPage)
|
||||
resource = accounts.api.search(query, page: pageToLoad.nextPage)
|
||||
resource.addObserver(store)
|
||||
|
||||
resource
|
||||
|
||||
@@ -17,6 +17,10 @@ class Segment: ObservableObject, Hashable {
|
||||
segment.last!
|
||||
}
|
||||
|
||||
var duration: Double {
|
||||
end - start
|
||||
}
|
||||
|
||||
var endTime: CMTime {
|
||||
CMTime(seconds: end, preferredTimescale: 1000)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
@Published var segments = [Segment]()
|
||||
|
||||
static func categoryDescription(_ name: String) -> String? {
|
||||
guard SponsorBlockAPI.categories.contains(name) else {
|
||||
guard Self.categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
}
|
||||
|
||||
static func categoryDetails(_ name: String) -> String? {
|
||||
guard SponsorBlockAPI.categories.contains(name) else {
|
||||
guard Self.categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -22,4 +22,8 @@ final class Store<Data>: ResourceObserver, ObservableObject {
|
||||
func replace(_ items: Data) {
|
||||
all = items
|
||||
}
|
||||
|
||||
func clear() {
|
||||
all = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,29 @@ import Foundation
|
||||
// swiftlint:disable:next final_class
|
||||
class Stream: Equatable, Hashable, Identifiable {
|
||||
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case hd1440p60, hd1440p, hd1080p60, hd1080p, hd720p60, hd720p, sd480p, sd360p, sd240p, sd144p, unknown
|
||||
case hd4320p60
|
||||
case hd4320p
|
||||
case hd2160p60
|
||||
case hd2160p50
|
||||
case hd2160p48
|
||||
case hd2160p
|
||||
case hd1440p60
|
||||
case hd1440p50
|
||||
case hd1440p48
|
||||
case hd1440p
|
||||
case hd1080p60
|
||||
case hd1080p50
|
||||
case hd1080p48
|
||||
case hd1080p
|
||||
case hd720p60
|
||||
case hd720p50
|
||||
case hd720p48
|
||||
case hd720p
|
||||
case sd480p
|
||||
case sd360p
|
||||
case sd240p
|
||||
case sd144p
|
||||
case unknown
|
||||
|
||||
var name: String {
|
||||
"\(height)p\(refreshRate != -1 ? ", \(refreshRate) fps" : "")"
|
||||
@@ -57,6 +79,49 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Format: String, Comparable {
|
||||
case webm
|
||||
case avc1
|
||||
case av1
|
||||
case mp4
|
||||
case unknown
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .webm:
|
||||
return 0
|
||||
case .mp4:
|
||||
return 1
|
||||
case .avc1:
|
||||
return 2
|
||||
case .av1:
|
||||
return 3
|
||||
case .unknown:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
|
||||
static func from(_ string: String) -> Self {
|
||||
let lowercased = string.lowercased()
|
||||
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
} else if lowercased.contains("avc1") {
|
||||
return .avc1
|
||||
} else if lowercased.contains("av01") {
|
||||
return .av1
|
||||
} else if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
|
||||
var instance: Instance!
|
||||
@@ -66,8 +131,10 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var resolution: Resolution!
|
||||
var kind: Kind!
|
||||
var format: Format!
|
||||
|
||||
var encoding: String!
|
||||
var videoFormat: String!
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
@@ -76,7 +143,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
hlsURL: URL? = nil,
|
||||
resolution: Resolution? = nil,
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil
|
||||
encoding: String? = nil,
|
||||
videoFormat: String? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
@@ -85,14 +153,20 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.resolution = resolution
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
}
|
||||
|
||||
var quality: String {
|
||||
kind == .hls ? "adaptive (HLS)" : "\(resolution.name) \(kind == .stream ? "(\(kind.rawValue))" : "")"
|
||||
if resolution == .hd2160p {
|
||||
return "4K (2160p)"
|
||||
}
|
||||
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(quality) - \(instance?.description ?? "")"
|
||||
let formatString = format == .unknown ? "" : " (\(format.rawValue))"
|
||||
return "\(quality)\(formatString) - \(instance?.description ?? "")"
|
||||
}
|
||||
|
||||
var assets: [AVURLAsset] {
|
||||
|
||||
@@ -17,7 +17,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
var genre: String?
|
||||
|
||||
// index used when in the Playlist
|
||||
let indexID: String?
|
||||
var indexID: String?
|
||||
|
||||
var live: Bool
|
||||
var upcoming: Bool
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import CoreData
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
@objc(Watch)
|
||||
final class Watch: NSManagedObject, Identifiable {
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
}
|
||||
|
||||
extension Watch {
|
||||
@@ -45,4 +47,15 @@ extension Watch {
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: watchedAt, relativeTo: Date())
|
||||
}
|
||||
|
||||
var timeToRestart: CMTime? {
|
||||
finished ? nil : saveHistory ? .secondsInDefaultTimescale(stoppedAt) : nil
|
||||
}
|
||||
|
||||
var video: Video {
|
||||
Video(
|
||||
videoID: videoID, title: "", author: "",
|
||||
length: 0, published: "", views: -1, channel: Channel(id: "", name: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
24
README.md
24
README.md
@@ -1,4 +1,7 @@
|
||||
<div align="center">
|
||||
<h3><strong>📣 <a href="https://yattee.stream/beta">TestFlight beta</a> now available 📣 </strong></h3>
|
||||
<hr />
|
||||
|
||||
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
|
||||
<h1>Yattee</h1>
|
||||
<p>Alternative YouTube frontend for iOS, tvOS and macOS<br />built with <a href="https://github.com/iv-org/invidious">Invidious</a> and <a href="https://github.com/TeamPiped/Piped">Piped</a></p>
|
||||
@@ -19,31 +22,32 @@
|
||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||
* Stream quality selection
|
||||
|
||||
### Features in alpha testing for iOS and macOS
|
||||
### Features in development
|
||||
* New player component with custom controls, gestures and support for 4K playback
|
||||
|
||||
You can leave your feedback in [discussion on v1.4 release](https://github.com/yattee/yattee/discussions/75).
|
||||
You can leave your feedback in [discussion on v1.4.alpha.4 release](https://github.com/yattee/yattee/discussions/132) or join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat. Thanks!
|
||||
|
||||
### Availability
|
||||
|| Invidious | Piped |
|
||||
| - | - | - |
|
||||
| User Accounts | ✅ | ✅ |
|
||||
| Subscriptions | ✅ | ✅ |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | ✅ |
|
||||
| Trending | ✅ | ✅ |
|
||||
| Channels | ✅ | ✅ |
|
||||
| Channel Playlists | ✅ | ✅ |
|
||||
| Search | ✅ | ✅ |
|
||||
| Search Suggestions | ✅ | ✅ |
|
||||
| Search Filters | ✅ | 🔴 |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| Subtitles | 🔴 | ✅ |
|
||||
| Comments | 🔴 | ✅ |
|
||||
|
||||
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
|
||||
|
||||
## Documentation
|
||||
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
* [Installation](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
* [Building](https://github.com/yattee/yattee/wiki/Building-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)
|
||||
@@ -53,10 +57,16 @@ You can browse and use accounts from one app and play videos with another (for e
|
||||
## Contributing
|
||||
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
|
||||
|
||||
## License and Liability
|
||||
Use [building instructions](https://github.com/yattee/yattee/wiki/Building-instructions) or
|
||||
Join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat if you need an advice or want to discuss the project.
|
||||
|
||||
## License
|
||||
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
|
||||
Contributors take no responsibility for the use of the tool (Point 16. of the license). We strongly recommend you abide by the valid official regulations in your country. Furthermore, we refuse liability for any inappropriate use of the tool, such as downloading materials without proper consent.
|
||||
|
||||
## Disclaimer
|
||||
The Yattee project and its contents are not affiliated with, funded, authorized, endorsed by, or in any way accociated with YouTube, Google LLC or any of its affiliates and subsidaries. The official YouTube website can be found at www.youtube.com.
|
||||
|
||||
Any trademark, service mark, trade name, or other intellectual property rights used in the Yattee project are owned by the respective owners.
|
||||
|
||||
This tool is an open source software built for learning and research purposes.
|
||||
|
||||
@@ -23,13 +23,9 @@ extension Defaults.Keys {
|
||||
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
||||
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
||||
|
||||
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
||||
|
||||
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"))
|
||||
])
|
||||
|
||||
@@ -43,12 +39,12 @@ extension Defaults.Keys {
|
||||
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
|
||||
static let roundedThumbnails = Key<Bool>("roundedThumbnails", default: true)
|
||||
|
||||
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
|
||||
static let quality = Key<ResolutionSetting>("quality", default: .best)
|
||||
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 showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
|
||||
static let showChannelSubscribers = Key<Bool>("showChannelSubscribers", default: true)
|
||||
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
|
||||
#if !os(tvOS)
|
||||
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
|
||||
@@ -87,18 +83,31 @@ extension Defaults.Keys {
|
||||
#if os(iOS)
|
||||
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
static let lockLandscapeOnRotation = Key<Bool>("lockLandscapeOnRotation", default: false)
|
||||
static let lockLandscapeWhenEnteringFullscreen = Key<Bool>("lockLandscapeWhenEnteringFullscreen", default: false)
|
||||
static let lockOrientationInFullScreen = Key<Bool>("lockOrientationInFullScreen", default: false)
|
||||
#endif
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
case best, hd720p, sd480p, sd360p, sd240p, sd144p
|
||||
case best
|
||||
case hd4320p60
|
||||
case hd4320p
|
||||
case hd2160p60
|
||||
case hd2160p
|
||||
case hd1440p60
|
||||
case hd1440p
|
||||
case hd1080p60
|
||||
case hd1080p
|
||||
case hd720p60
|
||||
case hd720p
|
||||
case sd480p
|
||||
case sd360p
|
||||
case sd240p
|
||||
case sd144p
|
||||
|
||||
var value: Stream.Resolution {
|
||||
switch self {
|
||||
case .best:
|
||||
return .hd720p
|
||||
return .hd4320p60
|
||||
default:
|
||||
return Stream.Resolution(rawValue: rawValue)!
|
||||
}
|
||||
@@ -108,6 +117,14 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
switch self {
|
||||
case .best:
|
||||
return "Best available quality"
|
||||
case .hd4320p60:
|
||||
return "8K, 60fps"
|
||||
case .hd4320p:
|
||||
return "8K"
|
||||
case .hd2160p60:
|
||||
return "4K, 60fps"
|
||||
case .hd2160p:
|
||||
return "4K"
|
||||
default:
|
||||
return value.name
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private struct InNavigationViewKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
private struct InChannelViewKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
@@ -29,18 +25,17 @@ private struct CurrentPlaylistID: EnvironmentKey {
|
||||
static let defaultValue: String? = nil
|
||||
}
|
||||
|
||||
typealias LoadMoreContentHandlerType = () -> Void
|
||||
|
||||
private struct LoadMoreContentHandler: EnvironmentKey {
|
||||
static let defaultValue: LoadMoreContentHandlerType = {}
|
||||
}
|
||||
|
||||
typealias LoadMoreContentHandlerType = () -> Void
|
||||
private struct ScrollViewBottomPaddingKey: EnvironmentKey {
|
||||
static let defaultValue: Double = 30
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var inNavigationView: Bool {
|
||||
get { self[InNavigationViewKey.self] }
|
||||
set { self[InNavigationViewKey.self] = newValue }
|
||||
}
|
||||
|
||||
var inChannelView: Bool {
|
||||
get { self[InChannelViewKey.self] }
|
||||
set { self[InChannelViewKey.self] = newValue }
|
||||
@@ -70,4 +65,9 @@ extension EnvironmentValues {
|
||||
get { self[LoadMoreContentHandler.self] }
|
||||
set { self[LoadMoreContentHandler.self] = newValue }
|
||||
}
|
||||
|
||||
var scrollViewBottomPadding: Double {
|
||||
get { self[ScrollViewBottomPaddingKey.self] }
|
||||
set { self[ScrollViewBottomPaddingKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ struct FavoritesView: View {
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
if !accounts.current.isNil {
|
||||
#if os(tvOS)
|
||||
@@ -39,6 +39,7 @@ struct FavoritesView: View {
|
||||
.padding(.top, item == first && RefreshControl.navigationBarTitleDisplayMode == .inline ? 10 : 0)
|
||||
#endif
|
||||
}
|
||||
Color.clear.padding(.bottom, 30)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct AppSidebarNavigation: View {
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@@ -49,10 +50,10 @@ struct AppSidebarNavigation: View {
|
||||
.frame(minWidth: sidebarMinWidth)
|
||||
|
||||
VStack {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Image(systemName: "play.tv")
|
||||
Image(systemName: "4k.tv")
|
||||
.renderingMode(.original)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.accentColor)
|
||||
@@ -62,23 +63,6 @@ struct AppSidebarNavigation: View {
|
||||
}
|
||||
}
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
#if os(iOS)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
|
||||
@@ -11,9 +11,7 @@ struct AppSidebarPlaylists: View {
|
||||
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
|
||||
LazyView(PlaylistVideosView(playlist))
|
||||
} label: {
|
||||
Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
playlistLabel(playlist)
|
||||
}
|
||||
.id(playlist.id)
|
||||
.contextMenu {
|
||||
@@ -34,6 +32,18 @@ struct AppSidebarPlaylists: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func playlistLabel(_ playlist: Playlist) -> some View {
|
||||
let label = Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||
|
||||
if player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
label
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
var newPlaylistButton: some View {
|
||||
Button(action: { navigation.presentNewPlaylistForm() }) {
|
||||
Label("New Playlist", systemImage: "plus.circle")
|
||||
|
||||
@@ -7,6 +7,7 @@ struct AppTabNavigation: View {
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@@ -42,51 +43,9 @@ struct AppTabNavigation: View {
|
||||
searchNavigationView
|
||||
}
|
||||
.id(accounts.current?.id ?? "")
|
||||
.overlay(playlistView)
|
||||
.overlay(channelView)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannel) {
|
||||
if let channel = recents.presentedChannel {
|
||||
NavigationView {
|
||||
ChannelVideosView(channel: channel)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylist) {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
NavigationView {
|
||||
ChannelPlaylistView(playlist: playlist)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var favoritesNavigationView: some View {
|
||||
@@ -95,7 +54,7 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
Label("Favorites", systemImage: "heart.fill")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.tag(TabSelection.favorites)
|
||||
@@ -129,7 +88,7 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Popular", systemImage: "arrow.up.right.circle")
|
||||
Label("Popular", systemImage: "arrow.up.right.circle.fill")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
.tag(TabSelection.popular)
|
||||
@@ -141,7 +100,7 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Trending", systemImage: "chart.bar")
|
||||
Label("Trending", systemImage: "chart.bar.fill")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
.tag(TabSelection.trending)
|
||||
@@ -171,15 +130,6 @@ struct AppTabNavigation: View {
|
||||
.tag(TabSelection.search)
|
||||
}
|
||||
|
||||
private var playerNavigationLink: some View {
|
||||
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
|
||||
videoPlayer
|
||||
.environment(\.inNavigationView, true)
|
||||
}) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
@@ -187,6 +137,7 @@ struct AppTabNavigation: View {
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
@@ -208,4 +159,26 @@ struct AppTabNavigation: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var channelView: some View {
|
||||
ChannelVideosView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
|
||||
private var playlistView: some View {
|
||||
ChannelPlaylistView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFAudio
|
||||
import Defaults
|
||||
import MediaPlayer
|
||||
import SDWebImage
|
||||
import SDWebImagePINPlugin
|
||||
import SDWebImageWebPCoder
|
||||
@@ -12,6 +13,7 @@ struct ContentView: View {
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@@ -24,6 +26,8 @@ struct ContentView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
@@ -55,50 +59,54 @@ struct ContentView: View {
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
// iOS 14 has problem with multiple sheets in one view
|
||||
// but it's ok when it's in background
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingWelcomeScreen) {
|
||||
WelcomeScreen()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
}
|
||||
)
|
||||
#if !os(tvOS)
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
}
|
||||
)
|
||||
#if os(iOS)
|
||||
.overlay(videoPlayer)
|
||||
#endif
|
||||
.alert(isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to unsubscribe from \(navigation.channelToUnsubscribe.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(navigation.channelToUnsubscribe.id)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
|
||||
// iOS 14 has problem with multiple sheets in one view
|
||||
// but it's ok when it's in background
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingWelcomeScreen) {
|
||||
WelcomeScreen()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
}
|
||||
)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.alert(isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to unsubscribe from \(navigation.channelToUnsubscribe.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(navigation.channelToUnsubscribe.id)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func configure() {
|
||||
@@ -106,7 +114,7 @@ struct ContentView: View {
|
||||
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
setupNowPlayingInfoCenter()
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
@@ -135,9 +143,11 @@ struct ContentView: View {
|
||||
menu.accounts = accounts
|
||||
menu.navigation = navigation
|
||||
menu.player = player
|
||||
playerControls.player = player
|
||||
|
||||
player.accounts = accounts
|
||||
player.comments = comments
|
||||
player.controls = playerControls
|
||||
|
||||
if !accounts.current.isNil {
|
||||
player.restoreQueue()
|
||||
@@ -161,6 +171,56 @@ struct ContentView: View {
|
||||
playlists.load()
|
||||
}
|
||||
|
||||
func setupNowPlayingInfoCenter() {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
#endif
|
||||
|
||||
MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
|
||||
player.play()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in
|
||||
player.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
|
||||
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
|
||||
|
||||
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { remoteEvent in
|
||||
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent
|
||||
else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
player.backend.seek(to: event.positionTime)
|
||||
|
||||
return .success
|
||||
}
|
||||
|
||||
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
|
||||
skipForwardCommand.isEnabled = true
|
||||
skipForwardCommand.preferredIntervals = [10]
|
||||
|
||||
skipForwardCommand.addTarget { _ in
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
return .success
|
||||
}
|
||||
|
||||
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
|
||||
skipBackwardCommand.isEnabled = true
|
||||
skipBackwardCommand.preferredIntervals = [10]
|
||||
|
||||
skipBackwardCommand.addTarget { _ in
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
func openWelcomeScreenIfAccountEmpty() {
|
||||
guard Defaults[.instances].isEmpty else {
|
||||
return
|
||||
@@ -168,6 +228,21 @@ struct ContentView: View {
|
||||
|
||||
navigation.presentingWelcomeScreen = true
|
||||
}
|
||||
|
||||
var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
|
||||
14
Shared/Player/AppleAVPlayerView.swift
Normal file
14
Shared/Player/AppleAVPlayerView.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import AVKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AppleAVPlayerView: UIViewRepresentable {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
func makeUIView(context _: Context) -> some UIView {
|
||||
player.playerLayerView = PlayerLayerView(frame: .zero)
|
||||
return player.playerLayerView
|
||||
}
|
||||
|
||||
func updateUIView(_: UIViewType, context _: Context) {}
|
||||
}
|
||||
203
Shared/Player/AppleAVPlayerViewController.swift
Normal file
203
Shared/Player/AppleAVPlayerViewController.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import AVKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
final class AppleAVPlayerViewController: UIViewController {
|
||||
var playerLoaded = false
|
||||
var commentsModel: CommentsModel!
|
||||
var navigationModel: NavigationModel!
|
||||
var playerModel: PlayerModel!
|
||||
var subscriptionsModel: SubscriptionsModel!
|
||||
var playerView = AVPlayerViewController()
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
#if !os(tvOS)
|
||||
var aspectRatio: Double? {
|
||||
let ratio = Double(playerView.videoBounds.width) / Double(playerView.videoBounds.height)
|
||||
|
||||
guard ratio.isFinite else {
|
||||
return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
return [ratio, 1.0].max()!
|
||||
}
|
||||
#endif
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
loadPlayer()
|
||||
|
||||
#if os(tvOS)
|
||||
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.avPlayerBackend.controller = self
|
||||
playerView.player = playerModel.avPlayerBackend.avPlayer
|
||||
playerView.allowsPictureInPicturePlayback = true
|
||||
playerView.showsPlaybackControls = false
|
||||
#if os(iOS)
|
||||
if #available(iOS 14.2, *) {
|
||||
playerView.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
#endif
|
||||
playerView.delegate = self
|
||||
|
||||
#if os(tvOS)
|
||||
var infoViewControllers = [UIHostingController<AnyView>]()
|
||||
if CommentsModel.enabled {
|
||||
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
|
||||
}
|
||||
|
||||
var queueSections = [NowPlayingView.ViewSection.playingNext]
|
||||
if Defaults[.showHistoryInPlayer] {
|
||||
queueSections.append(.playedPreviously)
|
||||
}
|
||||
|
||||
infoViewControllers.append(contentsOf: [
|
||||
infoViewController([.related], title: "Related"),
|
||||
infoViewController(queueSections, title: "Queue")
|
||||
])
|
||||
|
||||
playerView.customInfoViewControllers = infoViewControllers
|
||||
#else
|
||||
embedViewController()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
func infoViewController(
|
||||
_ sections: [NowPlayingView.ViewSection],
|
||||
title: String
|
||||
) -> UIHostingController<AnyView> {
|
||||
let controller = UIHostingController(rootView:
|
||||
AnyView(
|
||||
NowPlayingView(sections: sections, inInfoViewController: true)
|
||||
.frame(maxHeight: 600)
|
||||
.environmentObject(commentsModel)
|
||||
.environmentObject(playerModel)
|
||||
.environmentObject(subscriptionsModel)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
)
|
||||
)
|
||||
|
||||
controller.title = title
|
||||
|
||||
return controller
|
||||
}
|
||||
#else
|
||||
func embedViewController() {
|
||||
playerView.view.frame = view.bounds
|
||||
|
||||
addChild(playerView)
|
||||
view.addSubview(playerView.view)
|
||||
|
||||
playerView.didMove(toParent: self)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension AppleAVPlayerViewController: AVPlayerViewControllerDelegate {
|
||||
func playerViewControllerShouldDismiss(_: AVPlayerViewController) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {
|
||||
if Defaults[.pauseOnHidingPlayer] {
|
||||
playerModel.pause()
|
||||
}
|
||||
dismiss(animated: false)
|
||||
}
|
||||
|
||||
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator context: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
#if os(iOS)
|
||||
if !context.isCancelled, Defaults[.lockOrientationInFullScreen] {
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
let wasPlaying = playerModel.isPlaying
|
||||
coordinator.animate(alongsideTransition: nil) { context in
|
||||
#if os(iOS)
|
||||
if wasPlaying {
|
||||
self.playerModel.play()
|
||||
}
|
||||
#endif
|
||||
if !context.isCancelled {
|
||||
#if os(iOS)
|
||||
self.playerModel.lockedOrientation = nil
|
||||
if Defaults[.enterFullscreenInLandscape] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
self.playerModel.play()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.playerModel.show()
|
||||
self.playerModel.setNeedsDrawing(true)
|
||||
|
||||
#if os(tvOS)
|
||||
if self.playerModel.playingInPictureInPicture {
|
||||
self.present(self.playerView, animated: false) {
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
#else
|
||||
completionHandler(true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {
|
||||
playerModel.playingInPictureInPicture = true
|
||||
}
|
||||
|
||||
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {
|
||||
playerModel.playingInPictureInPicture = false
|
||||
}
|
||||
}
|
||||
412
Shared/Player/Controls/PlayerControls.swift
Normal file
412
Shared/Player/Controls/PlayerControls.swift
Normal file
@@ -0,0 +1,412 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerControls: View {
|
||||
static let animation = Animation.easeInOut(duration: 0.2)
|
||||
|
||||
private var player: PlayerModel!
|
||||
|
||||
@EnvironmentObject<PlayerControlsModel> private var model
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
#elseif os(tvOS)
|
||||
enum Field: Hashable {
|
||||
case play
|
||||
case backward
|
||||
case forward
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
#endif
|
||||
|
||||
init(player: PlayerModel) {
|
||||
self.player = player
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
HStack {
|
||||
statusBar
|
||||
.padding(3)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
buttonsBar
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
mediumButtonsBar
|
||||
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
timeline
|
||||
.offset(y: 10)
|
||||
.zIndex(1)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
bottomBar
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
.opacity(model.presentingControls ? 1 : 0)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.onChange(of: model.presentingControls) { _ in
|
||||
if model.presentingControls {
|
||||
focusedField = .play
|
||||
}
|
||||
}
|
||||
.onChange(of: focusedField) { _ in
|
||||
model.resetTimer()
|
||||
}
|
||||
#else
|
||||
.background(PlayerGestures())
|
||||
#endif
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
var timeline: some View {
|
||||
TimelineView(duration: durationBinding, current: currentTimeBinding, cornerRadius: 0)
|
||||
}
|
||||
|
||||
var durationBinding: Binding<Double> {
|
||||
Binding<Double>(
|
||||
get: { model.duration.seconds },
|
||||
set: { value in model.duration = .secondsInDefaultTimescale(value) }
|
||||
)
|
||||
}
|
||||
|
||||
var currentTimeBinding: Binding<Double> {
|
||||
Binding<Double>(
|
||||
get: { model.currentTime.seconds },
|
||||
set: { value in model.currentTime = .secondsInDefaultTimescale(value) }
|
||||
)
|
||||
}
|
||||
|
||||
var statusBar: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(playbackStatus)
|
||||
|
||||
Text("•")
|
||||
|
||||
#if !os(tvOS)
|
||||
ToggleBackendButton()
|
||||
Text("•")
|
||||
|
||||
StreamControl()
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 300)
|
||||
#endif
|
||||
#else
|
||||
Text(player.stream?.description ?? "")
|
||||
#endif
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.padding(.trailing, 4)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
|
||||
private var hidePlayerButton: some View {
|
||||
button("Hide", systemImage: "chevron.down") {
|
||||
player.hide()
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var playbackStatus: String {
|
||||
if player.live {
|
||||
return "LIVE"
|
||||
}
|
||||
|
||||
guard !player.isLoadingVideo else {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
let videoLengthAtRate = (player.currentVideo?.length ?? 0) / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - (player.time?.seconds ?? 0)
|
||||
|
||||
if remainingSeconds < 60 {
|
||||
return "less than a minute"
|
||||
}
|
||||
|
||||
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
||||
|
||||
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
|
||||
}
|
||||
|
||||
private func formattedTimeFinishAt(_ date: Date) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.dateStyle = .none
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
var buttonsBar: some View {
|
||||
HStack(spacing: 20) {
|
||||
#if !os(tvOS)
|
||||
#if os(iOS)
|
||||
hidePlayerButton
|
||||
#endif
|
||||
|
||||
fullscreenButton
|
||||
#if os(iOS)
|
||||
pipButton
|
||||
#endif
|
||||
rateButton
|
||||
|
||||
closeVideoButton
|
||||
|
||||
Spacer()
|
||||
#endif
|
||||
// button("Music Mode", systemImage: "music.note")
|
||||
}
|
||||
}
|
||||
|
||||
var fullscreenButton: some View {
|
||||
button(
|
||||
"Fullscreen",
|
||||
systemImage: fullScreenLayout ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
|
||||
) {
|
||||
player.toggleFullscreen(fullScreenLayout)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(fullScreenLayout ? .cancelAction : .defaultAction)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var rateButton: some View {
|
||||
#if os(macOS)
|
||||
ratePicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 70)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
ratePicker
|
||||
.frame(width: 45, height: 30)
|
||||
#if os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
} label: {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 50, height: 30)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#endif
|
||||
}
|
||||
|
||||
private var closeVideoButton: some View {
|
||||
button("Close", systemImage: "xmark") {
|
||||
player.pause()
|
||||
|
||||
player.hide()
|
||||
player.closePiP()
|
||||
|
||||
var delay = 0.2
|
||||
#if os(macOS)
|
||||
delay = 0.0
|
||||
#endif
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
player.closeCurrentItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ratePicker: some View {
|
||||
Picker("Rate", selection: rateBinding) {
|
||||
ForEach(PlayerModel.availableRates, id: \.self) { rate in
|
||||
Text(player.rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
private var rateBinding: Binding<Float> {
|
||||
.init(get: { player.currentRate }, set: { rate in player.currentRate = rate })
|
||||
}
|
||||
|
||||
private var pipButton: some View {
|
||||
button("PiP", systemImage: "pip") {
|
||||
model.startPiP()
|
||||
}
|
||||
}
|
||||
|
||||
var mediumButtonsBar: some View {
|
||||
HStack {
|
||||
#if !os(tvOS)
|
||||
restartVideoButton
|
||||
.padding(.trailing, 15)
|
||||
|
||||
button("Seek Backward", systemImage: "gobackward.10", size: 30, cornerRadius: 5) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .backward)
|
||||
#else
|
||||
.keyboardShortcut("k", modifiers: [])
|
||||
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
button(
|
||||
model.isPlaying ? "Pause" : "Play",
|
||||
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
||||
size: 30, cornerRadius: 5
|
||||
) {
|
||||
player.backend.togglePlay()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .play)
|
||||
#else
|
||||
.keyboardShortcut("p")
|
||||
.keyboardShortcut(.space)
|
||||
#endif
|
||||
.disabled(model.isLoadingVideo)
|
||||
|
||||
Spacer()
|
||||
|
||||
#if !os(tvOS)
|
||||
button("Seek Forward", systemImage: "goforward.10", size: 30, cornerRadius: 5) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .forward)
|
||||
#else
|
||||
.keyboardShortcut("l", modifiers: [])
|
||||
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
|
||||
#endif
|
||||
|
||||
advanceToNextItemButton
|
||||
.padding(.leading, 15)
|
||||
#endif
|
||||
}
|
||||
.font(.system(size: 20))
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var restartVideoButton: some View {
|
||||
button("Restart video", systemImage: "backward.end.fill", size: 30, cornerRadius: 5) {
|
||||
player.backend.seek(to: 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
private var advanceToNextItemButton: some View {
|
||||
button("Next", systemImage: "forward.fill", size: 30, cornerRadius: 5) {
|
||||
player.advanceToNextItem()
|
||||
}
|
||||
.disabled(player.queue.isEmpty)
|
||||
}
|
||||
|
||||
var bottomBar: some View {
|
||||
HStack {
|
||||
Text(model.playbackTime)
|
||||
}
|
||||
.font(.system(size: 15))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 3)
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
func button(
|
||||
_ label: String,
|
||||
systemImage: String = "arrow.up.left.and.arrow.down.right",
|
||||
size: Double = 30,
|
||||
cornerRadius: Double = 3,
|
||||
action: @escaping () -> Void = {}
|
||||
) -> some View {
|
||||
Button {
|
||||
action()
|
||||
model.resetTimer()
|
||||
} label: {
|
||||
Label(label, systemImage: systemImage)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: size, height: size)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
var fullScreenLayout: Bool {
|
||||
#if os(iOS)
|
||||
model.playingFullscreen || verticalSizeClass == .compact
|
||||
#else
|
||||
model.playingFullscreen
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerControls_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let model = PlayerControlsModel()
|
||||
model.presentingControls = true
|
||||
model.currentTime = .secondsInDefaultTimescale(0)
|
||||
model.duration = .secondsInDefaultTimescale(120)
|
||||
|
||||
let view = ZStack {
|
||||
Color.gray
|
||||
|
||||
PlayerControls(player: PlayerModel())
|
||||
.injectFixtureEnvironmentObjects()
|
||||
.environmentObject(model)
|
||||
}
|
||||
|
||||
return Group {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
view.previewInterfaceOrientation(.landscapeLeft)
|
||||
} else {
|
||||
view
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Shared/Player/Controls/ToggleBackendButton.swift
Normal file
23
Shared/Player/Controls/ToggleBackendButton.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToggleBackendButton: View {
|
||||
@EnvironmentObject<PlayerControlsModel> private var controls
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
player.saveTime {
|
||||
player.changeActiveBackend(from: player.activeBackend, to: player.activeBackend.next())
|
||||
controls.resetTimer()
|
||||
}
|
||||
} label: {
|
||||
Text(player.activeBackend.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleBackendButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ToggleBackendButton()
|
||||
}
|
||||
}
|
||||
70
Shared/Player/MPV/MPVOGLView.swift
Normal file
70
Shared/Player/MPV/MPVOGLView.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import GLKit
|
||||
import Logging
|
||||
import OpenGLES
|
||||
|
||||
final class MPVOGLView: GLKView {
|
||||
private var logger = Logger(label: "stream.yattee.mpv.oglview")
|
||||
private var defaultFBO: GLint?
|
||||
|
||||
var mpvGL: UnsafeMutableRawPointer?
|
||||
var needsDrawing = true
|
||||
|
||||
override init(frame: CGRect) {
|
||||
guard let context = EAGLContext(api: .openGLES3) else {
|
||||
print("Failed to initialize OpenGLES 2.0 context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
logger.info("frame size: \(frame.width) x \(frame.height)")
|
||||
|
||||
super.init(frame: frame, context: context)
|
||||
contentMode = .redraw
|
||||
|
||||
EAGLContext.setCurrent(context)
|
||||
|
||||
drawableColorFormat = .RGBA8888
|
||||
drawableDepthFormat = .formatNone
|
||||
drawableStencilFormat = .formatNone
|
||||
|
||||
defaultFBO = -1
|
||||
isOpaque = false
|
||||
|
||||
fillBlack()
|
||||
}
|
||||
|
||||
func fillBlack() {
|
||||
glClearColor(0, 0, 0, 0)
|
||||
glClear(UInt32(GL_COLOR_BUFFER_BIT))
|
||||
}
|
||||
|
||||
override func draw(_: CGRect) {
|
||||
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO!)
|
||||
|
||||
var dims: [GLint] = [0, 0, 0, 0]
|
||||
glGetIntegerv(GLenum(GL_VIEWPORT), &dims)
|
||||
|
||||
if mpvGL != nil {
|
||||
var data = mpv_opengl_fbo(
|
||||
fbo: Int32(defaultFBO!),
|
||||
w: Int32(dims[2]),
|
||||
h: Int32(dims[3]),
|
||||
internal_format: 0
|
||||
)
|
||||
var flip: CInt = 1
|
||||
withUnsafeMutablePointer(to: &flip) { flip in
|
||||
withUnsafeMutablePointer(to: &data) { data in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: data),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flip),
|
||||
mpv_render_param()
|
||||
]
|
||||
mpv_render_context_render(OpaquePointer(mpvGL), ¶ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
}
|
||||
}
|
||||
26
Shared/Player/MPV/MPVViewController.swift
Normal file
26
Shared/Player/MPV/MPVViewController.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import UIKit
|
||||
|
||||
final class MPVViewController: UIViewController {
|
||||
var client: MPVClient!
|
||||
var glView: MPVOGLView!
|
||||
|
||||
init() {
|
||||
client = MPVClient()
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.loadView()
|
||||
|
||||
client.create(frame: view.frame)
|
||||
glView = client.glView
|
||||
|
||||
view.addSubview(glView)
|
||||
|
||||
super.viewDidLoad()
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackBar: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
#if !os(macOS)
|
||||
closeButton
|
||||
#endif
|
||||
|
||||
if player.currentItem != nil {
|
||||
HStack {
|
||||
Text(playbackStatus)
|
||||
Text("•")
|
||||
rateMenu
|
||||
}
|
||||
.font(.caption2)
|
||||
#if os(macOS)
|
||||
.padding(.leading, 4)
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if !player.lastSkipped.isNil {
|
||||
restoreLastSkippedSegmentButton
|
||||
}
|
||||
if player.live {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
} else if player.isLoadingAvailableStreams || player.isLoadingStream {
|
||||
Image(systemName: "bolt.horizontal.fill")
|
||||
} else if !player.playerError.isNil {
|
||||
Button {
|
||||
player.presentingErrorDetails = true
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
streamControl
|
||||
.disabled(player.isLoadingAvailableStreams)
|
||||
.frame(alignment: .trailing)
|
||||
.onChange(of: player.streamSelection) { selection in
|
||||
guard !selection.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
player.upgradeToStream(selection!)
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 180)
|
||||
#endif
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.font(.caption2)
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.foregroundColor(colorScheme == .dark ? .gray : .black)
|
||||
.alert(isPresented: $player.presentingErrorDetails) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(player.playerError?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 20)
|
||||
.padding(4)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button {
|
||||
player.hide()
|
||||
} label: {
|
||||
Label(
|
||||
"Close",
|
||||
systemImage: inNavigationView ? "chevron.backward.circle.fill" : "chevron.down.circle.fill"
|
||||
)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.accessibilityLabel(Text("Close"))
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(.gray)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
|
||||
private var playbackStatus: String {
|
||||
if player.live {
|
||||
return "LIVE"
|
||||
}
|
||||
|
||||
guard !player.isLoadingVideo else {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
let videoLengthAtRate = player.currentVideo!.length / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - player.time!.seconds
|
||||
|
||||
if remainingSeconds < 60 {
|
||||
return "less than a minute"
|
||||
}
|
||||
|
||||
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
||||
|
||||
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
|
||||
}
|
||||
|
||||
private func formattedTimeFinishAt(_ date: Date) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.dateStyle = .none
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private var rateMenu: some View {
|
||||
#if os(macOS)
|
||||
ratePicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 70)
|
||||
#else
|
||||
Menu {
|
||||
ratePicker
|
||||
} label: {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
private var ratePicker: some View {
|
||||
Picker("", selection: $player.currentRate) {
|
||||
ForEach(PlayerModel.availableRates, id: \.self) { rate in
|
||||
Text(player.rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var restoreLastSkippedSegmentButton: some View {
|
||||
HStack(spacing: 4) {
|
||||
Button {
|
||||
player.restoreLastSkippedSegment()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.uturn.left.circle")
|
||||
Text(player.lastSkipped!.title())
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text("•")
|
||||
}
|
||||
}
|
||||
|
||||
private var streamControl: some View {
|
||||
#if os(macOS)
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
|
||||
Section(header: Text(instance.longDescription)) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.quality).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
Menu {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.description).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(player.streamSelection?.quality ?? "")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func availableStreamsForInstance(_ instance: Instance) -> [Stream.Kind: [Stream]] {
|
||||
let streams = player.availableStreamsSorted.filter { $0.instance == instance }
|
||||
|
||||
return Dictionary(grouping: streams, by: \.kind!)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlaybackBar()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
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?
|
||||
|
||||
init(controller: PlayerViewController? = nil) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
func makeUIViewController(context _: Context) -> PlayerViewController {
|
||||
if self.controller != nil {
|
||||
return self.controller!
|
||||
}
|
||||
|
||||
let controller = PlayerViewController()
|
||||
|
||||
controller.commentsModel = comments
|
||||
controller.navigationModel = navigation
|
||||
controller.playerModel = player
|
||||
controller.subscriptionsModel = subscriptions
|
||||
player.controller = controller
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_: PlayerViewController, context _: Context) {
|
||||
player.rebuildTVMenu()
|
||||
}
|
||||
}
|
||||
64
Shared/Player/PlayerGestures.swift
Normal file
64
Shared/Player/PlayerGestures.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerGestures: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var model
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
singleTapAction: {
|
||||
model.toggle()
|
||||
},
|
||||
doubleTapAction: {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
},
|
||||
anyTapAction: {
|
||||
model.update()
|
||||
}
|
||||
)
|
||||
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
singleTapAction: {
|
||||
model.toggle()
|
||||
},
|
||||
doubleTapAction: {
|
||||
player.backend.togglePlay()
|
||||
},
|
||||
anyTapAction: {
|
||||
model.update()
|
||||
}
|
||||
)
|
||||
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
singleTapAction: {
|
||||
model.toggle()
|
||||
},
|
||||
doubleTapAction: {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
},
|
||||
anyTapAction: {
|
||||
model.update()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var gestureRectangle: some View {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerGestures_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlayerGestures()
|
||||
}
|
||||
}
|
||||
23
Shared/Player/PlayerLayerView.swift
Normal file
23
Shared/Player/PlayerLayerView.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class PlayerLayerView: UIView {
|
||||
var playerLayer = AVPlayerLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
layer.addSublayer(playerLayer)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
@@ -11,15 +12,30 @@ struct PlayerQueueRow: View {
|
||||
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
|
||||
@FetchRequest private var watchRequest: FetchedResults<Watch>
|
||||
|
||||
init(item: PlayerQueueItem, history: Bool = false, fullScreen: Binding<Bool> = .constant(false)) {
|
||||
self.item = item
|
||||
self.history = history
|
||||
_fullScreen = fullScreen
|
||||
_watchRequest = FetchRequest<Watch>(
|
||||
entity: Watch.entity(),
|
||||
sortDescriptors: [],
|
||||
predicate: NSPredicate(format: "videoID = %@", item.videoID)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Button {
|
||||
player.prepareCurrentItemForHistory()
|
||||
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
|
||||
|
||||
if history {
|
||||
player.playHistory(item)
|
||||
player.playHistory(item, at: watchStoppedAt)
|
||||
} else {
|
||||
player.advanceToItem(item)
|
||||
player.advanceToItem(item, at: watchStoppedAt)
|
||||
}
|
||||
|
||||
if fullScreen {
|
||||
@@ -32,9 +48,21 @@ struct PlayerQueueRow: View {
|
||||
player.closePiP()
|
||||
}
|
||||
} label: {
|
||||
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
|
||||
VideoBanner(video: item.video, playbackTime: watchStoppedAt, videoDuration: watch?.videoDuration)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var watch: Watch? {
|
||||
watchRequest.first
|
||||
}
|
||||
|
||||
private var watchStoppedAt: CMTime? {
|
||||
guard let seconds = watch?.stoppedAt else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .secondsInDefaultTimescale(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
79
Shared/Player/StreamControl.swift
Normal file
79
Shared/Player/StreamControl.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StreamControl: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
|
||||
Section(header: Text(instance.longDescription)) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.description).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(player.isLoadingAvailableStreams)
|
||||
|
||||
#else
|
||||
Menu {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.description).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(player.streamSelection?.quality ?? "no playable streams")
|
||||
}
|
||||
.disabled(player.isLoadingAvailableStreams)
|
||||
#endif
|
||||
}
|
||||
|
||||
.transaction { t in t.animation = .none }
|
||||
.onChange(of: player.streamSelection) { selection in
|
||||
guard !selection.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
player.upgradeToStream(selection!)
|
||||
}
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
|
||||
private func availableStreamsForInstance(_ instance: Instance) -> [Stream.Kind: [Stream]] {
|
||||
let streams = player.availableStreamsSorted.filter { $0.instance == instance }.filter { player.backend.canPlay($0) }
|
||||
|
||||
return Dictionary(grouping: streams, by: \.kind!)
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamControl_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
StreamControl()
|
||||
}
|
||||
}
|
||||
69
Shared/Player/TapRecognizerViewModifier.swift
Normal file
69
Shared/Player/TapRecognizerViewModifier.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TapRecognizerViewModifier: ViewModifier {
|
||||
@State private var singleTapIsTaped: Bool = .init()
|
||||
|
||||
var tapSensitivity: Double
|
||||
var singleTapAction: () -> Void
|
||||
var doubleTapAction: () -> Void
|
||||
var anyTapAction: () -> Void
|
||||
|
||||
init(
|
||||
tapSensitivity: Double,
|
||||
singleTapAction: @escaping () -> Void,
|
||||
doubleTapAction: @escaping () -> Void,
|
||||
anyTapAction: @escaping () -> Void
|
||||
) {
|
||||
self.tapSensitivity = tapSensitivity
|
||||
self.singleTapAction = singleTapAction
|
||||
self.doubleTapAction = doubleTapAction
|
||||
self.anyTapAction = anyTapAction
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.gesture(simultaneouslyGesture)
|
||||
}
|
||||
|
||||
private var singleTapGesture: some Gesture {
|
||||
TapGesture(count: 1).onEnded {
|
||||
anyTapAction()
|
||||
|
||||
singleTapIsTaped = true
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + tapSensitivity) {
|
||||
if singleTapIsTaped {
|
||||
singleTapAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var doubleTapGesture: some Gesture {
|
||||
TapGesture(count: 2).onEnded {
|
||||
singleTapIsTaped = false
|
||||
doubleTapAction()
|
||||
}
|
||||
}
|
||||
|
||||
private var simultaneouslyGesture: some Gesture {
|
||||
singleTapGesture.simultaneously(with: doubleTapGesture)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func tapRecognizer(
|
||||
tapSensitivity: Double,
|
||||
singleTapAction: @escaping () -> Void,
|
||||
doubleTapAction: @escaping () -> Void,
|
||||
anyTapAction: @escaping () -> Void = {}
|
||||
) -> some View {
|
||||
modifier(
|
||||
TapRecognizerViewModifier(
|
||||
tapSensitivity: tapSensitivity,
|
||||
singleTapAction: singleTapAction,
|
||||
doubleTapAction: doubleTapAction,
|
||||
anyTapAction: anyTapAction
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
197
Shared/Player/TimelineView.swift
Normal file
197
Shared/Player/TimelineView.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineView: View {
|
||||
@Binding private var duration: Double
|
||||
@Binding private var current: Double
|
||||
|
||||
@State private var size = CGSize.zero
|
||||
@State private var dragging = false
|
||||
@State private var dragOffset: Double = 0
|
||||
@State private var draggedFrom: Double = 0
|
||||
|
||||
private var start: Double = 0.0
|
||||
private var height = 8.0
|
||||
|
||||
var cornerRadius: Double
|
||||
var thumbTooltipWidth: Double = 100
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var controls
|
||||
|
||||
init(duration: Binding<Double>, current: Binding<Double>, cornerRadius: Double = 10.0) {
|
||||
_duration = duration
|
||||
_current = current
|
||||
self.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Group {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxHeight: height)
|
||||
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.green)
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: current * oneUnitWidth)
|
||||
|
||||
segmentsLayers
|
||||
}
|
||||
|
||||
Circle()
|
||||
.strokeBorder(.gray, lineWidth: 1)
|
||||
.background(Circle().fill(dragging ? .gray : .white))
|
||||
.offset(x: thumbOffset)
|
||||
.foregroundColor(.red.opacity(0.6))
|
||||
.frame(maxHeight: height * 4)
|
||||
|
||||
#if !os(tvOS)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if !dragging {
|
||||
controls.removeTimer()
|
||||
draggedFrom = current
|
||||
}
|
||||
|
||||
dragging = true
|
||||
|
||||
let drag = value.translation.width
|
||||
let change = (drag / size.width) * units
|
||||
let changedCurrent = current + change
|
||||
|
||||
guard changedCurrent >= start, changedCurrent <= duration else {
|
||||
return
|
||||
}
|
||||
withAnimation(Animation.linear(duration: 0.2)) {
|
||||
dragOffset = drag
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
current = projectedValue
|
||||
|
||||
player.backend.seek(to: projectedValue)
|
||||
|
||||
dragging = false
|
||||
dragOffset = 0.0
|
||||
draggedFrom = 0.0
|
||||
controls.resetTimer()
|
||||
}
|
||||
)
|
||||
#endif
|
||||
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.frame(maxWidth: thumbTooltipWidth, maxHeight: 30)
|
||||
|
||||
Text(projectedValue.formattedAsPlaybackTime() ?? "--:--")
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.animation(.linear(duration: 0.1))
|
||||
.opacity(dragging ? 1 : 0)
|
||||
.offset(x: thumbTooltipOffset, y: -(height * 2) - 7)
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
self.size = proxy.size
|
||||
}
|
||||
.onChange(of: proxy.size) { size in
|
||||
self.size = size
|
||||
}
|
||||
})
|
||||
#if !os(tvOS)
|
||||
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
|
||||
let target = (value.location.x / size.width) * units
|
||||
current = target
|
||||
player.backend.seek(to: target)
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
var projectedValue: Double {
|
||||
let change = (dragOffset / size.width) * units
|
||||
let projected = draggedFrom + change
|
||||
return projected.isFinite ? (duration - projected < (0.01 * duration) ? duration : projected) : start
|
||||
}
|
||||
|
||||
var thumbOffset: Double {
|
||||
let offset = dragging ? (draggedThumbHorizontalOffset + dragOffset) : thumbHorizontalOffset
|
||||
return offset.isFinite ? offset : thumbLeadingOffset
|
||||
}
|
||||
|
||||
var thumbTooltipOffset: Double {
|
||||
let offset = (dragging ? ((current * oneUnitWidth) + dragOffset) : (current * oneUnitWidth)) - (thumbTooltipWidth / 2)
|
||||
|
||||
return offset.clamped(to: minThumbTooltipOffset ... maxThumbTooltipOffset)
|
||||
}
|
||||
|
||||
var minThumbTooltipOffset: Double = -10
|
||||
|
||||
var maxThumbTooltipOffset: Double {
|
||||
max(minThumbTooltipOffset, (units * oneUnitWidth) - thumbTooltipWidth + 10)
|
||||
}
|
||||
|
||||
var segmentsLayers: some View {
|
||||
ForEach(player.sponsorBlock.segments, id: \.uuid) { segment in
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.offset(x: segmentLayerHorizontalOffset(segment))
|
||||
.foregroundColor(.red)
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: segmentLayerWidth(segment))
|
||||
}
|
||||
}
|
||||
|
||||
func segmentLayerHorizontalOffset(_ segment: Segment) -> Double {
|
||||
segment.start * oneUnitWidth
|
||||
}
|
||||
|
||||
func segmentLayerWidth(_ segment: Segment) -> Double {
|
||||
let width = segment.duration * oneUnitWidth
|
||||
return width.isFinite ? width : thumbLeadingOffset
|
||||
}
|
||||
|
||||
var draggedThumbHorizontalOffset: Double {
|
||||
thumbLeadingOffset + (draggedFrom * oneUnitWidth)
|
||||
}
|
||||
|
||||
var thumbHorizontalOffset: Double {
|
||||
thumbLeadingOffset + (current * oneUnitWidth)
|
||||
}
|
||||
|
||||
var thumbLeadingOffset: Double {
|
||||
-(size.width / 2)
|
||||
}
|
||||
|
||||
var oneUnitWidth: Double {
|
||||
let one = size.width / units
|
||||
return one.isFinite ? one : 0
|
||||
}
|
||||
|
||||
var units: Double {
|
||||
duration - start
|
||||
}
|
||||
|
||||
func setCurrent(_ current: Double) {
|
||||
withAnimation {
|
||||
self.current = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 40) {
|
||||
TimelineView(duration: .constant(100), current: .constant(0))
|
||||
TimelineView(duration: .constant(100), current: .constant(1))
|
||||
TimelineView(duration: .constant(100), current: .constant(30))
|
||||
TimelineView(duration: .constant(100), current: .constant(50))
|
||||
TimelineView(duration: .constant(100), current: .constant(66))
|
||||
TimelineView(duration: .constant(100), current: .constant(90))
|
||||
TimelineView(duration: .constant(100), current: .constant(100))
|
||||
}
|
||||
.environmentObject(PlayerModel())
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ struct VideoDetails: View {
|
||||
@State private var currentPage = Page.info
|
||||
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@@ -30,7 +29,6 @@ struct VideoDetails: View {
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.showChannelSubscribers) private var showChannelSubscribers
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
|
||||
init(
|
||||
@@ -90,7 +88,7 @@ struct VideoDetails: View {
|
||||
if fullScreen {
|
||||
fullScreen = false
|
||||
} else {
|
||||
self.presentationMode.wrappedValue.dismiss()
|
||||
self.player.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,12 +96,8 @@ struct VideoDetails: View {
|
||||
|
||||
switch currentPage {
|
||||
case .info:
|
||||
if player.isLoadingVideo {
|
||||
PlaceholderProgressView()
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
detailsPage
|
||||
}
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
detailsPage
|
||||
}
|
||||
case .queue:
|
||||
PlayerQueueView(sidebarQueue: $sidebarQueue, fullScreen: $fullScreen)
|
||||
@@ -117,7 +111,6 @@ struct VideoDetails: View {
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.top, inNavigationView && fullScreen ? 10 : 0)
|
||||
.onAppear {
|
||||
if video.isNil && !sidebarQueue {
|
||||
currentPage = .queue
|
||||
@@ -208,15 +201,13 @@ struct VideoDetails: View {
|
||||
.font(.system(size: 14))
|
||||
.bold()
|
||||
|
||||
if showChannelSubscribers {
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
}
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,7 +426,7 @@ struct VideoDetails: View {
|
||||
|
||||
var detailsPage: some View {
|
||||
Group {
|
||||
Group {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let video = player.currentVideo {
|
||||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
@@ -449,6 +440,7 @@ struct VideoDetails: View {
|
||||
|
||||
Divider()
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let description = video.description {
|
||||
|
||||
@@ -4,9 +4,9 @@ import SwiftUI
|
||||
struct VideoDetailsPaddingModifier: ViewModifier {
|
||||
static var defaultAdditionalDetailsPadding: Double {
|
||||
#if os(macOS)
|
||||
30
|
||||
5
|
||||
#else
|
||||
40
|
||||
10
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ struct VideoDetailsPaddingModifier: ViewModifier {
|
||||
self.geometry = geometry
|
||||
self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio
|
||||
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
|
||||
self.additionalPadding = additionalPadding ?? VideoDetailsPaddingModifier.defaultAdditionalDetailsPadding
|
||||
self.additionalPadding = additionalPadding ?? Self.defaultAdditionalDetailsPadding
|
||||
self.fullScreen = fullScreen
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
let geometry: GeometryProxy
|
||||
let aspectRatio: Double?
|
||||
let minimumHeightLeft: Double
|
||||
let fullScreen: Bool
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@@ -13,18 +14,19 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
init(
|
||||
geometry: GeometryProxy,
|
||||
aspectRatio: Double? = nil,
|
||||
minimumHeightLeft: Double? = nil
|
||||
minimumHeightLeft: Double? = nil,
|
||||
fullScreen: Bool = false
|
||||
) {
|
||||
self.geometry = geometry
|
||||
self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio
|
||||
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
|
||||
self.fullScreen = fullScreen
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.frame(maxHeight: maxHeight)
|
||||
.aspectRatio(usedAspectRatio, contentMode: usedAspectRatioContentMode)
|
||||
.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
|
||||
.frame(maxHeight: fullScreen ? .infinity : maxHeight)
|
||||
.aspectRatio(usedAspectRatio, contentMode: .fit)
|
||||
}
|
||||
|
||||
var usedAspectRatio: Double {
|
||||
@@ -44,7 +46,7 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
|
||||
var usedAspectRatioContentMode: ContentMode {
|
||||
#if os(iOS)
|
||||
verticalSizeClass == .regular ? .fit : .fill
|
||||
!fullScreen ? .fit : .fill
|
||||
#else
|
||||
.fit
|
||||
#endif
|
||||
@@ -59,14 +61,4 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
|
||||
return [height, 0].max()!
|
||||
}
|
||||
|
||||
var edgesIgnoringSafeArea: Edge.Set {
|
||||
let empty = Edge.Set()
|
||||
|
||||
#if os(iOS)
|
||||
return verticalSizeClass == .compact ? .all : empty
|
||||
#else
|
||||
return empty
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct VideoPlayerView: View {
|
||||
#if os(iOS)
|
||||
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
|
||||
#endif
|
||||
|
||||
static let defaultAspectRatio = 16 / 9.0
|
||||
static var defaultMinimumHeightLeft: Double {
|
||||
#if os(macOS)
|
||||
@@ -17,25 +21,32 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
|
||||
@State private var playerSize: CGSize = .zero
|
||||
@State private var hoveringPlayer = false
|
||||
@State private var fullScreenDetails = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@Default(.lockLandscapeOnRotation) private var lockLandscapeOnRotation
|
||||
@Default(.lockOrientationInFullScreen) private var lockOrientationInFullScreen
|
||||
|
||||
@State private var motionManager: CMMotionManager!
|
||||
@State private var orientation = UIInterfaceOrientation.portrait
|
||||
@State private var lastOrientation: UIInterfaceOrientation?
|
||||
#elseif os(macOS)
|
||||
var mouseLocation: CGPoint { NSEvent.mouseLocation }
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
@State private var viewVerticalOffset = Self.hiddenOffset
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
@@ -51,36 +62,40 @@ struct VideoPlayerView: View {
|
||||
content
|
||||
.onAppear {
|
||||
playerSize = geometry.size
|
||||
|
||||
#if os(iOS)
|
||||
configureOrientationUpdatesBasedOnAccelerometer()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: geometry.size) { size in
|
||||
self.playerSize = size
|
||||
}
|
||||
.onChange(of: fullScreenDetails) { value in
|
||||
player.backend.setNeedsDrawing(!value)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||
handleOrientationDidChangeNotification()
|
||||
}
|
||||
.onDisappear {
|
||||
guard !player.playingFullscreen else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
.onChange(of: player.presentingPlayer) { newValue in
|
||||
if newValue {
|
||||
viewVerticalOffset = 0
|
||||
configureOrientationUpdatesBasedOnAccelerometer()
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
|
||||
motionManager?.stopAccelerometerUpdates()
|
||||
motionManager = nil
|
||||
motionManager?.stopAccelerometerUpdates()
|
||||
motionManager = nil
|
||||
viewVerticalOffset = Self.hiddenOffset
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
#if os(iOS)
|
||||
.offset(y: viewVerticalOffset)
|
||||
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -88,100 +103,233 @@ struct VideoPlayerView: View {
|
||||
Group {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
player.playerView
|
||||
playerView
|
||||
.ignoresSafeArea(.all, edges: .all)
|
||||
.onMoveCommand { direction in
|
||||
if direction == .left {
|
||||
playerControls.resetTimer()
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
if direction == .right {
|
||||
playerControls.resetTimer()
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
if direction == .up {
|
||||
playerControls.show()
|
||||
playerControls.resetTimer()
|
||||
}
|
||||
if direction == .down {
|
||||
playerControls.show()
|
||||
playerControls.resetTimer()
|
||||
}
|
||||
}
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
PlaybackBar()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
PlaybackBar()
|
||||
#endif
|
||||
|
||||
if player.currentItem.isNil {
|
||||
playerPlaceholder(geometry: geometry)
|
||||
} else if player.playingInPictureInPicture {
|
||||
pictureInPicturePlaceholder(geometry: geometry)
|
||||
} else {
|
||||
player.playerView
|
||||
.modifier(
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.controller?.aspectRatio
|
||||
)
|
||||
playerView
|
||||
#if !os(tvOS)
|
||||
.modifier(
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
|
||||
fullScreen: playerControls.playingFullscreen
|
||||
)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onSwipeGesture(
|
||||
up: {
|
||||
withAnimation {
|
||||
fullScreenDetails = true
|
||||
.frame(maxWidth: fullScreenLayout ? .infinity : nil, maxHeight: fullScreenLayout ? .infinity : nil)
|
||||
.onHover { hovering in
|
||||
hoveringPlayer = hovering
|
||||
hovering ? playerControls.show() : playerControls.hide()
|
||||
}
|
||||
#if !os(macOS)
|
||||
.gesture(
|
||||
DragGesture(coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
guard !fullScreenLayout else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
player.backend.setNeedsDrawing(false)
|
||||
let drag = value.translation.height
|
||||
|
||||
guard drag > 0 else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewVerticalOffset = drag
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if viewVerticalOffset > 100 {
|
||||
player.backend.setNeedsDrawing(false)
|
||||
player.hide()
|
||||
} else {
|
||||
viewVerticalOffset = 0
|
||||
player.backend.setNeedsDrawing(true)
|
||||
player.show()
|
||||
}
|
||||
}
|
||||
},
|
||||
down: { player.hide() }
|
||||
)
|
||||
#else
|
||||
.onAppear(perform: {
|
||||
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
|
||||
if hoveringPlayer {
|
||||
playerControls.resetTimer()
|
||||
}
|
||||
|
||||
return $0
|
||||
}
|
||||
})
|
||||
#endif
|
||||
|
||||
.background(Color.black)
|
||||
.background(Color.black)
|
||||
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
#if !os(tvOS)
|
||||
if !playerControls.playingFullscreen {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
}
|
||||
|
||||
#else
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
#endif
|
||||
}
|
||||
|
||||
#else
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
#endif
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.modifier(VideoDetailsPaddingModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.controller?.aspectRatio,
|
||||
fullScreen: fullScreenDetails
|
||||
))
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.modifier(VideoDetailsPaddingModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
|
||||
fullScreen: fullScreenDetails
|
||||
))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.background(((colorScheme == .dark || fullScreenLayout) ? Color.black : Color.white).edgesIgnoringSafeArea(.all))
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 650)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreenDetails)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
#endif
|
||||
if !playerControls.playingFullscreen {
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreenDetails)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.all, edges: fullScreenLayout ? .vertical : Edge.Set())
|
||||
#if os(iOS)
|
||||
.statusBar(hidden: playerControls.playingFullscreen)
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
var playerView: some View {
|
||||
ZStack(alignment: .top) {
|
||||
switch player.activeBackend {
|
||||
case .mpv:
|
||||
player.mpvPlayerView
|
||||
.overlay(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
player.playerSize = proxy.size
|
||||
}
|
||||
.onChange(of: proxy.size) { _ in
|
||||
player.playerSize = proxy.size
|
||||
}
|
||||
})
|
||||
case .appleAVPlayer:
|
||||
player.avPlayerView
|
||||
#if os(iOS)
|
||||
.onAppear {
|
||||
player.pipController = .init(playerLayer: player.playerLayerView.playerLayer)
|
||||
let pipDelegate = PiPDelegate()
|
||||
pipDelegate.player = player
|
||||
|
||||
player.pipDelegate = pipDelegate
|
||||
player.pipController!.delegate = pipDelegate
|
||||
player.playerLayerView.playerLayer.player = player.avPlayerBackend.avPlayer
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
PlayerGestures()
|
||||
#endif
|
||||
|
||||
PlayerControls(player: player)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onAppear {
|
||||
// ugly patch for #78
|
||||
guard player.activeBackend == .mpv else {
|
||||
return
|
||||
}
|
||||
|
||||
player.activeBackend = .appleAVPlayer
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
player.activeBackend = .mpv
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var fullScreenLayout: Bool {
|
||||
#if os(iOS)
|
||||
playerControls.playingFullscreen || verticalSizeClass == .compact
|
||||
#else
|
||||
playerControls.playingFullscreen
|
||||
#endif
|
||||
}
|
||||
|
||||
func playerPlaceholder(geometry: GeometryProxy) -> some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
ZStack(alignment: .topLeading) {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "ticket")
|
||||
.font(.system(size: 120))
|
||||
#endif
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "ticket")
|
||||
.font(.system(size: 120))
|
||||
#endif
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
|
||||
#if os(iOS)
|
||||
Button {
|
||||
player.hide()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 40))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(10)
|
||||
.foregroundColor(.gray)
|
||||
#endif
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / Self.defaultAspectRatio)
|
||||
}
|
||||
|
||||
func pictureInPicturePlaceholder(geometry: GeometryProxy) -> some View {
|
||||
@@ -210,7 +358,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / Self.defaultAspectRatio)
|
||||
}
|
||||
|
||||
var sidebarQueue: Bool {
|
||||
@@ -235,7 +383,7 @@ struct VideoPlayerView: View {
|
||||
private func configureOrientationUpdatesBasedOnAccelerometer() {
|
||||
if UIDevice.current.orientation.isLandscape,
|
||||
enterFullscreenInLandscape,
|
||||
!player.playingFullscreen,
|
||||
!playerControls.playingFullscreen,
|
||||
!player.playingInPictureInPicture
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
@@ -289,7 +437,7 @@ struct VideoPlayerView: View {
|
||||
|
||||
Orientation.lockOrientation(orientationLockMask, andRotateTo: orientation)
|
||||
|
||||
guard lockLandscapeOnRotation else {
|
||||
guard lockOrientationInFullScreen else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -298,7 +446,8 @@ struct VideoPlayerView: View {
|
||||
} else {
|
||||
guard abs(acceleration.z) <= 0.74,
|
||||
player.lockedOrientation.isNil,
|
||||
enterFullscreenInLandscape
|
||||
enterFullscreenInLandscape,
|
||||
!lockOrientationInFullScreen
|
||||
else {
|
||||
return
|
||||
}
|
||||
@@ -313,10 +462,11 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
|
||||
private func handleOrientationDidChangeNotification() {
|
||||
viewVerticalOffset = viewVerticalOffset == 0 ? 0 : Self.hiddenOffset
|
||||
let newOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
|
||||
if newOrientation?.isLandscape ?? false,
|
||||
player.presentingPlayer,
|
||||
lockLandscapeOnRotation,
|
||||
lockOrientationInFullScreen,
|
||||
!player.lockedOrientation.isNil
|
||||
{
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: newOrientation)
|
||||
|
||||
@@ -9,6 +9,7 @@ struct AddToPlaylistView: View {
|
||||
|
||||
@State private var error = ""
|
||||
@State private var presentingErrorAlert = false
|
||||
@State private var submitButtonDisabled = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@@ -122,7 +123,7 @@ struct AddToPlaylistView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Add to Playlist", action: addToPlaylist)
|
||||
.disabled(selectedPlaylist.isNil)
|
||||
.disabled(submitButtonDisabled || selectedPlaylist.isNil)
|
||||
.padding(.top, 30)
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
@@ -165,6 +166,8 @@ struct AddToPlaylistView: View {
|
||||
|
||||
Defaults[.lastUsedPlaylistID] = id
|
||||
|
||||
submitButtonDisabled = true
|
||||
|
||||
model.addVideo(
|
||||
playlistID: id,
|
||||
videoID: video.videoID,
|
||||
@@ -174,6 +177,7 @@ struct AddToPlaylistView: View {
|
||||
onFailure: { requestError in
|
||||
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
submitButtonDisabled = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,9 +43,12 @@ struct PlaylistFormView: View {
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.frame(maxWidth: 450)
|
||||
.padding(.leading, 10)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
if accounts.app.userPlaylistsHaveVisibility {
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
@@ -59,7 +62,7 @@ struct PlaylistFormView: View {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error when accessing playlist"),
|
||||
@@ -75,7 +78,7 @@ struct PlaylistFormView: View {
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: 150)
|
||||
.frame(width: 400, height: accounts.app.userPlaylistsHaveVisibility ? 150 : 120)
|
||||
#endif
|
||||
|
||||
#else
|
||||
@@ -119,20 +122,24 @@ struct PlaylistFormView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
TextField("Playlist Name", text: $name, onCommit: validate)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Visibility")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if accounts.app.userPlaylistsHaveVisibility {
|
||||
HStack {
|
||||
Text("Visibility")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
visibilityFormItem
|
||||
visibilityFormItem
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm).disabled(!valid)
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
}
|
||||
.padding(.top, 40)
|
||||
|
||||
@@ -172,27 +179,15 @@ struct PlaylistFormView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let body = ["title": name, "privacy": visibility.rawValue]
|
||||
accounts.api.playlistForm(name, visibility.rawValue, playlist: playlist, onFailure: { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}) { modifiedPlaylist in
|
||||
self.playlist = modifiedPlaylist
|
||||
playlists.load(force: true)
|
||||
|
||||
resource?
|
||||
.request(editing ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
playlist = modifiedPlaylist
|
||||
}
|
||||
|
||||
playlists.load(force: true)
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.onFailure { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
var resource: Resource? {
|
||||
editing ? accounts.api.playlist(playlist.id) : accounts.api.playlists
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
var visibilityFormItem: some View {
|
||||
@@ -236,17 +231,14 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
|
||||
func deletePlaylistAndDismiss() {
|
||||
accounts.api.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.onFailure { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
accounts.api.deletePlaylist(playlist, onFailure: { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||
presentingErrorAlert = true
|
||||
}) {
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ struct PlaylistsView: View {
|
||||
@State private var showingEditPlaylist = false
|
||||
@State private var editedPlaylist: Playlist?
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
@@ -18,11 +20,76 @@ struct PlaylistsView: View {
|
||||
@Namespace private var focusNamespace
|
||||
|
||||
var items: [ContentItem] {
|
||||
ContentItem.array(of: currentPlaylist?.videos ?? [])
|
||||
var videos = currentPlaylist?.videos ?? []
|
||||
|
||||
if videos.isEmpty {
|
||||
videos = store.item?.videos ?? []
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
var i = 0
|
||||
|
||||
for index in videos.indices {
|
||||
var video = videos[index]
|
||||
video.indexID = "\(i)"
|
||||
i += 1
|
||||
videos[index] = video
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContentItem.array(of: videos)
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
guard !player.accounts.app.userPlaylistsEndpointIncludesVideos,
|
||||
let playlist = currentPlaylist
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resource = player.accounts.api.playlist(playlist.id)
|
||||
resource?.addObserver(store)
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls(toolbar: {
|
||||
HStack {
|
||||
HStack {
|
||||
newPlaylistButton
|
||||
.offset(x: -10)
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
}
|
||||
|
||||
if !model.isEmpty {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
if model.isEmpty {
|
||||
Text("No Playlists")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
selectPlaylistButton
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if currentPlaylist != nil {
|
||||
HStack(spacing: 0) {
|
||||
playButton
|
||||
|
||||
shuffleButton
|
||||
}
|
||||
.offset(x: 10)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}) {
|
||||
SignInRequiredView(title: "Playlists") {
|
||||
VStack {
|
||||
#if os(tvOS)
|
||||
@@ -41,6 +108,7 @@ struct PlaylistsView: View {
|
||||
Spacer()
|
||||
#else
|
||||
VerticalCells(items: items)
|
||||
.environment(\.scrollViewBottomPadding, 70)
|
||||
#endif
|
||||
}
|
||||
.environment(\.currentPlaylistID, currentPlaylist?.id)
|
||||
@@ -48,6 +116,18 @@ struct PlaylistsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
model.load()
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
model.load(force: true)
|
||||
}
|
||||
.onChange(of: selectedPlaylistID) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
.onChange(of: model.reloadPlaylists) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
PlaylistFormView(playlist: $createdPlaylist)
|
||||
@@ -57,67 +137,25 @@ struct PlaylistsView: View {
|
||||
PlaylistFormView(playlist: $editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
.focusScope(focusNamespace)
|
||||
#else
|
||||
.background(
|
||||
EmptyView()
|
||||
.sheet(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
PlaylistFormView(playlist: $createdPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView()
|
||||
.sheet(isPresented: $showingEditPlaylist, onDismiss: selectEditedPlaylist) {
|
||||
PlaylistFormView(playlist: $editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView()
|
||||
.sheet(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
PlaylistFormView(playlist: $createdPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView()
|
||||
.sheet(isPresented: $showingEditPlaylist, onDismiss: selectEditedPlaylist) {
|
||||
PlaylistFormView(playlist: $editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Group {
|
||||
if model.isEmpty {
|
||||
Text("No Playlists")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
selectPlaylistButton
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if currentPlaylist != nil {
|
||||
HStack(spacing: 10) {
|
||||
playButton
|
||||
shuffleButton
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 2) {
|
||||
newPlaylistButton
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focusScope(focusNamespace)
|
||||
#endif
|
||||
.onAppear {
|
||||
model.load()
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
model.load(force: true)
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -223,7 +261,7 @@ struct PlaylistsView: View {
|
||||
}
|
||||
} label: {
|
||||
Text(currentPlaylist?.title ?? "Select playlist")
|
||||
.frame(maxWidth: 140, alignment: .leading)
|
||||
.frame(maxWidth: 140, alignment: .center)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -234,16 +272,17 @@ struct PlaylistsView: View {
|
||||
self.showingEditPlaylist = true
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
Text("Edit")
|
||||
Image(systemName: "rectangle.and.pencil.and.ellipsis")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var newPlaylistButton: some View {
|
||||
Button(action: { self.showingNewPlaylist = true }) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: "plus")
|
||||
.padding(8)
|
||||
.contentShape(Rectangle())
|
||||
#if os(tvOS)
|
||||
Text("New Playlist")
|
||||
#endif
|
||||
@@ -256,6 +295,8 @@ struct PlaylistsView: View {
|
||||
player.play(items.compactMap(\.video))
|
||||
} label: {
|
||||
Image(systemName: "play")
|
||||
.padding(8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +305,8 @@ struct PlaylistsView: View {
|
||||
player.play(items.compactMap(\.video), shuffling: true)
|
||||
} label: {
|
||||
Image(systemName: "shuffle")
|
||||
.padding(8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
Shared/RepeatingTimer.swift
Normal file
54
Shared/RepeatingTimer.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
final class RepeatingTimer {
|
||||
let timeInterval: TimeInterval
|
||||
|
||||
init(timeInterval: TimeInterval) {
|
||||
self.timeInterval = timeInterval
|
||||
}
|
||||
|
||||
private lazy var timer: DispatchSourceTimer = {
|
||||
let t = DispatchSource.makeTimerSource()
|
||||
t.schedule(deadline: .now() + self.timeInterval, repeating: self.timeInterval)
|
||||
t.setEventHandler { [weak self] in
|
||||
self?.eventHandler?()
|
||||
}
|
||||
return t
|
||||
}()
|
||||
|
||||
var eventHandler: (() -> Void)?
|
||||
|
||||
private enum State {
|
||||
case suspended
|
||||
case resumed
|
||||
}
|
||||
|
||||
private var state: State = .suspended
|
||||
|
||||
deinit {
|
||||
timer.setEventHandler {}
|
||||
timer.cancel()
|
||||
/*
|
||||
If the timer is suspended, calling cancel without resuming
|
||||
triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902
|
||||
*/
|
||||
resume()
|
||||
eventHandler = nil
|
||||
}
|
||||
|
||||
func resume() {
|
||||
if state == .resumed {
|
||||
return
|
||||
}
|
||||
state = .resumed
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
func suspend() {
|
||||
if state == .suspended {
|
||||
return
|
||||
}
|
||||
state = .suspended
|
||||
timer.suspend()
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,23 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls(toolbar: {
|
||||
#if os(iOS)
|
||||
if accounts.app.supportsSearchFilters {
|
||||
HStack(spacing: 0) {
|
||||
Menu("Sort: \(searchSortOrder.name)") {
|
||||
searchSortOrderPicker
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
|
||||
Spacer()
|
||||
|
||||
filtersMenu
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
#endif
|
||||
}) {
|
||||
#if os(iOS)
|
||||
VStack {
|
||||
SearchTextField(favoriteItem: $favoriteItem)
|
||||
@@ -70,27 +86,19 @@ struct SearchView: View {
|
||||
#endif
|
||||
}
|
||||
.toolbar {
|
||||
#if !os(tvOS)
|
||||
#if os(macOS)
|
||||
ToolbarItemGroup(placement: toolbarPlacement) {
|
||||
#if os(macOS)
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem?.id)
|
||||
#endif
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem?.id)
|
||||
|
||||
if accounts.app.supportsSearchFilters {
|
||||
Section {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Sort:")
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
Text("Sort:")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
searchSortOrderPicker
|
||||
}
|
||||
#else
|
||||
Menu("Sort: \(searchSortOrder.name)") {
|
||||
searchSortOrderPicker
|
||||
}
|
||||
#endif
|
||||
searchSortOrderPicker
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
@@ -99,9 +107,7 @@ struct SearchView: View {
|
||||
filtersMenu
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
SearchTextField()
|
||||
#endif
|
||||
SearchTextField()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -198,7 +204,7 @@ struct SearchView: View {
|
||||
visibleSections.append(.subscriptions)
|
||||
}
|
||||
|
||||
if accounts.app.supportsUserPlaylists && preferred.contains(.playlists) {
|
||||
if accounts.app.supportsUserPlaylists && accounts.signedIn && preferred.contains(.playlists) {
|
||||
visibleSections.append(.playlists)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,11 @@ struct PlayerSettings: View {
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showHistoryInPlayer) private var showHistory
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.showChannelSubscribers) private var channelSubscribers
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
#if os(iOS)
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@Default(.lockLandscapeOnRotation) private var lockLandscapeOnRotation
|
||||
@Default(.lockOrientationInFullScreen) private var lockOrientationInFullScreen
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.lockLandscapeWhenEnteringFullscreen) private var lockLandscapeWhenEnteringFullscreen
|
||||
#endif
|
||||
@Default(.closePiPOnNavigation) private var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer
|
||||
@@ -28,6 +26,8 @@ struct PlayerSettings: View {
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) private var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
|
||||
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
|
||||
|
||||
#if os(iOS)
|
||||
private var idiom: UIUserInterfaceIdiom {
|
||||
UIDevice.current.userInterfaceIdiom
|
||||
@@ -83,7 +83,7 @@ struct PlayerSettings: View {
|
||||
|
||||
keywordsToggle
|
||||
showHistoryToggle
|
||||
channelSubscribersToggle
|
||||
returnYouTubeDislikeToggle
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Picture in Picture")) {
|
||||
@@ -95,13 +95,12 @@ struct PlayerSettings: View {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
Section(header: SettingsHeader(text: "Orientation"), footer: orientationFooter) {
|
||||
Section(header: SettingsHeader(text: "Orientation")) {
|
||||
if idiom == .pad {
|
||||
enterFullscreenInLandscapeToggle
|
||||
}
|
||||
honorSystemOrientationLockToggle
|
||||
lockLandscapeOnRotationToggle
|
||||
lockLandscapeWhenEnteringFullscreenToggle
|
||||
lockOrientationInFullScreenToggle
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -196,8 +195,8 @@ struct PlayerSettings: View {
|
||||
Toggle("Show history", isOn: $showHistory)
|
||||
}
|
||||
|
||||
private var channelSubscribersToggle: some View {
|
||||
Toggle("Show subscribers count", isOn: $channelSubscribers)
|
||||
private var returnYouTubeDislikeToggle: some View {
|
||||
Toggle("Enable Return YouTube Dislike", isOn: $enableReturnYouTubeDislike)
|
||||
}
|
||||
|
||||
private var pauseOnHidingPlayerToggle: some View {
|
||||
@@ -214,18 +213,10 @@ struct PlayerSettings: View {
|
||||
Toggle("Enter fullscreen in landscape", isOn: $enterFullscreenInLandscape)
|
||||
}
|
||||
|
||||
private var lockLandscapeOnRotationToggle: some View {
|
||||
Toggle("Lock landscape on rotation", isOn: $lockLandscapeOnRotation)
|
||||
private var lockOrientationInFullScreenToggle: some View {
|
||||
Toggle("Lock orientation in fullscreen", isOn: $lockOrientationInFullScreen)
|
||||
.disabled(!enterFullscreenInLandscape)
|
||||
}
|
||||
|
||||
private var lockLandscapeWhenEnteringFullscreenToggle: some View {
|
||||
Toggle("Rotate and lock landscape on entering fullscreen", isOn: $lockLandscapeWhenEnteringFullscreen)
|
||||
}
|
||||
|
||||
private var orientationFooter: some View {
|
||||
Text("Orientation settings are experimental and do not yet work properly with all devices and iOS versions")
|
||||
}
|
||||
#endif
|
||||
|
||||
private var closePiPOnNavigationToggle: some View {
|
||||
|
||||
@@ -16,9 +16,9 @@ struct TrendingCountry: View {
|
||||
#if !os(tvOS)
|
||||
HStack {
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
TextField("Country", text: $query, prompt: Text(TrendingCountry.prompt))
|
||||
TextField("Country", text: $query, prompt: Text(Self.prompt))
|
||||
} else {
|
||||
TextField(TrendingCountry.prompt, text: $query)
|
||||
TextField(Self.prompt, text: $query)
|
||||
}
|
||||
|
||||
Button("Done") { selectCountryAndDismiss() }
|
||||
@@ -30,7 +30,7 @@ struct TrendingCountry: View {
|
||||
countriesList
|
||||
}
|
||||
#if os(tvOS)
|
||||
.searchable(text: $query, placement: .automatic, prompt: Text(TrendingCountry.prompt))
|
||||
.searchable(text: $query, placement: .automatic, prompt: Text(Self.prompt))
|
||||
.background(Color.black)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -33,7 +33,39 @@ struct TrendingView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls(toolbar: {
|
||||
HStack {
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
categoryButton
|
||||
// only way to disable Menu animation is to
|
||||
// force redraw of the view when it changes
|
||||
.id(UUID())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem, labelPadding: true)
|
||||
.id(favoriteItem.id)
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Country")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
countryButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}) {
|
||||
Section {
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
#if os(tvOS)
|
||||
@@ -44,6 +76,7 @@ struct TrendingView: View {
|
||||
Spacer()
|
||||
#else
|
||||
VerticalCells(items: trending)
|
||||
.environment(\.scrollViewBottomPadding, 70)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -62,38 +95,6 @@ struct TrendingView: View {
|
||||
}
|
||||
countryButton
|
||||
}
|
||||
#elseif os(iOS)
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Group {
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
categoryButton
|
||||
// only way to disable Menu animation is to
|
||||
// force redraw of the view when it changes
|
||||
.id(UUID())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem.id)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Country")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
countryButton
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: resource) { _ in
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
struct VideoURLParser {
|
||||
@@ -11,7 +12,7 @@ struct VideoURLParser {
|
||||
return queryItemValue("v")
|
||||
}
|
||||
|
||||
var time: TimeInterval? {
|
||||
var time: CMTime? {
|
||||
guard let time = queryItemValue("t") else {
|
||||
return nil
|
||||
}
|
||||
@@ -24,13 +25,13 @@ struct VideoURLParser {
|
||||
let seconds = TimeInterval(timeComponents["seconds"] ?? "0")
|
||||
else {
|
||||
if let time = TimeInterval(time) {
|
||||
return time
|
||||
return .secondsInDefaultTimescale(time)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return seconds + (minutes * 60) + (hours * 60 * 60)
|
||||
return .secondsInDefaultTimescale(seconds + (minutes * 60) + (hours * 60 * 60))
|
||||
}
|
||||
|
||||
func queryItemValue(_ name: String) -> String? {
|
||||
|
||||
@@ -6,6 +6,7 @@ struct VerticalCells: View {
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
#endif
|
||||
|
||||
@Environment(\.scrollViewBottomPadding) private var scrollViewBottomPadding
|
||||
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
|
||||
|
||||
var items = [ContentItem]()
|
||||
@@ -20,6 +21,9 @@ struct VerticalCells: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
#if !os(tvOS)
|
||||
Color.clear.padding(.bottom, scrollViewBottomPadding)
|
||||
#endif
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#if os(macOS)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
@@ -5,8 +6,8 @@ import SwiftUI
|
||||
struct VideoCell: View {
|
||||
private var video: Video
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@Environment(\.inChannelView) private var inChannelView
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@@ -45,11 +46,8 @@ struct VideoCell: View {
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
|
||||
.contextMenu {
|
||||
VideoContextMenuView(
|
||||
video: video,
|
||||
playerNavigationLinkActive: $player.playerNavigationLinkActive
|
||||
)
|
||||
.environmentObject(accounts)
|
||||
VideoContextMenuView(video: video)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,34 +60,39 @@ struct VideoCell: View {
|
||||
}
|
||||
|
||||
private func playAction() {
|
||||
guard video.videoID != Video.fixtureID else {
|
||||
return
|
||||
}
|
||||
|
||||
if watchingNow {
|
||||
if !player.playingInPictureInPicture {
|
||||
player.show()
|
||||
DispatchQueue.main.async {
|
||||
guard video.videoID != Video.fixtureID else {
|
||||
return
|
||||
}
|
||||
|
||||
if !playNowContinues {
|
||||
player.player.seek(to: .zero)
|
||||
if watchingNow {
|
||||
if !player.playingInPictureInPicture {
|
||||
player.show()
|
||||
}
|
||||
|
||||
if !playNowContinues {
|
||||
player.backend.seek(to: .zero)
|
||||
}
|
||||
|
||||
player.play()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
player.play()
|
||||
var playAt: CMTime?
|
||||
|
||||
return
|
||||
if saveHistory,
|
||||
playNowContinues,
|
||||
!watch.isNil,
|
||||
!watch!.finished
|
||||
{
|
||||
playAt = .secondsInDefaultTimescale(watch!.stoppedAt)
|
||||
}
|
||||
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
|
||||
|
||||
player.play(video, at: playAt)
|
||||
}
|
||||
|
||||
var playAt: TimeInterval?
|
||||
|
||||
if playNowContinues,
|
||||
!watch.isNil,
|
||||
!watch!.finished
|
||||
{
|
||||
playAt = watch!.stoppedAt
|
||||
}
|
||||
|
||||
player.play(video, at: playAt, inNavigationView: inNavigationView)
|
||||
}
|
||||
|
||||
private var playNowContinues: Bool {
|
||||
@@ -255,7 +258,6 @@ struct VideoCell: View {
|
||||
HStack(spacing: 8) {
|
||||
if let date = video.publishedDate {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "calendar")
|
||||
Text(date)
|
||||
.allowsTightening(true)
|
||||
}
|
||||
@@ -272,7 +274,6 @@ struct VideoCell: View {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "clock")
|
||||
Text(time)
|
||||
}
|
||||
}
|
||||
@@ -294,6 +295,10 @@ struct VideoCell: View {
|
||||
|
||||
private func channelButton(badge: Bool = true) -> some View {
|
||||
Button {
|
||||
guard !inChannelView else {
|
||||
return
|
||||
}
|
||||
|
||||
NavigationModel.openChannel(
|
||||
video.channel,
|
||||
player: player,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerControlsView<Content: View>: View {
|
||||
struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
|
||||
let content: Content
|
||||
let toolbar: Toolbar?
|
||||
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerModel> private var model
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) {
|
||||
init(@ViewBuilder toolbar: @escaping () -> Toolbar? = { nil }, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content()
|
||||
self.toolbar = toolbar()
|
||||
}
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) where Toolbar == EmptyView {
|
||||
self.init(toolbar: { EmptyView() }, content: content)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -16,17 +23,30 @@ struct PlayerControlsView<Content: View>: View {
|
||||
content
|
||||
#if !os(tvOS)
|
||||
.frame(minHeight: 0, maxHeight: .infinity)
|
||||
.padding(.bottom, 50)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
controls
|
||||
Group {
|
||||
#if !os(tvOS)
|
||||
#if !os(macOS)
|
||||
toolbar
|
||||
.frame(height: 100)
|
||||
.offset(x: 0, y: -28)
|
||||
#endif
|
||||
controls
|
||||
|
||||
#endif
|
||||
}
|
||||
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .sidebar))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(.all))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
let controls = HStack {
|
||||
HStack {
|
||||
Button(action: {
|
||||
model.togglePlayer()
|
||||
}) {
|
||||
@@ -45,58 +65,49 @@ struct PlayerControlsView<Content: View>: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
model.closeCurrentItem()
|
||||
} label: {
|
||||
Label("Close Video", systemImage: "xmark.circle")
|
||||
.labelStyle(.automatic)
|
||||
}
|
||||
.disabled(model.currentItem.isNil)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
|
||||
ZStack(alignment: .bottom) {
|
||||
HStack {
|
||||
Group {
|
||||
if model.isPlaying {
|
||||
Button(action: {
|
||||
model.pause()
|
||||
}) {
|
||||
Label("Pause", systemImage: "pause.fill")
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
model.play()
|
||||
}) {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(model.player.currentItem.isNil)
|
||||
HStack {
|
||||
Group {
|
||||
if !model.currentItem.isNil {
|
||||
Button {
|
||||
model.closeCurrentItem()
|
||||
model.closePiP()
|
||||
} label: {
|
||||
Label("Close Video", systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 30))
|
||||
.frame(minWidth: 30)
|
||||
|
||||
Button(action: { model.advanceToNextItem() }) {
|
||||
Label("Next", systemImage: "forward.fill")
|
||||
if playerControls.isPlaying {
|
||||
Button(action: {
|
||||
model.pause()
|
||||
}) {
|
||||
Label("Pause", systemImage: "pause.fill")
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
model.play()
|
||||
}) {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
.disabled(model.queue.isEmpty)
|
||||
}
|
||||
.disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
|
||||
.font(.system(size: 30))
|
||||
.frame(minWidth: 30)
|
||||
|
||||
ProgressView(value: progressViewValue, total: progressViewTotal)
|
||||
.progressViewStyle(.linear)
|
||||
#if os(iOS)
|
||||
.offset(x: 0, y: 8)
|
||||
.frame(maxWidth: 60)
|
||||
#else
|
||||
.offset(x: 0, y: 15)
|
||||
.frame(maxWidth: 70)
|
||||
#endif
|
||||
Button(action: { model.advanceToNextItem() }) {
|
||||
Label("Next", systemImage: "forward.fill")
|
||||
.padding(.vertical)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(model.queue.isEmpty)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -111,20 +122,6 @@ struct PlayerControlsView<Content: View>: View {
|
||||
model.show()
|
||||
})
|
||||
#endif
|
||||
|
||||
return Group {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
controls
|
||||
.background(Material.ultraThinMaterial)
|
||||
} else {
|
||||
controls
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemUltraThinMaterial))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressViewValue: Double {
|
||||
@@ -138,7 +135,7 @@ struct PlayerControlsView<Content: View>: View {
|
||||
|
||||
struct PlayerControlsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Hello")
|
||||
@@ -2,55 +2,89 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelPlaylistView: View {
|
||||
var playlist: ChannelPlaylist
|
||||
#if os(iOS)
|
||||
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
|
||||
#endif
|
||||
|
||||
var playlist: ChannelPlaylist?
|
||||
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
|
||||
#if os(iOS)
|
||||
@State private var viewVerticalOffset = Self.hiddenOffset
|
||||
#endif
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@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
|
||||
|
||||
var items: [ContentItem] {
|
||||
private var items: [ContentItem] {
|
||||
ContentItem.array(of: store.item?.videos ?? [])
|
||||
}
|
||||
|
||||
var resource: Resource? {
|
||||
accounts.api.channelPlaylist(playlist.id)
|
||||
private var presentedPlaylist: ChannelPlaylist? {
|
||||
playlist ?? recents.presentedPlaylist
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
guard let playlist = presentedPlaylist else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resource = accounts.api.channelPlaylist(playlist.id)
|
||||
resource?.addObserver(store)
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
if inNavigationView {
|
||||
content
|
||||
} else {
|
||||
PlayerControlsView {
|
||||
if navigationStyle == .tab {
|
||||
NavigationView {
|
||||
BrowserPlayerControls {
|
||||
content
|
||||
}
|
||||
}
|
||||
#else
|
||||
PlayerControlsView {
|
||||
#if os(iOS)
|
||||
.onChange(of: navigation.presentingPlaylist) { newValue in
|
||||
if newValue {
|
||||
store.clear()
|
||||
viewVerticalOffset = 0
|
||||
resource?.load()
|
||||
} else {
|
||||
viewVerticalOffset = Self.hiddenOffset
|
||||
}
|
||||
}
|
||||
.offset(y: viewVerticalOffset)
|
||||
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
|
||||
#endif
|
||||
} else {
|
||||
BrowserPlayerControls {
|
||||
content
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
VStack(alignment: .leading) {
|
||||
#if os(tvOS)
|
||||
HStack {
|
||||
Text(playlist.title)
|
||||
.font(.title2)
|
||||
.frame(alignment: .leading)
|
||||
if let playlist = presentedPlaylist {
|
||||
Text(playlist.title)
|
||||
.font(.title2)
|
||||
.frame(alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
.labelStyle(.iconOnly)
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
|
||||
playButton
|
||||
.labelStyle(.iconOnly)
|
||||
@@ -69,7 +103,6 @@ struct ChannelPlaylistView: View {
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
resource?.loadIfNeeded()
|
||||
}
|
||||
#if os(tvOS)
|
||||
@@ -77,26 +110,31 @@ struct ChannelPlaylistView: View {
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
if navigationStyle == .tab {
|
||||
Button("Done") {
|
||||
navigation.presentingPlaylist = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: playlistButtonsPlacement) {
|
||||
HStack {
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
|
||||
if let playlist = presentedPlaylist {
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
}
|
||||
|
||||
playButton
|
||||
shuffleButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(playlist.title)
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(player.playerNavigationLinkActive)
|
||||
#endif
|
||||
.navigationTitle(presentedPlaylist?.title ?? "")
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -110,7 +148,7 @@ struct ChannelPlaylistView: View {
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
player.play(videos, inNavigationView: inNavigationView)
|
||||
player.play(videos)
|
||||
} label: {
|
||||
Label("Play All", systemImage: "play")
|
||||
}
|
||||
@@ -118,7 +156,7 @@ struct ChannelPlaylistView: View {
|
||||
|
||||
private var shuffleButton: some View {
|
||||
Button {
|
||||
player.play(videos, shuffling: true, inNavigationView: inNavigationView)
|
||||
player.play(videos, shuffling: true)
|
||||
} label: {
|
||||
Label("Shuffle", systemImage: "shuffle")
|
||||
}
|
||||
|
||||
@@ -2,46 +2,69 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelVideosView: View {
|
||||
let channel: Channel
|
||||
#if os(iOS)
|
||||
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
|
||||
#endif
|
||||
var channel: Channel?
|
||||
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
@State private var subscriptionToggleButtonDisabled = false
|
||||
|
||||
#if os(iOS)
|
||||
@State private var viewVerticalOffset = Self.hiddenOffset
|
||||
#endif
|
||||
|
||||
@StateObject private var store = Store<Channel>()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Namespace private var focusNamespace
|
||||
|
||||
var presentedChannel: Channel? {
|
||||
channel ?? recents.presentedChannel
|
||||
}
|
||||
|
||||
var videos: [ContentItem] {
|
||||
ContentItem.array(of: store.item?.videos ?? [])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
if inNavigationView {
|
||||
content
|
||||
} else {
|
||||
PlayerControlsView {
|
||||
if navigationStyle == .tab {
|
||||
NavigationView {
|
||||
BrowserPlayerControls {
|
||||
content
|
||||
}
|
||||
}
|
||||
#else
|
||||
PlayerControlsView {
|
||||
#if os(iOS)
|
||||
.onChange(of: navigation.presentingChannel) { newValue in
|
||||
if newValue {
|
||||
store.clear()
|
||||
viewVerticalOffset = 0
|
||||
resource?.load()
|
||||
} else {
|
||||
viewVerticalOffset = Self.hiddenOffset
|
||||
}
|
||||
}
|
||||
.offset(y: viewVerticalOffset)
|
||||
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
|
||||
#endif
|
||||
} else {
|
||||
BrowserPlayerControls {
|
||||
content
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
@@ -54,8 +77,10 @@ struct ChannelVideosView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
.labelStyle(.iconOnly)
|
||||
if let channel = presentedChannel {
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
|
||||
if let subscribers = store.item?.subscriptionsString {
|
||||
Text("**\(subscribers)** subscribers")
|
||||
@@ -77,27 +102,35 @@ struct ChannelVideosView: View {
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
if navigationStyle == .tab {
|
||||
Button("Done") {
|
||||
navigation.presentingChannel = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
HStack {
|
||||
HStack(spacing: 3) {
|
||||
Text("\(store.item?.subscriptionsString ?? "loading")")
|
||||
Text("\(store.item?.subscriptionsString ?? "")")
|
||||
.fontWeight(.bold)
|
||||
Text(" subscribers")
|
||||
.allowsTightening(true)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
|
||||
}
|
||||
.allowsTightening(true)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
|
||||
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
|
||||
subscriptionToggleButton
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
if let channel = presentedChannel {
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,15 +143,11 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
if store.item.isNil {
|
||||
resource.addObserver(store)
|
||||
resource.load()
|
||||
}
|
||||
resource?.loadIfNeeded()
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(player.playerNavigationLinkActive)
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.navigationTitle(navigationTitle)
|
||||
#endif
|
||||
|
||||
return Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
@@ -135,44 +164,50 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var resource: Resource {
|
||||
private var resource: Resource? {
|
||||
guard let channel = presentedChannel else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resource = accounts.api.channel(channel.id)
|
||||
resource.addObserver(store)
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
private var subscriptionToggleButton: some View {
|
||||
Group {
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn {
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
Button("Unsubscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
@ViewBuilder private var subscriptionToggleButton: some View {
|
||||
if let channel = presentedChannel {
|
||||
Group {
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn {
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
Button("Unsubscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
subscriptions.unsubscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
subscriptions.unsubscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
subscriptions.subscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
subscriptions.subscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(subscriptionToggleButtonDisabled)
|
||||
}
|
||||
.disabled(subscriptionToggleButtonDisabled)
|
||||
}
|
||||
|
||||
private var contentItem: ContentItem {
|
||||
ContentItem(channel: channel)
|
||||
ContentItem(channel: presentedChannel)
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
store.item?.name ?? channel.name
|
||||
presentedChannel?.name ?? store.item?.name ?? "No channel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import SwiftUI
|
||||
struct FavoriteButton: View {
|
||||
let item: FavoriteItem!
|
||||
let favorites = FavoritesModel.shared
|
||||
let labelPadding: Bool
|
||||
|
||||
init(item: FavoriteItem?, labelPadding: Bool = false) {
|
||||
self.item = item
|
||||
self.labelPadding = labelPadding
|
||||
}
|
||||
|
||||
@State private var isFavorite = false
|
||||
|
||||
@@ -19,11 +25,17 @@ struct FavoriteButton: View {
|
||||
favorites.toggle(item)
|
||||
isFavorite.toggle()
|
||||
} label: {
|
||||
if isFavorite {
|
||||
Label("Remove from Favorites", systemImage: "heart.fill")
|
||||
} else {
|
||||
Label("Add to Favorites", systemImage: "heart")
|
||||
Group {
|
||||
if isFavorite {
|
||||
Label("Remove from Favorites", systemImage: "heart.fill")
|
||||
} else {
|
||||
Label("Add to Favorites", systemImage: "heart")
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.padding(labelPadding ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
#endif
|
||||
}
|
||||
.disabled(item.isNil)
|
||||
.onAppear {
|
||||
|
||||
41
Shared/Views/MPVPlayerView.swift
Normal file
41
Shared/Views/MPVPlayerView.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
#if !os(macOS)
|
||||
struct MPVPlayerView: UIViewControllerRepresentable {
|
||||
@State private var controller = MPVViewController()
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
func makeUIViewController(context _: Context) -> some UIViewController {
|
||||
player.mpvBackend.controller = controller
|
||||
player.mpvBackend.client = controller.client
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_: UIViewControllerType, context _: Context) {}
|
||||
}
|
||||
#else
|
||||
struct MPVPlayerView: NSViewRepresentable {
|
||||
@State private var client = MPVClient()
|
||||
@State private var layer = VideoLayer()
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
func makeNSView(context _: Context) -> some NSView {
|
||||
player.mpvBackend.client = client
|
||||
|
||||
client.layer = layer
|
||||
layer.client = client
|
||||
|
||||
let view = MPVOGLView()
|
||||
|
||||
view.layer = client.layer
|
||||
view.wantsLayer = true
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_: NSViewType, context _: Context) {}
|
||||
}
|
||||
#endif
|
||||
@@ -4,11 +4,36 @@ import SwiftUI
|
||||
struct PlaylistVideosView: View {
|
||||
let playlist: Playlist
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
ContentItem.array(of: playlist.videos)
|
||||
var videos = playlist.videos
|
||||
|
||||
if videos.isEmpty {
|
||||
videos = store.item?.videos ?? []
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
var i = 0
|
||||
|
||||
for index in videos.indices {
|
||||
var video = videos[index]
|
||||
video.indexID = "\(i)"
|
||||
i += 1
|
||||
videos[index] = video
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContentItem.array(of: videos)
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
let resource = player.accounts.api.playlist(playlist.id)
|
||||
resource?.addObserver(store)
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
var videos: [Video] {
|
||||
@@ -20,8 +45,16 @@ struct PlaylistVideosView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls {
|
||||
VerticalCells(items: contentItems)
|
||||
.onAppear {
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
resource?.load()
|
||||
}
|
||||
}
|
||||
.onChange(of: model.reloadPlaylists) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("\(playlist.title) Playlist")
|
||||
#endif
|
||||
@@ -32,13 +65,13 @@ struct PlaylistVideosView: View {
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
|
||||
Button {
|
||||
player.play(videos, inNavigationView: inNavigationView)
|
||||
player.play(videos)
|
||||
} label: {
|
||||
Label("Play All", systemImage: "play")
|
||||
}
|
||||
|
||||
Button {
|
||||
player.play(videos, shuffling: true, inNavigationView: inNavigationView)
|
||||
player.play(videos, shuffling: true)
|
||||
} label: {
|
||||
Label("Shuffle", systemImage: "shuffle")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct PopularView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls {
|
||||
VerticalCells(items: videos)
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
|
||||
@@ -45,7 +45,7 @@ struct ShareButton: View {
|
||||
shareAction(
|
||||
accounts.api.shareURL(
|
||||
contentItem,
|
||||
time: player.player.currentTime()
|
||||
time: player.backend.currentTime
|
||||
)!
|
||||
)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ struct ShareButton: View {
|
||||
accounts.api.shareURL(
|
||||
contentItem,
|
||||
frontendHost: "www.youtube.com",
|
||||
time: player.player.currentTime()
|
||||
time: player.backend.currentTime
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ struct SignInRequiredView<Content: View>: View {
|
||||
|
||||
struct SignInRequiredView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls {
|
||||
SignInRequiredView(title: "Subscriptions") {
|
||||
Text("Only when signed in")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct SubscriptionsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls {
|
||||
SignInRequiredView(title: "Subscriptions") {
|
||||
VerticalCells(items: videos)
|
||||
.onAppear {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import CoreData
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct VideoContextMenuView: View {
|
||||
let video: Video
|
||||
|
||||
@Binding var playerNavigationLinkActive: Bool
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.inChannelView) private var inChannelView
|
||||
@Environment(\.inChannelPlaylistView) private var inChannelPlaylistView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@@ -26,9 +24,8 @@ struct VideoContextMenuView: View {
|
||||
|
||||
private var viewContext: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
init(video: Video, playerNavigationLinkActive: Binding<Bool>) {
|
||||
init(video: Video) {
|
||||
self.video = video
|
||||
_playerNavigationLinkActive = playerNavigationLinkActive
|
||||
_watchRequest = video.watchFetchRequest
|
||||
}
|
||||
|
||||
@@ -57,6 +54,9 @@ struct VideoContextMenuView: View {
|
||||
|
||||
Section {
|
||||
playNowButton
|
||||
#if os(iOS)
|
||||
playNowInPictureInPictureButton
|
||||
#endif
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -111,7 +111,7 @@ struct VideoContextMenuView: View {
|
||||
|
||||
private var continueButton: some View {
|
||||
Button {
|
||||
player.play(video, at: watch!.stoppedAt, inNavigationView: inNavigationView)
|
||||
player.play(video, at: .secondsInDefaultTimescale(watch!.stoppedAt))
|
||||
} label: {
|
||||
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime() ?? "where I left off")", systemImage: "playpause")
|
||||
}
|
||||
@@ -131,12 +131,24 @@ struct VideoContextMenuView: View {
|
||||
|
||||
private var playNowButton: some View {
|
||||
Button {
|
||||
player.play(video, inNavigationView: inNavigationView)
|
||||
player.play(video)
|
||||
} label: {
|
||||
Label("Play Now", systemImage: "play")
|
||||
}
|
||||
}
|
||||
|
||||
private var playNowInPictureInPictureButton: some View {
|
||||
Button {
|
||||
player.controls.startPiP(startImmediately: false)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
player.play(video, at: watch?.timeToRestart, showingPlayer: false)
|
||||
}
|
||||
} label: {
|
||||
Label("Play in PiP", systemImage: "pip")
|
||||
}
|
||||
}
|
||||
|
||||
private var playNextButton: some View {
|
||||
Button {
|
||||
player.playNext(video)
|
||||
@@ -195,15 +207,15 @@ struct VideoContextMenuView: View {
|
||||
Button {
|
||||
navigation.presentAddToPlaylist(video)
|
||||
} label: {
|
||||
Label("Add to playlist...", systemImage: "text.badge.plus")
|
||||
Label("Add to Playlist...", systemImage: "text.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
func removeFromPlaylistButton(playlistID: String) -> some View {
|
||||
Button {
|
||||
playlists.removeVideo(videoIndexID: video.indexID!, playlistID: playlistID)
|
||||
playlists.removeVideo(index: video.indexID!, playlistID: playlistID)
|
||||
} label: {
|
||||
Label("Remove from playlist", systemImage: "text.badge.minus")
|
||||
Label("Remove from Playlist", systemImage: "text.badge.minus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ struct YatteeApp: App {
|
||||
@StateObject private var menu = MenuModel()
|
||||
@StateObject private var navigation = NavigationModel()
|
||||
@StateObject private var player = PlayerModel()
|
||||
@StateObject private var playerControls = PlayerControlsModel()
|
||||
@StateObject private var playlists = PlaylistsModel()
|
||||
@StateObject private var recents = RecentsModel()
|
||||
@StateObject private var search = SearchModel()
|
||||
@@ -41,6 +42,7 @@ struct YatteeApp: App {
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
@@ -90,6 +92,16 @@ struct YatteeApp: App {
|
||||
.background(
|
||||
HostingWindowFinder { window in
|
||||
Windows.playerWindow = window
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willExitFullScreenNotification,
|
||||
object: window,
|
||||
queue: OperationQueue.main
|
||||
) { _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.player.controls.playingFullscreen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.onAppear { player.presentingPlayer = true }
|
||||
@@ -101,6 +113,7 @@ struct YatteeApp: App {
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
@@ -115,6 +128,7 @@ struct YatteeApp: App {
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(updater)
|
||||
}
|
||||
#endif
|
||||
|
||||
BIN
Vendor/mpv/iOS/lib/libass.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libass.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libavcodec.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libavcodec.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libavdevice.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libavdevice.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libavfilter.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libavfilter.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libavformat.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libavformat.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libavutil.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libavutil.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libcrypto.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libcrypto.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libfreetype.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libfreetype.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libfribidi.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libfribidi.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libharfbuzz.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libharfbuzz.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libmpv.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libmpv.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libssl.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libssl.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libswresample.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libswresample.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libswscale.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libswscale.a
vendored
Normal file
Binary file not shown.
BIN
Vendor/mpv/iOS/lib/libuchardet.a
vendored
Normal file
BIN
Vendor/mpv/iOS/lib/libuchardet.a
vendored
Normal file
Binary file not shown.
1969
Vendor/mpv/include/client.h
vendored
Normal file
1969
Vendor/mpv/include/client.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user