Compare commits

...

95 Commits

Author SHA1 Message Date
Arkadiusz Fal
d344ab15dc Bump build number 2022-05-29 22:52:29 +02:00
Arkadiusz Fal
888813f095 Minor fixes 2022-05-29 22:51:52 +02:00
Arkadiusz Fal
a0a6020ee6 Add close video button to browser controls 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
e3e68cd158 Minor improvements 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
42e56d6e4b Navigation improvements 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
f979af7f01 New playlist navigation 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
124a48812a New channel navigation 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
44e6c28fd4 Add buttons to next video and restart video (fix #106)
Previous video requires rebuilding queue a little, maybe in the future
2022-05-29 22:20:38 +02:00
Arkadiusz Fal
4f6e0e2a3d Don't push MPV to play HLS on changing backends
It takes longer to load than WEBM
2022-05-29 22:20:38 +02:00
Arkadiusz Fal
ccc1cc89ad Add Open in PiP option (fix #137) 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
5d0eb2478c Fix playing video from start when history disabled 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
c28429ec7f Minor improvements 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
6c3a7882e1 Player animation improvements 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
76286e8a45 Fix orientation (#121) 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
c5128f6227 Minor improvements 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
4bf171637b Player overlaying other views and swipe gesture (fix #44, #130) 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
249adab427 Remove some default Favorites 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
a27ebcce27 More controls improvements 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
b5f3a1bd09 Minor player controls improvements (fix #94) 2022-05-29 21:21:17 +02:00
Arkadiusz Fal
b306819af9 Update build and version for TestFlight 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
aa29688b4c Bump build and version number 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
371e6a275b Fix browser controls play button disabled state 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
0b7e9f8c47 PiP improvements 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
680fe915e1 Don't skip segments that start before 4 seconds
To minimize buffering
2022-05-27 23:05:09 +02:00
Arkadiusz Fal
1e7ebe4e68 Fix #126 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
1a230d36f7 Improve stream control on macOS 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
60e1cef84f Prefer VP9/WEBM over H.264/MP4 (fix #128) 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
1150a01496 Add PiP for iOS 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
e5c9e11b17 Minor improvements to controls 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
fb3d64bcc7 Fix rate button 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
dec670b7b7 Lint 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
ea4dc2358b Add resolution 8K 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
fca7b7a1a7 Run play action async 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
ea0db9533a Fix using Watch history in player queue 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
db85be76f7 Throttle SponsorBlock seek 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
5cb2e1b8f0 Add rate change selector 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
387f29e395 Restore last played item into queue only if it's not in there yet 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
9ae8c85f4e Improve subscriptions count
Piped API now includes it in the streams response, no need for separate
query
2022-05-27 23:05:09 +02:00
Arkadiusz Fal
c94a35d2c6 Add resolutions for 50fps and 48fps (fix #120) 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
aacbd7889c Add hd2160p60fps resolution (fix #118) 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
d8020d06dd Fix player size handling 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
c556f0e21d Minor fix 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
ff1f62b6ad Bump build and version number 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
7cb4e0dccf Fix #86 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
3229e3151f Improve EOF handling 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
53103c9ecf Try to patch #78
Issue appears when app switches layout from tab to sidebar navigation
2022-05-27 23:05:09 +02:00
Arkadiusz Fal
ac53deb39b Limit formats available to AVPlayer 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
cab6f486ba Fullscreen handling changes 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
40e16fcc7e Remove redunant update of player size 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
9c687f8704 Fix project settings 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
340ceff131 Improve keyboard shortcuts 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
76a116659e Minor fixes 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
7c009080a5 Fix optional 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
61d7c53a58 Bump build and version number 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
08e65aff0f Controls fixes 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
722616e3d0 tvOS fixes 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
0826f01150 Close fullscreen and restore portrait on closing player 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
fcb9ec1f6a Improve streams quality settings 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
5bb1589159 Add tvOS mpv libraries 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
60e5cc5d75 Fix player window on Mac 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
bc410ad6ba Minor improvements 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
35c358de6b Bump version number 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
23955699dc Add toggle for dislikes 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
742103e4c2 Bump version number 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
6173f4610b Minor fixes 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
1217acf264 Add ReturnYoutubeDislike API 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
0b8d887cef Fixes for MPV in macOS 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
b760f172d0 Fix EOF handler 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
bb8fc78760 Minor improvements 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
d2317f725f Add hide player button cancel action 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
693c3364b0 Prevent multiple seeks 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
18bfc1abc9 Add Now Playing info center updates 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
717830ee99 Hello, mpv! 🎉 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
2b001ec96c Reorganize toolbars placement 2022-05-27 23:04:20 +02:00
Arkadiusz Fal
ebeb5bc520 Update version and iOS Info.plist for Testflight 2022-05-27 23:04:20 +02:00
Arkadiusz Fal
7a7e265ba1 Update build and version numbers 2022-05-27 22:59:10 +02:00
Arkadiusz Fal
c086112a49 Update README 2022-05-25 23:11:38 +02:00
Arkadiusz Fal
0802fe0029 Fix #133 2022-05-25 09:23:34 +02:00
Arkadiusz Fal
40813c2859 Update build number for Testflight 2022-05-25 09:14:27 +02:00
Arkadiusz Fal
b1238869a6 Update README 2022-05-22 23:27:59 +02:00
Arkadiusz Fal
453a5fa71b Update README 2022-05-22 23:23:05 +02:00
Arkadiusz Fal
86be252bd5 Bump build and version number 2022-05-22 18:45:19 +02:00
Bharathi
20f96fb9d6 Update README.md
Change in Piped "User Playlists" availability
2022-05-22 18:16:01 +02:00
Arkadiusz Fal
e539fb0067 Remaining playlists fixes 2022-05-22 18:08:14 +02:00
Arkadiusz Fal
03d5eefab0 Reload playlist on adding video
In case video was added to the saame playlist
2022-05-22 00:36:25 +02:00
Arkadiusz Fal
0bc4a677d4 Create/delete Piped playlists and add/remove videos to Piped playlists 2022-05-22 00:30:10 +02:00
Arkadiusz Fal
b374f82da4 Update package 2022-05-21 23:01:04 +02:00
Arkadiusz Fal
b70697e1be Improve subscriptions count
Piped API now includes it in the streams response, no need for separate
query
2022-04-16 20:05:20 +02:00
Arkadiusz Fal
db5765a84b Disable placeholder channel link 2022-04-16 20:03:25 +02:00
Arkadiusz Fal
8d36f57271 Preliminary support for Piped playlist (listing playlists and videos) 2022-04-10 17:07:10 +02:00
Arkadiusz Fal
836057578f Minor changes 2022-04-02 14:34:06 +02:00
Arkadiusz Fal
e39f4373bb Fix crashes (#69, #71) 2022-03-31 20:39:02 +02:00
Arkadiusz Fal
1490437537 Update README 2022-03-28 21:30:31 +02:00
Arkadiusz Fal
4f1b52826d Fix #109 2022-03-28 21:26:52 +02:00
Arkadiusz Fal
15e62468bb Update README 2022-03-27 23:13:53 +02:00
153 changed files with 9108 additions and 1746 deletions

3
.gitignore vendored
View File

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

View File

@@ -0,0 +1,7 @@
import Foundation
extension Comparable {
func clamped(to limits: ClosedRange<Self>) -> Self {
min(max(self, limits.lowerBound), limits.upperBound)
}
}

View File

@@ -3,6 +3,6 @@ import AppKit
extension NSTextField {
override open var focusRingType: NSFocusRingType {
get { .none }
set {} // swiftlint:disable:this unused_setter_value
set {}
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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) {}
}

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

View 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, &params) < 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()
}

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

View 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"
}
}
}

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

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
}
var duration: TimeInterval {
videoDuration ?? video.length
videoDuration ?? video?.length ?? .zero
}
var shouldRestartPlaying: Bool {

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import Foundation
final class RecentsModel: ObservableObject {
@Default(.recentlyOpened) var items
@Default(.saveRecents) var saveRecents
func clear() {
items = []
}

View 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 ?? "")"
}
}

View File

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

View File

@@ -17,6 +17,10 @@ class Segment: ObservableObject, Hashable {
segment.last!
}
var duration: Double {
end - start
}
var endTime: CMTime {
CMTime(seconds: end, preferredTimescale: 1000)
}

View File

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

View File

@@ -22,4 +22,8 @@ final class Store<Data>: ResourceObserver, ObservableObject {
func replace(_ items: Data) {
all = items
}
func clear() {
all = nil
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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) {}
}

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

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

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

View 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), &params)
}
}
}
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}

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

View File

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

View File

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

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -15,7 +15,7 @@ struct PopularView: View {
}
var body: some View {
PlayerControlsView {
BrowserPlayerControls {
VerticalCells(items: videos)
.onAppear {
resource?.addObserver(store)

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ struct SubscriptionsView: View {
}
var body: some View {
PlayerControlsView {
BrowserPlayerControls {
SignInRequiredView(title: "Subscriptions") {
VerticalCells(items: videos)
.onAppear {

View File

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

View File

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

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libavcodec.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libavdevice.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libavfilter.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libavformat.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libavutil.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libcrypto.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libfreetype.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libfribidi.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libharfbuzz.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libmpv.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libssl.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libswresample.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libswscale.a vendored Normal file

Binary file not shown.

BIN
Vendor/mpv/iOS/lib/libuchardet.a vendored Normal file

Binary file not shown.

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